Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec3428f78 | ||
|
|
4a5e4b76e3 | ||
|
|
43f952b2fb | ||
|
|
0d86bec34d | ||
|
|
47a6f29ad7 | ||
|
|
84834f180e | ||
|
|
bfd445618a | ||
|
|
eddfd37b93 | ||
|
|
d7341863bf | ||
|
|
2f51be7b5a | ||
|
|
fbc0014d14 | ||
|
|
46df6171fc | ||
|
|
761b62914f | ||
|
|
91b0299f77 | ||
|
|
6d08e59d10 | ||
|
|
83cc0fe585 | ||
|
|
c711c627b8 | ||
|
|
0b34690721 | ||
|
|
b640734db5 | ||
|
|
f8feb63dde | ||
|
|
25a5d3fe5d | ||
|
|
41dd93fd25 | ||
|
|
adf3c9405d | ||
|
|
abf4f57b47 | ||
|
|
c1e36975d4 | ||
|
|
0bfa0915d4 | ||
|
|
4da87672a2 | ||
|
|
3ba0d287e9 | ||
|
|
b4a86cb5bb | ||
|
|
0fa1f2f868 | ||
|
|
71610ddfdb | ||
|
|
5c9e683769 | ||
|
|
32a5e83b88 | ||
|
|
88904c08f7 | ||
|
|
9928bfe780 | ||
|
|
a491dec5c4 | ||
|
|
17ef33ee31 | ||
|
|
06caaee343 | ||
|
|
1c161a76dd | ||
|
|
aa05ab03fa | ||
|
|
43ea3f3181 | ||
|
|
3966b27948 | ||
|
|
7e24327621 | ||
|
|
d025aa3c6d | ||
|
|
325f6893c5 | ||
|
|
466419407d | ||
|
|
16a9d0af2a | ||
|
|
e51698e01f | ||
|
|
519091dd86 | ||
|
|
972088f1b3 | ||
|
|
542d31daf0 | ||
|
|
8f9a849c48 | ||
|
|
e8bfe6e520 | ||
|
|
15618f357b | ||
|
|
b74010435a | ||
|
|
0a58a7faee | ||
|
|
0d21e2f913 | ||
|
|
ebb82d2dc9 | ||
|
|
557e346810 | ||
|
|
08b2cf0894 | ||
|
|
11d997dc65 | ||
|
|
cdd5c99bc7 | ||
|
|
fb1da1c39a | ||
|
|
b29ece82c8 | ||
|
|
0e1f6f15a1 | ||
|
|
48054a86ab | ||
|
|
5547391cfc | ||
|
|
05933a5ee3 | ||
|
|
c96f2d1070 | ||
|
|
f58bf9b23c | ||
|
|
d59734e375 | ||
|
|
8cfd54a739 |
No files matched your search
@@ -42,4 +42,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
# A component the apk fetches by tag, never something users install: it must not
|
||||
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
@@ -43,4 +43,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber
|
||||
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "wlroots 0.20.2 with tools/wlroots/patches (Vulkan DMA-BUF allocator for non-DRM GPU nodes); staged by the apk at /usr/local/lib/droiddeck-wlroots for the desktop's GPU renderers. sha256 $(cut -d' ' -f1 wlroots.tzst.sha256)" wlroots.tzst wlroots.tzst.sha256 || gh release upload "${{ inputs.tag }}" wlroots.tzst wlroots.tzst.sha256 --clobber
|
||||
# A component the apk fetches by tag, never something users install: it must not
|
||||
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
|
||||
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/mangoapp/*') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -176,6 +176,9 @@ jobs:
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
|
||||
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
|
||||
done
|
||||
@@ -208,9 +211,13 @@ jobs:
|
||||
rm mango-pkgs/pkg.tar.zst
|
||||
done
|
||||
install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
|
||||
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
|
||||
for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
|
||||
cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
|
||||
done
|
||||
# Ours, not the package's: GPU memory without tracefs (tools/mangoapp/libtracefs-shim.c).
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
|
||||
-o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
|
||||
@@ -88,7 +88,8 @@ jobs:
|
||||
esac
|
||||
fi
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null
|
||||
jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null ||
|
||||
{ echo "::error::PR #$PR_NUMBER must be open, ready for review, and target main"; exit 1; }
|
||||
head=$(jq -r '.head.sha' <<<"$pr")
|
||||
base=$(jq -r '.base.sha' <<<"$pr")
|
||||
|
||||
@@ -114,36 +115,39 @@ jobs:
|
||||
sleep 30
|
||||
done
|
||||
[[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 30 minutes'; exit 1; }
|
||||
# GitHub leaves a run's pull_requests empty when its head is in a fork, so the build is
|
||||
# tied to this PR by its head repository, branch and commit instead.
|
||||
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
|
||||
jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" '
|
||||
jq -e --arg head "$head" --arg repo "$(jq -r .head.repo.full_name <<<"$pr")" --arg ref "$(jq -r .head.ref <<<"$pr")" '
|
||||
.event == "pull_request" and .head_sha == $head and .conclusion == "success" and
|
||||
any(.pull_requests[]?; .number == $number and .head.sha == $head and .base.sha == $base)
|
||||
' <<<"$run" >/dev/null
|
||||
.head_repository.full_name == $repo and .head_branch == $ref
|
||||
' <<<"$run" >/dev/null ||
|
||||
{ echo "::error::Build run $run_id is not a green build of PR #$PR_NUMBER at ${head:0:7}"; exit 1; }
|
||||
|
||||
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
|
||||
tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
|
||||
stable_tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
|
||||
mkdir -p "$RUNNER_TEMP/stable" "$RUNNER_TEMP/pr"
|
||||
gh release download "$stable_tag" -R "$GITHUB_REPOSITORY" \
|
||||
-p "DroidDeck-$stable_tag.apk" -D "$RUNNER_TEMP/stable"
|
||||
# A PR build is made from the PR merged onto main, so it must install over main's own
|
||||
# build. main is what this job checked out; no release has to exist or be marked latest.
|
||||
main_code=$(sed -n 's/^[[:space:]]*versionCode[[:space:]][[:space:]]*\([0-9][0-9]*\)[[:space:]]*$/\1/p' app/build.gradle)
|
||||
[[ "$main_code" =~ ^[0-9]+$ ]] || { echo "::error::No numeric versionCode in main's app/build.gradle"; exit 1; }
|
||||
mkdir -p "$RUNNER_TEMP/pr"
|
||||
gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D "$RUNNER_TEMP/pr"
|
||||
mapfile -t apks < <(find "$RUNNER_TEMP/pr" -type f -name '*.apk')
|
||||
[[ ${#apks[@]} == 1 ]] || { echo '::error::Expected one PR APK'; exit 1; }
|
||||
package_line() {
|
||||
"$tools/aapt" dump badging "$1" | sed -n "s/^package: name='\\([^']*\\)' versionCode='\\([^']*\\)'.*/\\1 \\2/p"
|
||||
}
|
||||
stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk")
|
||||
candidate=$(package_line "${apks[0]}")
|
||||
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ &&
|
||||
${candidate##* } -ge ${stable##* } ]] || {
|
||||
echo "::error::PR package/versionCode '$candidate' cannot install over stable '$stable'"; exit 1;
|
||||
[[ "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && ${candidate##* } -ge $main_code ]] || {
|
||||
echo "::error::PR package/versionCode '$candidate' cannot install over main (versionCode $main_code)"; exit 1;
|
||||
}
|
||||
|
||||
current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
jq -e --arg head "$head" --arg base "$base" '
|
||||
.state == "open" and .draft == false and .base.ref == "main" and
|
||||
.head.sha == $head and .base.sha == $base
|
||||
' <<<"$current" >/dev/null
|
||||
' <<<"$current" >/dev/null ||
|
||||
{ echo "::error::PR #$PR_NUMBER moved on while its build was checked; publish again"; exit 1; }
|
||||
{
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "run_id=$run_id"
|
||||
|
||||
@@ -62,3 +62,59 @@ jobs:
|
||||
label: ${{ inputs.version }}
|
||||
artifact: droiddeck-release
|
||||
secrets: inherit
|
||||
|
||||
# The release is made here, from the apks this run signed, so it traces back to its Build APK run.
|
||||
publish:
|
||||
name: publish the release
|
||||
needs: [find, sign]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-release-*
|
||||
merge-multiple: true
|
||||
path: out
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
BUILD_RUN: ${{ needs.find.outputs.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
notes="docs/releases/$VERSION.md"
|
||||
[ -f "$notes" ] || { echo "::error::$notes is missing: add the release notes first"; exit 1; }
|
||||
if gh release view "$VERSION" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::error::release $VERSION already exists"; exit 1
|
||||
fi
|
||||
# The notes' first line is the title; media paths become raw URLs at this release's tag.
|
||||
title=$(sed -n '1s/^# *//p' "$notes")
|
||||
[ -n "$title" ] || title="DroidDeck $VERSION"
|
||||
raw="https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$VERSION/docs/releases/media/"
|
||||
{
|
||||
sed '1{/^# /d;}' "$notes" | sed -E "s#(\]\(|src=\")media/#\1$raw#g"
|
||||
echo
|
||||
echo "---"
|
||||
echo
|
||||
echo "Built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$GITHUB_SHA\`, signed and published by [Release APKs run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)."
|
||||
} > "$RUNNER_TEMP/body.md"
|
||||
( cd out && cat SHA256SUMS-*.txt | sort -k2 > SHA256SUMS.txt && rm SHA256SUMS-*.txt && cat SHA256SUMS.txt )
|
||||
gh release create "$VERSION" -R "$GITHUB_REPOSITORY" out/*.apk out/SHA256SUMS.txt \
|
||||
--target "$GITHUB_SHA" --title "$title" --notes-file "$RUNNER_TEMP/body.md" --latest
|
||||
echo "Published [$title]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$VERSION)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# A release made with this run's token fires no release event, so the in-app catalog is refreshed here.
|
||||
catalog:
|
||||
needs: publish
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: inherit
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 9
|
||||
versionName "0.2.0"
|
||||
versionCode 10
|
||||
versionName "0.3.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -190,6 +190,7 @@ dependencies {
|
||||
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
|
||||
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -750,6 +750,8 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
|
||||
static struct surface *g_hud_surface;
|
||||
/* When the HUD's window last presented a frame (now_ns), to let a replacement take over. */
|
||||
static int64_t g_hud_last_ns;
|
||||
/* That window committed a frame the screen has not drawn yet. */
|
||||
static int g_hud_fresh;
|
||||
extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
|
||||
extern void banner_on_game_frame(void);
|
||||
/* The program behind that window: its Linux pid (the Wayland client's credentials) and executable name
|
||||
@@ -852,7 +854,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
|
||||
* Asking whether the compositor could import it, as this used to, is a question about the
|
||||
* copy path and not about whether a frame happened: a game whose buffers go straight to
|
||||
* the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */
|
||||
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); banner_on_game_frame(); }
|
||||
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; banner_on_game_frame(); }
|
||||
}
|
||||
|
||||
/* ---- hooks for ahb_swapchain.c (zero-copy layers) */
|
||||
@@ -2021,6 +2023,7 @@ static void render_scene(void) {
|
||||
int copy = 0; /* this scene went through the screen swapchain (the perf line's "copy") */
|
||||
|
||||
g_dirty = 0;
|
||||
g_hud_fresh = 0;
|
||||
g_hdr_unimported = NULL; /* found again while the scene is built (HDR gate open only) */
|
||||
wl_list_for_each(s, &g_surfaces, link) s->drawn = 0;
|
||||
scene_size(&w, &h);
|
||||
@@ -2236,6 +2239,13 @@ static void schedule_render(void) {
|
||||
wl_event_source_timer_update(g_fallback_timer, 8);
|
||||
}
|
||||
|
||||
/* With frame generation on, every scene is the engine's next source frame, so one drawn for
|
||||
* another window (Steam's overlay) counted as a game frame and took its present slots. Such a
|
||||
* change waits for the game's next frame, unless the game has been still for 100 ms. */
|
||||
static int scene_waits_for_game(int64_t now) {
|
||||
return g_hud_surface && !g_hud_fresh && now - g_hud_last_ns < 100000000LL && vkp_framegen_active();
|
||||
}
|
||||
|
||||
/* A screen refresh (Choreographer tick): draw the newest state once. */
|
||||
static void on_vsync(int64_t frame_time_ns) {
|
||||
int64_t now = now_ns();
|
||||
@@ -2251,7 +2261,7 @@ static void on_vsync(int64_t frame_time_ns) {
|
||||
if (vkp_apply_window_request()) g_dirty = 1;
|
||||
/* A screen-effect setting changed (JNI, any thread): redraw so it shows on a static scene too. */
|
||||
if (vkp_effects_sync()) g_dirty = 1;
|
||||
if (g_dirty) render_scene();
|
||||
if (g_dirty && !scene_waits_for_game(now)) render_scene();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ wl_seat */
|
||||
|
||||
@@ -14,7 +14,7 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs: a new install's first read decides it.
|
||||
// Before anything else touches the session prefs, so the first session already runs in Deck mode.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
|
||||
@@ -137,6 +137,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var prootFastPath by mutableStateOf(true)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(null)
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
@@ -147,7 +148,6 @@ class MainActivity : ComponentActivity() {
|
||||
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
|
||||
@@ -246,9 +246,6 @@ 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)
|
||||
@@ -340,10 +337,11 @@ class MainActivity : ComponentActivity() {
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
@@ -391,7 +389,6 @@ class MainActivity : ComponentActivity() {
|
||||
showPhantomGate = showPhantomGate,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
storeEnabled = storeEnabled,
|
||||
appImagesEnabled = appImagesEnabled,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
@@ -411,18 +408,15 @@ 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
|
||||
// An app added on the Desktop page, full screen under gamescope like an emulator.
|
||||
onUserApp = { app ->
|
||||
app.args.firstOrNull()?.let { Library.flatpakNames[it] = app.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)))
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, app.program)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, app.args.toTypedArray()))
|
||||
},
|
||||
// A Flatpak app from the store, full screen under gamescope like an emulator.
|
||||
onFlatpakApp = { id, name ->
|
||||
Library.flatpakNames[id] = name
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
@@ -498,7 +492,6 @@ class MainActivity : ComponentActivity() {
|
||||
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)
|
||||
@@ -1052,7 +1045,7 @@ class MainActivity : ComponentActivity() {
|
||||
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
|
||||
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
|
||||
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
|
||||
@@ -1062,6 +1055,7 @@ class MainActivity : ComponentActivity() {
|
||||
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
|
||||
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
|
||||
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
|
||||
onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on },
|
||||
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
|
||||
onClientCore = { core, on ->
|
||||
clientCores = if (on) clientCores + core else clientCores - core
|
||||
@@ -1076,10 +1070,20 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */
|
||||
/** The added games for the session settings: a folder walk, so off the main thread. */
|
||||
private fun refreshAddedGames() {
|
||||
addedGames = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
Thread({
|
||||
val games = scanAddedGames()
|
||||
ui.post { addedGames = games; fetchAddedGameArt() }
|
||||
}, "added-games").start()
|
||||
}
|
||||
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
// Art the games do not have yet, from Steam's store, off the main thread; the rail
|
||||
// redraws when something arrives.
|
||||
if (SessionPrefs.addedGamesArt(this) && !artFetchRunning) {
|
||||
@@ -1100,7 +1104,6 @@ class MainActivity : ComponentActivity() {
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
drivers.refreshDrivers()
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
customResolution = SessionPrefs.customResolution(this, mode)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
@@ -1110,7 +1113,6 @@ class MainActivity : ComponentActivity() {
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
refreshAddedGames()
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
@@ -1126,13 +1128,26 @@ class MainActivity : ComponentActivity() {
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
renderer = SessionPrefs.desktopRenderer(this)
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageOptions = GameStorage.options(this).map { it.label to it.path }
|
||||
if (mode == SessionService.MODE_STEAM) {
|
||||
decky.deckyInstalled = DeckyManager.installed(this)
|
||||
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
|
||||
decky.refreshDecky()
|
||||
}
|
||||
settingsMode = mode
|
||||
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val games = scanAddedGames()
|
||||
val storage = GameStorage.options(this).map { it.label to it.path }
|
||||
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
|
||||
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
|
||||
ui.post {
|
||||
addedGames = games
|
||||
storageOptions = storage
|
||||
if (mode == SessionService.MODE_STEAM) {
|
||||
decky.deckyInstalled = deckyInstalled
|
||||
decky.deckySupervisor = deckySupervisor
|
||||
decky.refreshDecky()
|
||||
}
|
||||
fetchAddedGameArt()
|
||||
}
|
||||
}, "mode-settings").start()
|
||||
}
|
||||
|
||||
/** A second Steam library, proven writable first; "" = internal only. */
|
||||
@@ -1162,6 +1177,7 @@ class MainActivity : ComponentActivity() {
|
||||
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
prootFastPath = SessionPrefs.prootFastPath(this)
|
||||
guestHostname = SessionPrefs.guestHostname(this)
|
||||
refreshPhantomStatus()
|
||||
}
|
||||
@@ -1184,6 +1200,9 @@ class MainActivity : ComponentActivity() {
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
Thread({
|
||||
// One update with the whole list: the wall places games by their position in it, so a
|
||||
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
|
||||
// covers the wait.
|
||||
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(
|
||||
@@ -1194,7 +1213,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
} else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
ui.post { steamGames = games.distinctBy { it.gameId }; emulatorList = emus }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
ui.post { steamGames = all; emulatorList = emus }
|
||||
// Box art for the games that have none, fetched after the list is up; the list is
|
||||
// rebuilt once if any was found.
|
||||
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
|
||||
|
||||
@@ -181,6 +181,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
|
||||
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
|
||||
private var quitFlooded = false
|
||||
/** Leaving behind a flood spreading out of the throbber (FloodSpread); see finish(). */
|
||||
private var leaving by mutableStateOf(false)
|
||||
/** Close without the leaving flood: a retry opens a fresh session at once. */
|
||||
private var closeAtOnce = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerPage by mutableIntStateOf(0)
|
||||
@@ -369,6 +373,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun createSessionOverlay(): ComposeView = ComposeView(this).apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
// Compose draws each control's focus ring. Android's default highlight would cover
|
||||
// this full-screen host and remain as a white veil after the drawer closes.
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
@@ -497,6 +504,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
title = pausedTitle(),
|
||||
onResume = { SessionService.resume(this@SessionActivity) },
|
||||
)
|
||||
if (leaving) com.droiddeck.launcher.ui.FloodSpread(
|
||||
com.droiddeck.launcher.ui.LocalPalette.current.signal,
|
||||
ball = if (loading.visible) com.droiddeck.launcher.ui.ThrobberSpot.ball else null,
|
||||
) { completeLeave() }
|
||||
quitFrom?.let { from ->
|
||||
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
|
||||
com.droiddeck.launcher.ui.LaunchFlood(
|
||||
@@ -1059,6 +1070,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** The same start again, in a fresh activity: the ended one keeps no session to reuse. */
|
||||
private fun retrySession() {
|
||||
val again = Intent(intent)
|
||||
closeAtOnce = true
|
||||
finish()
|
||||
startActivity(again)
|
||||
}
|
||||
@@ -1732,14 +1744,41 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
finish()
|
||||
}
|
||||
|
||||
/** Leaving the session sinks its surface back down onto the front end. */
|
||||
/**
|
||||
* Every way out on screen (backing out of the loading screen, closing the ended screen, the
|
||||
* session ending) leaves as a session arrives, in reverse: the blue spreads out of the
|
||||
* throbber's ball, or the middle of the screen once a game is up, and the front end opens on it
|
||||
* and draws it back into the button the session came from. Off screen, or with animations
|
||||
* off, it just closes.
|
||||
*/
|
||||
override fun finish() {
|
||||
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
|
||||
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
|
||||
if (!leaving) {
|
||||
leaving = true
|
||||
// Should the flood never get its frames, the session still closes.
|
||||
uiHandler.postDelayed({ completeLeave() }, LEAVE_TIMEOUT_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
super.finish()
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
|
||||
/** The screen is all blue: close onto the front end, which opens on the same blue. */
|
||||
private fun completeLeave() {
|
||||
if (isFinishing) return
|
||||
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
|
||||
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
|
||||
quitFlooded = true
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** How long the leaving flood may take before the session closes without it. */
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TAG = "SessionActivity"
|
||||
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
|
||||
private const val CURSOR_PAD_HOLD_MS = 1200L
|
||||
|
||||
@@ -154,6 +154,7 @@ object DeviceReport {
|
||||
k("Skip xalia", SessionPrefs.noXalia(context))
|
||||
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("proot fast path", SessionPrefs.prootFastPath(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
|
||||
@@ -96,11 +96,33 @@ object AddedGames {
|
||||
)
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders under a library's steamapps/common that a manifest beside it already claims.
|
||||
* Steam lists those itself, so a shortcut would only add a non-Steam copy of the game. Steam
|
||||
* may lowercase installdir on Android's case-insensitive storage, so names compare lowercased.
|
||||
*/
|
||||
internal fun steamInstallDirs(dir: File): Set<String> {
|
||||
val steamapps = dir.parentFile?.takeIf { dir.name == "common" && it.name == "steamapps" } ?: return emptySet()
|
||||
return steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
|
||||
.orEmpty()
|
||||
.mapNotNull { manifest ->
|
||||
runCatching { INSTALL_DIR.find(manifest.readText())?.groupValues?.get(1)?.trim()?.lowercase() }.getOrNull()
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private val INSTALL_DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
val candidates = candidates(folder)
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SecondaryLibrary
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -84,7 +85,11 @@ object Library {
|
||||
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
|
||||
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
|
||||
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first })
|
||||
val secondary = GameStorage.effective(context)?.let { File(it.path) }
|
||||
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
|
||||
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
|
||||
else root
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
|
||||
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
|
||||
return roots.asSequence()
|
||||
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
|
||||
@@ -167,11 +172,12 @@ object Library {
|
||||
|
||||
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
|
||||
fun nameForProgram(program: String?): String? =
|
||||
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
|
||||
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER ||
|
||||
program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER) {
|
||||
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. */
|
||||
/** Added apps' names by Flatpak id or directory, as launched: the session only knows that. */
|
||||
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The last game list the front end showed, kept in the app's files so the next start draws the
|
||||
* Steam wall on its first frame. Building the list reads every manifest and walks the added-games
|
||||
* folders, which can take a second or more; the fresh list replaces this one when it is ready.
|
||||
*/
|
||||
object LibraryCache {
|
||||
private const val TAG = "LibraryCache"
|
||||
private fun file(context: Context) = File(context.filesDir, "library-cache.json")
|
||||
|
||||
fun load(context: Context): List<Library.SteamGame> = runCatching {
|
||||
val f = file(context)
|
||||
if (!f.isFile) return emptyList()
|
||||
val array = JSONArray(f.readText())
|
||||
List(array.length()) { i ->
|
||||
val o = array.getJSONObject(i)
|
||||
Library.SteamGame(
|
||||
appId = o.getInt("appId"), name = o.getString("name"), art = o.file("art"),
|
||||
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
|
||||
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
|
||||
)
|
||||
}
|
||||
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
|
||||
|
||||
fun save(context: Context, games: List<Library.SteamGame>) {
|
||||
runCatching {
|
||||
val array = JSONArray()
|
||||
for (g in games) array.put(
|
||||
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
|
||||
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
|
||||
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix),
|
||||
)
|
||||
val f = file(context)
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
tmp.renameTo(f)
|
||||
}.onFailure { e -> Log.w(TAG, "not saved: ${e.message}") }
|
||||
}
|
||||
|
||||
private fun JSONObject.file(key: String): File? = optString(key).takeIf { it.isNotEmpty() }?.let(::File)
|
||||
private fun JSONObject.putFile(key: String, f: File?): JSONObject = if (f == null) this else put(key, f.path)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.StatFs
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
@@ -21,6 +23,7 @@ object AppImageManager {
|
||||
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
|
||||
private const val ELF_AARCH64 = 183
|
||||
private const val ELF_X86_64 = 62
|
||||
private const val SHARP_ICON = 128
|
||||
|
||||
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
|
||||
val guestDir: String get() = "$GUEST_DIR/$id"
|
||||
@@ -74,8 +77,15 @@ object AppImageManager {
|
||||
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? {
|
||||
fun dir(context: Context, id: String) = File(root(context), id)
|
||||
|
||||
/**
|
||||
* Imports [file]; null on success, else what went wrong. [name] and [icon] replace the image's own;
|
||||
* each of [meta] is written as a file beside them.
|
||||
*/
|
||||
fun import(
|
||||
context: Context, file: File, name: String?, icon: File?, meta: Map<String, String> = emptyMap(), 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() }
|
||||
@@ -83,9 +93,45 @@ object AppImageManager {
|
||||
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 dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
|
||||
try {
|
||||
extract(context, dir, file, onProgress)?.let { FileUtils.delete(dir); return it }
|
||||
describe(dir, file)
|
||||
if (name != null) FileUtils.writeString(File(dir, "name"), name)
|
||||
if (icon != null && !UserApps.saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
|
||||
writeMenuEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import ${file.name}", e)
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: "Import failed"
|
||||
}
|
||||
}
|
||||
|
||||
/** Swaps the program of imported [id] for [file], keeping its name and icon; the old one stays if this fails. */
|
||||
fun replace(context: Context, id: String, file: File, meta: Map<String, String>, onProgress: (String) -> Unit): String? {
|
||||
problem(file)?.let { return it }
|
||||
val dir = dir(context, id)
|
||||
if (!File(dir, "app").isDirectory) return "That AppImage is gone"
|
||||
if (StatFs(dir.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: updating needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
}
|
||||
return try {
|
||||
extract(context, dir, file, onProgress) ?: run {
|
||||
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "replace $id", e)
|
||||
e.message ?: "Update failed"
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts [file] to [dir]/app, replacing what is there only once the extraction has worked. */
|
||||
private fun extract(context: Context, dir: File, file: File, onProgress: (String) -> Unit): String? {
|
||||
val image = File(dir, "image.AppImage")
|
||||
val links = File(dir, ".links").apply { mkdirs() }
|
||||
try {
|
||||
dir.mkdirs()
|
||||
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
|
||||
@@ -94,27 +140,41 @@ object AppImageManager {
|
||||
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()
|
||||
// uruntime's DwarFS images unpack into AppDir with squashfs-root a link to it, and keep
|
||||
// one program under several names as hard links (sharun), which Android denies apps.
|
||||
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) }
|
||||
"cd \"$1\" && rm -rf squashfs-root AppDir && ./image.AppImage --appimage-extract >/dev/null && " +
|
||||
"rm -rf app && mv \"$(readlink -f squashfs-root)\" app && rm -f squashfs-root",
|
||||
"extract", "$GUEST_DIR/${dir.name}",
|
||||
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
|
||||
if (status != 0 || !File(dir, "app").isDirectory) {
|
||||
FileUtils.delete(dir)
|
||||
listOf("squashfs-root", "AppDir").forEach { FileUtils.delete(File(dir, it)) }
|
||||
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
|
||||
}
|
||||
image.delete()
|
||||
describe(dir, file)
|
||||
writeMenuEntry(context, dir)
|
||||
copyLinkedFiles(File(dir, "app"), links)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import ${file.name}", e)
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: "Import failed"
|
||||
} finally {
|
||||
image.delete()
|
||||
FileUtils.delete(links)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each name proot's link2symlink left for a hard link becomes a file of its own: a program run
|
||||
* through a link would see the shared file's name, and sharun picks what to start by its own.
|
||||
*/
|
||||
private fun copyLinkedFiles(app: File, links: File) {
|
||||
val roots = setOf(links.path + "/", links.canonicalPath + "/")
|
||||
app.walkTopDown().onEnter { !Files.isLink(it) }.forEach { f ->
|
||||
if (!Files.isLink(f)) return@forEach
|
||||
val target = runCatching { Os.readlink(f.path) }.getOrNull() ?: return@forEach
|
||||
if (roots.none { target.startsWith(it) }) return@forEach
|
||||
val data = f.toPath().toRealPath()
|
||||
java.nio.file.Files.delete(f.toPath())
|
||||
java.nio.file.Files.copy(data, f.toPath())
|
||||
if (java.nio.file.Files.isExecutable(data)) f.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,14 +188,32 @@ object AppImageManager {
|
||||
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)
|
||||
restoreIcon(dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* The image's own icon as [dir]'s: of the files its entry names, beside it or anywhere in its
|
||||
* icon theme, a PNG of at least [SHARP_ICON] px, else the SVG, else the largest PNG; .DirIcon
|
||||
* without any.
|
||||
*/
|
||||
internal fun restoreIcon(dir: File) {
|
||||
val app = File(dir, "app")
|
||||
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
|
||||
val iconName = entry?.let { FileUtils.readString(it) }?.let { desktopKey(it, "Icon") }
|
||||
val named = iconName?.let { n ->
|
||||
app.walkTopDown().maxDepth(8).filter { it.isFile && (it.name == "$n.png" || it.name == "$n.svg") }.toList()
|
||||
}.orEmpty()
|
||||
val png = named.filter(::isPng).maxByOrNull(::pngSide)
|
||||
val svg = named.firstOrNull { it.name.endsWith(".svg") }
|
||||
val pick = png?.takeIf { pngSide(it) >= SHARP_ICON } ?: svg ?: png ?: File(app, ".DirIcon").takeIf { it.isFile }
|
||||
val icon = File(dir, "icon.png")
|
||||
if (pick == null || !UserApps.saveIcon(pick, icon)) icon.delete()
|
||||
}
|
||||
|
||||
private fun pngSide(f: File): Int {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(f.path, bounds)
|
||||
return minOf(bounds.outWidth, bounds.outHeight)
|
||||
}
|
||||
|
||||
private fun isPng(f: File): Boolean = try {
|
||||
@@ -159,7 +237,7 @@ object AppImageManager {
|
||||
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) {
|
||||
internal 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;"
|
||||
|
||||
@@ -89,7 +89,7 @@ object FlatpakManager {
|
||||
/** 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)
|
||||
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
|
||||
}
|
||||
|
||||
private inline fun <T> exclusive(what: String, block: () -> T): T? {
|
||||
|
||||
@@ -18,10 +18,11 @@ 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/tools/<logName>.log beside the session logs, so a problem can
|
||||
* be handed over like a session's.
|
||||
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
|
||||
* become symlinks to files proot keeps there (its link2symlink).
|
||||
*/
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
onLine: (String) -> Unit): Int {
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
@@ -29,13 +30,13 @@ object GuestCommand {
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
return runLogged(context, argv, fakeRoot, linkDir) { 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 {
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
@@ -43,11 +44,12 @@ object GuestCommand {
|
||||
LinuxNetworkLinkComponent(context, root).publish()
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
if (linkDir != null) cmd.add(1, "--link2symlink")
|
||||
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}",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
@@ -56,6 +58,7 @@ object GuestCommand {
|
||||
builder.environment().apply {
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
|
||||
put("PROOT_TMP_DIR", context.cacheDir.path)
|
||||
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ public final class LinuxRuntime {
|
||||
/** Shortcut extra naming which of the modes above a Linux entry launches. */
|
||||
public static final String EXTRA_LINUX_MODE = "linux_mode";
|
||||
private static final String KGSL_DEVICE = "/dev/kgsl-3d0";
|
||||
/**
|
||||
* Where the guest sees a command's XDG_RUNTIME_DIR. A Unix socket's path must fit in 108 bytes,
|
||||
* and libwayland checks the guest's path before proot ever sees it: with the app on an SD card
|
||||
* adopted as internal storage the files directory is /mnt/expand/<uuid>/user/0/<package>/files,
|
||||
* and gamescope-0 under it is one byte over, so gamescope could not open its socket and the
|
||||
* session ended at once. A long host path behind it is proot's to shorten.
|
||||
*/
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
@@ -185,7 +193,10 @@ 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());
|
||||
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
|
||||
if (runtimeDir != null) {
|
||||
bind(cmd, runtimeDir.getPath());
|
||||
bind(cmd, runtimeDir.getPath() + ":" + GUEST_RUNTIME_DIR);
|
||||
}
|
||||
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
|
||||
if (externalStorage != null && externalStorage.isDirectory()) {
|
||||
bind(cmd, externalStorage.getPath());
|
||||
@@ -231,12 +242,15 @@ public final class LinuxRuntime {
|
||||
};
|
||||
for (String[] entry : procFiles) {
|
||||
File fake = new File(fakeProc, entry[0]);
|
||||
if (fake.isFile() && !new File(entry[1]).canRead()) {
|
||||
// A live stand-in the session binds itself (CpuStatComponent's /proc/stat) wins.
|
||||
boolean live = false;
|
||||
if (extraBinds != null) for (String spec : extraBinds) live |= spec.endsWith(":" + entry[1]);
|
||||
if (!live && fake.isFile() && !new File(entry[1]).canRead()) {
|
||||
bind(cmd, fake.getPath() + ":" + entry[1]);
|
||||
}
|
||||
}
|
||||
bindGpuNode(context, cmd);
|
||||
bindAdrenoStats(cmd);
|
||||
bindAdrenoStats(cmd, extraBinds);
|
||||
bindCpuTemps(cmd, root);
|
||||
if (extraBinds != null) {
|
||||
for (String spec : extraBinds) bind(cmd, spec);
|
||||
@@ -282,27 +296,84 @@ public final class LinuxRuntime {
|
||||
if (!Files.isSymbolicLink(subsystem.toPath())) {
|
||||
Os.symlink("/sys/bus/platform", subsystem.getPath());
|
||||
}
|
||||
// What MangoHud names a GPU's driver by; msm_drm is how an Adreno's render node reads
|
||||
// on a mainline kernel, and the driver it reads the load of (bindAdrenoStats).
|
||||
File driver = new File(device, "driver");
|
||||
if (!Files.isSymbolicLink(driver.toPath())) {
|
||||
Os.symlink("/sys/bus/platform/drivers/msm_drm", driver.getPath());
|
||||
}
|
||||
} catch (IOException | ErrnoException e) {
|
||||
return;
|
||||
}
|
||||
bind(cmd, new File(base, "sys").getPath() + ":/sys/dev/char");
|
||||
bind(cmd, dri.getPath() + ":/dev/dri");
|
||||
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
|
||||
bindDrmClass(base, cmd, node, major + ":" + minor);
|
||||
}
|
||||
|
||||
/**
|
||||
* MangoHud (Deck mode's performance overlay, mangoapp) finds the GPU by listing
|
||||
* {@code /sys/class/drm}, and an exception from that listing ends the process: under an
|
||||
* enforcing SELinux policy - every retail phone - an app may not list it, so mangoapp died on
|
||||
* start, gamescope restarted it until the session gave up, and there was no overlay. Where the
|
||||
* app cannot list it, the session's {@code /sys/class/drm} is ours: the render node above,
|
||||
* named as an Adreno's is. MangoHud then reads the GPU's stats from KGSL's sysfs, which such a
|
||||
* device refuses too - SessionService's GpuStatsComponent stands in for that. Where the
|
||||
* listing is allowed it is left alone.
|
||||
*/
|
||||
private static void bindDrmClass(File base, List<String> cmd, String node, String devNumbers) {
|
||||
if (new File("/sys/class/drm").list() != null) return;
|
||||
File drmClass = new File(base, "class");
|
||||
try {
|
||||
if (!drmClass.isDirectory() && !drmClass.mkdirs()) return;
|
||||
// Only symlinks are ever made here; deleting them must not follow one into its target.
|
||||
File[] old = drmClass.listFiles();
|
||||
if (old != null) for (File f : old) Files.deleteIfExists(f.toPath());
|
||||
Os.symlink("/sys/dev/char/" + devNumbers, new File(drmClass, node).getPath());
|
||||
} catch (IOException | ErrnoException e) {
|
||||
return;
|
||||
}
|
||||
bind(cmd, drmClass.getPath() + ":/sys/class/drm");
|
||||
android.util.Log.i("LinuxRuntime", "hud: /sys/class/drm is not listable here; the session's lists " + node);
|
||||
// Also refused to list, also asked for by MangoHud twice a second - an error line each
|
||||
// time in the session log - and holding nothing it reads on an Adreno phone.
|
||||
if (new File("/sys/class/powercap").list() == null) {
|
||||
File empty = new File(base, "empty-powercap");
|
||||
if (empty.isDirectory() || empty.mkdirs()) bind(cmd, empty.getPath() + ":/sys/class/powercap");
|
||||
}
|
||||
}
|
||||
|
||||
/** The temperature of a CPU zone the app may read (the first by name), or null. */
|
||||
public static String cpuTempSource() {
|
||||
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(thermalZones()).entrySet()) {
|
||||
File temp = new File(e.getValue(), "temp");
|
||||
if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) return temp.getPath();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The file the GPU's temperature reads from, or null where the app may read none. */
|
||||
public static String gpuTempSource() {
|
||||
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
|
||||
return firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
|
||||
}
|
||||
|
||||
private static String gpuLoadSource() {
|
||||
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
|
||||
return firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load");
|
||||
}
|
||||
|
||||
/**
|
||||
* Valve's mangoapp (Deck mode's performance overlay) reads an Adreno GPU's load, clock and
|
||||
* temperatures from where they are on Valve's own hardware; every Adreno under Android keeps
|
||||
* them in KGSL's sysfs, readable by the app, though which files a kernel has differs between
|
||||
* Snapdragon generations - so each value takes the first source that exists, the order other
|
||||
* Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left
|
||||
* alone.
|
||||
* Snapdragon generations.
|
||||
*/
|
||||
private static void bindAdrenoStats(List<String> cmd) {
|
||||
private static void bindAdrenoStats(List<String> cmd, List<String> extraBinds) {
|
||||
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
|
||||
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
|
||||
String gpuTemp = gpuTempSource();
|
||||
String[][] stats = {
|
||||
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"},
|
||||
{gpuLoadSource(), "/sys/kernel/debug/dri/0/perf_now"},
|
||||
{firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"),
|
||||
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
|
||||
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
|
||||
@@ -310,8 +381,21 @@ public final class LinuxRuntime {
|
||||
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
|
||||
};
|
||||
for (String[] stat : stats) {
|
||||
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
|
||||
// A value the session feeds itself (GpuStatsComponent, where KGSL is refused) wins.
|
||||
boolean fed = false;
|
||||
if (extraBinds != null) for (String spec : extraBinds) fed |= spec.endsWith(":" + stat[1]);
|
||||
if (stat[0] != null && !fed) bind(cmd, stat[0] + ":" + stat[1]);
|
||||
}
|
||||
// Which of the overlay's GPU values this device lets the app read: a phone under an
|
||||
// enforcing policy refuses some of them, and the overlay then has no line for that value.
|
||||
android.util.Log.i("LinuxRuntime", "hud sources: gpu load " + orNone(stats[0][0]) + ", gpu clock "
|
||||
+ orNone(stats[1][0]) + ", gpu temp " + orNone(gpuTemp) + ", ddr temp " + orNone(stats[4][0])
|
||||
+ ", thermal zones " + thermalZones().size()
|
||||
+ ", kgsl gpubusy " + (new File(kgsl + "gpubusy").canRead() ? "readable" : "refused"));
|
||||
}
|
||||
|
||||
private static String orNone(String path) {
|
||||
return path != null ? path : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* proot's fast path: the session's path lookups answered inside each process by libblfastpath
|
||||
* (tools/proot/fastpath), which issues the translated call from a trampoline page proot's seccomp
|
||||
* filter lets through (tools/proot/patches/0014). A stat or an open costs about a microsecond
|
||||
* instead of a 15-60 us round trip through the tracer.
|
||||
*
|
||||
* The library maps guest paths to host paths itself, so it has to be told the rootfs and the
|
||||
* binds exactly as proot was given them. Both sides carry a key derived from that view - proot
|
||||
* PROOT_FASTPATH=<key>, the guest PROOT_FP_KEY=<key> - and the library answers nothing unless the
|
||||
* process that traces it has the same key: a program that inherited the variables but runs under
|
||||
* another proot (a Flatpak sandbox, a one-off command) leaves every call to its own proot.
|
||||
*
|
||||
* Needs proot's seccomp filter, so "Run proot without seccomp" turns it off as well.
|
||||
*/
|
||||
object ProotFastPath {
|
||||
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
|
||||
|
||||
fun enabled(context: Context): Boolean =
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
|
||||
|
||||
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
|
||||
fun key(root: File, binds: List<String>): String? {
|
||||
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
|
||||
return digest.take(12).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** What proot's own environment carries. */
|
||||
fun hostEnv(key: String): Pair<String, String> = "PROOT_FASTPATH" to key
|
||||
|
||||
/** The guest's assignments (for `env -i`), describing the view proot was given. */
|
||||
fun guestEnv(root: File, binds: List<String>, key: String): List<String> = listOf(
|
||||
"PROOT_FP_ROOT=" + root.path,
|
||||
"PROOT_FP_BINDS=" + binds.joinToString("|"),
|
||||
"PROOT_FP_KEY=$key",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.caverock.androidsvg.SVG
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.store.FlathubApi
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* The apps a user adds to the Desktop page: shell scripts, AppImages (from storage or a GitHub
|
||||
* release) and Flatpak apps. Each opens full screen under gamescope, like an emulator.
|
||||
*
|
||||
* A script lives at /opt/droiddeck-apps/<id>: its folder at `files`, the script's name in `entry`.
|
||||
* The folder is linked where it lies; shared storage is mounted noexec, so a folder holding Linux
|
||||
* programs is copied in instead. A linked folder outside internal storage is bound into the
|
||||
* session at its own path ([binds]), so the link resolves inside the guest.
|
||||
*/
|
||||
object UserApps {
|
||||
private const val TAG = "UserApps"
|
||||
const val GUEST_DIR = "/opt/droiddeck-apps"
|
||||
const val SCRIPT_LAUNCHER = "/usr/local/bin/bannerlator-script-run"
|
||||
private const val FLATPAK_OVERRIDES = ".flatpak"
|
||||
private const val ICON_SIZE = 256
|
||||
private const val ELF_SCAN_LIMIT = 4000
|
||||
private const val ICON_SUGGESTIONS = 7
|
||||
private const val ICON_FILE_LIMIT = 2L shl 20
|
||||
private val ICON_TYPES = setOf("png", "jpg", "jpeg", "webp")
|
||||
private val IGNORED_DIRS = Regex("(^|/)(node_modules|vendor|third[_-]?party|external|tests?|docs?/screenshots)/", RegexOption.IGNORE_CASE)
|
||||
private const val GITHUB_REPO = "github-repo"
|
||||
private const val GITHUB_TAG = "github-tag"
|
||||
private const val GITHUB_PUBLISHED = "github-published"
|
||||
|
||||
enum class Kind { SCRIPT, APPIMAGE, FLATPAK }
|
||||
|
||||
/**
|
||||
* [copied]: a script whose folder was copied in rather than linked. [repo]: the GitHub repository
|
||||
* an AppImage came from, [version] the release it is.
|
||||
*/
|
||||
class App(
|
||||
val key: String, val kind: Kind, val name: String, val icon: Any?, val detail: String?,
|
||||
val program: String, val args: List<String>, val copied: Boolean = false,
|
||||
val repo: String? = null, val version: String? = null,
|
||||
)
|
||||
|
||||
class Release(val tag: String, val published: String, val asset: String, val url: String)
|
||||
|
||||
sealed class UpdateCheck {
|
||||
class Current(val tag: String) : UpdateCheck()
|
||||
class Available(val release: Release) : UpdateCheck()
|
||||
class Failed(val message: String) : UpdateCheck()
|
||||
}
|
||||
|
||||
sealed class Source {
|
||||
class Script(val file: File) : Source()
|
||||
class AppImage(val file: File) : Source()
|
||||
class GitHub(val repo: String) : Source()
|
||||
class Flatpak(val id: String) : Source()
|
||||
}
|
||||
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?)
|
||||
|
||||
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
|
||||
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
|
||||
|
||||
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
|
||||
|
||||
private fun overrides(context: Context, flatpakId: String) = File(root(context), "$FLATPAK_OVERRIDES/$flatpakId")
|
||||
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
|
||||
|
||||
fun list(context: Context): List<App> {
|
||||
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
|
||||
App(
|
||||
"script:${dir.name}", Kind.SCRIPT, readName(dir) ?: dir.name, File(dir, "icon.png").takeIf { it.isFile },
|
||||
FileUtils.readString(File(dir, "source"))?.trim(), SCRIPT_LAUNCHER, listOf("$GUEST_DIR/${dir.name}"),
|
||||
copied = !java.nio.file.Files.isSymbolicLink(File(dir, "files").toPath()),
|
||||
)
|
||||
}.orEmpty()
|
||||
val images = AppImageManager.list(context).map { item ->
|
||||
val dir = AppImageManager.dir(context, item.id)
|
||||
App(
|
||||
"appimage:${item.id}", Kind.APPIMAGE, item.name, item.icon, item.comment, AppImageManager.LAUNCHER, listOf(item.guestDir),
|
||||
repo = FileUtils.readString(File(dir, GITHUB_REPO))?.trim(), version = FileUtils.readString(File(dir, GITHUB_TAG))?.trim(),
|
||||
)
|
||||
}
|
||||
val flatpaks = FlatpakManager.installedApps(context).map { app ->
|
||||
val custom = overrides(context, app.id)
|
||||
App(
|
||||
"flatpak:${app.id}", Kind.FLATPAK, readName(custom) ?: app.name,
|
||||
File(custom, "icon.png").takeIf { it.isFile } ?: app.icon ?: FlathubApi.iconUrl(app.id),
|
||||
app.summary, FlatpakManager.LAUNCHER, listOf(app.id),
|
||||
)
|
||||
}
|
||||
return (scripts + images + flatpaks).sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
private fun readName(dir: File) = FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** Adds [request]; null on success, else what went wrong. */
|
||||
fun add(context: Context, request: Request, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return context.getString(R.string.user_apps_runtime_required)
|
||||
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return withIcon(context, request.icon) { icon ->
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [icon] as a file for [use]: a path as it is, a URL downloaded for the while. */
|
||||
private inline fun <T> withIcon(context: Context, icon: String?, use: (File?) -> T): T {
|
||||
if (icon == null || !icon.startsWith("https://")) return use(icon?.let(::File))
|
||||
val download = File(context.cacheDir, "icon-${System.nanoTime()}")
|
||||
try {
|
||||
return use(download.takeIf { Downloader.downloadFile(icon, it, false) { } })
|
||||
} finally {
|
||||
download.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames [app] and sets its icon: [icon] a path or URL, "" for its default, null to keep it.
|
||||
* The Linux desktop's menu entry follows.
|
||||
*/
|
||||
fun edit(context: Context, app: App, name: String, icon: String?): String? {
|
||||
val id = app.key.substringAfter(':')
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return null
|
||||
val dir = when (app.kind) {
|
||||
Kind.SCRIPT -> File(root(context), id)
|
||||
Kind.APPIMAGE -> AppImageManager.dir(context, id)
|
||||
Kind.FLATPAK -> overrides(context, id)
|
||||
}.apply { mkdirs() }
|
||||
FileUtils.writeString(File(dir, "name"), name.trim())
|
||||
val target = File(dir, "icon.png")
|
||||
when {
|
||||
icon == null -> {}
|
||||
icon.isEmpty() -> if (app.kind == Kind.APPIMAGE) AppImageManager.restoreIcon(dir) else target.delete()
|
||||
else -> withIcon(context, icon) { file -> if (file == null || !saveIcon(file, target)) return context.getString(R.string.user_apps_icon_failed) }
|
||||
}
|
||||
when (app.kind) {
|
||||
Kind.SCRIPT -> writeScriptEntry(context, dir)
|
||||
Kind.APPIMAGE -> AppImageManager.writeMenuEntry(context, dir)
|
||||
Kind.FLATPAK -> {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun remove(context: Context, app: App, onProgress: (String, Int) -> Unit): String? {
|
||||
val id = app.key.substringAfter(':')
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return null
|
||||
return when (app.kind) {
|
||||
Kind.SCRIPT -> {
|
||||
val dir = File(root(context), id)
|
||||
// The link goes, never the folder it points to.
|
||||
File(dir, "files").delete()
|
||||
FileUtils.delete(dir)
|
||||
menuEntry(context, id).delete()
|
||||
null
|
||||
}
|
||||
Kind.APPIMAGE -> { AppImageManager.remove(context, id); null }
|
||||
Kind.FLATPAK -> FlatpakManager.uninstall(context, id, onProgress).also {
|
||||
if (it == null) FileUtils.delete(overrides(context, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Folders linked from outside internal storage, bound into a session at their own paths. */
|
||||
fun binds(context: Context): List<String> {
|
||||
val internal = Environment.getExternalStorageDirectory().canonicalPath
|
||||
return root(context).listFiles().orEmpty().mapNotNull { dir ->
|
||||
val target = runCatching { Os.readlink(File(dir, "files").path) }.getOrNull() ?: return@mapNotNull null
|
||||
target.takeIf { !it.startsWith("$internal/") && File(it).isDirectory }
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
fun planScript(script: File): ScriptPlan {
|
||||
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
|
||||
var scanned = 0
|
||||
val copy = folder.walkTopDown().maxDepth(4).filter { it.isFile }.takeWhile { scanned++ < ELF_SCAN_LIMIT }.any(::isElf)
|
||||
val bytes = if (copy) folder.walkTopDown().filter { it.isFile }.sumOf { it.length() } else 0L
|
||||
return ScriptPlan(folder, copy, bytes)
|
||||
}
|
||||
|
||||
private fun isElf(f: File): Boolean = try {
|
||||
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
|
||||
val plan = planScript(script)
|
||||
val base = root(context).apply { mkdirs() }
|
||||
if (plan.copy && StatFs(base.path).availableBytes < plan.bytes + (64L shl 20)) {
|
||||
return context.getString(R.string.user_apps_no_space, FileUtils.sizeToString(plan.bytes))
|
||||
}
|
||||
val title = name ?: script.nameWithoutExtension
|
||||
val id = AppImageManager.idFor(title, base.list()?.toSet() ?: emptySet())
|
||||
val dir = File(base, id)
|
||||
try {
|
||||
dir.mkdirs()
|
||||
val files = File(dir, "files")
|
||||
if (plan.copy) {
|
||||
onProgress(context.getString(R.string.user_apps_copying, plan.folder.name), -1)
|
||||
copyTree(plan.folder, files)
|
||||
} else {
|
||||
Os.symlink(plan.folder.path, files.path)
|
||||
}
|
||||
FileUtils.writeString(File(dir, "entry"), script.name)
|
||||
FileUtils.writeString(File(dir, "name"), title)
|
||||
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
|
||||
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
writeScriptEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "script ${script.path}", e)
|
||||
File(dir, "files").takeIf { !plan.copy }?.delete()
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: context.getString(R.string.user_apps_failed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeScriptEntry(context: Context, dir: File) {
|
||||
val id = dir.name
|
||||
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "utilities-terminal"
|
||||
FileUtils.writeString(menuEntry(context, id),
|
||||
"[Desktop Entry]\nType=Application\nName=${readName(dir) ?: id}\nExec=$SCRIPT_LAUNCHER $GUEST_DIR/$id\nIcon=$icon\n" +
|
||||
"Terminal=false\nCategories=Game;\nX-DroidDeck-App=$id\n")
|
||||
}
|
||||
|
||||
/** Copies [from] into [to], keeping Linux programs and scripts executable. */
|
||||
private fun copyTree(from: File, to: File) {
|
||||
from.walkTopDown().forEach { src ->
|
||||
val dst = File(to, src.relativeTo(from).path)
|
||||
if (src.isDirectory) dst.mkdirs()
|
||||
else {
|
||||
src.inputStream().use { input -> dst.outputStream().use { FileUtils.copy(input, it) } }
|
||||
if (isElf(dst) || dst.extension == "sh") dst.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [source] (PNG, JPEG, WebP or SVG) as the app's icon: a PNG no larger than [ICON_SIZE] on a side. */
|
||||
internal fun saveIcon(source: File, target: File): Boolean {
|
||||
val bitmap = (if (isSvg(source)) renderSvg(source) else decodeScaled(source)) ?: return false
|
||||
return try {
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "icon ${source.path}", e); false
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeScaled(source: File): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(source.path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= ICON_SIZE) sample *= 2
|
||||
val decoded = BitmapFactory.decodeFile(source.path, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
|
||||
val scale = ICON_SIZE.toFloat() / maxOf(decoded.width, decoded.height)
|
||||
if (scale >= 1f) return decoded
|
||||
val scaled = Bitmap.createScaledBitmap(decoded, (decoded.width * scale).toInt().coerceAtLeast(1), (decoded.height * scale).toInt().coerceAtLeast(1), true)
|
||||
if (scaled !== decoded) decoded.recycle()
|
||||
return scaled
|
||||
}
|
||||
|
||||
private fun isSvg(f: File): Boolean = try {
|
||||
f.inputStream().use { val head = ByteArray(1024); val n = it.read(head); n > 0 && String(head, 0, n).contains("<svg") }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
/** An SVG drawn at [ICON_SIZE], centred in its own proportions. */
|
||||
private fun renderSvg(source: File): Bitmap? = try {
|
||||
val svg = source.inputStream().use { SVG.getFromInputStream(it) }
|
||||
if (svg.documentViewBox == null && svg.documentWidth > 0 && svg.documentHeight > 0) {
|
||||
svg.setDocumentViewBox(0f, 0f, svg.documentWidth, svg.documentHeight)
|
||||
}
|
||||
svg.setDocumentWidth("100%")
|
||||
svg.setDocumentHeight("100%")
|
||||
Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.ARGB_8888).also { svg.renderToCanvas(Canvas(it)) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "svg ${source.path}", e); null
|
||||
}
|
||||
|
||||
/** "owner/repo" from what was typed: any link into the repository, its clone address, or the short form. */
|
||||
fun githubRepo(input: String): String? {
|
||||
val t = input.trim()
|
||||
val m = Regex("^(?:https?://)?(?:www\\.)?github\\.com[/:]([\\w.-]+)/([\\w.-]+)", RegexOption.IGNORE_CASE).find(t)
|
||||
?: Regex("^git@github\\.com:([\\w.-]+)/([\\w.-]+)", RegexOption.IGNORE_CASE).find(t)
|
||||
?: Regex("^([\\w.-]+)/([\\w.-]+)/?$").find(t) ?: return null
|
||||
val repo = m.groupValues[2].removeSuffix(".git")
|
||||
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
|
||||
}
|
||||
|
||||
/** The ARM64 AppImage among a release's files: named for aarch64/arm64, else for no other CPU. */
|
||||
internal fun pickAsset(names: List<String>): String? {
|
||||
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
|
||||
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
|
||||
}
|
||||
|
||||
/** The newest release of [repo] with an ARM64 AppImage, preferring full releases; else what went wrong. */
|
||||
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
|
||||
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
|
||||
?: return null to context.getString(R.string.user_apps_github_unreachable, repo)
|
||||
val candidates = (0 until releases.length()).mapNotNull { i ->
|
||||
val r = releases.getJSONObject(i)
|
||||
if (r.optBoolean("draft")) return@mapNotNull null
|
||||
val assets = r.optJSONArray("assets") ?: return@mapNotNull null
|
||||
val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it.getString("browser_download_url") } }
|
||||
pickAsset(byName.keys.toList())?.let { asset ->
|
||||
r.optBoolean("prerelease") to Release(r.optString("tag_name", asset), r.optString("published_at"), asset, byName.getValue(asset))
|
||||
}
|
||||
}
|
||||
val pick = (candidates.firstOrNull { !it.first } ?: candidates.firstOrNull())?.second
|
||||
return pick to if (pick == null) context.getString(R.string.user_apps_github_no_appimage, repo) else null
|
||||
}
|
||||
|
||||
/** Downloads [release] and hands it to [use]; null on success, else what went wrong. */
|
||||
private fun withDownload(context: Context, release: Release, onProgress: (String, Int) -> Unit, use: (File) -> String?): String? {
|
||||
val download = File(context.cacheDir, "github-${release.asset}")
|
||||
try {
|
||||
val label = context.getString(R.string.user_apps_downloading, release.asset)
|
||||
val ok = Downloader.downloadFile(release.url, download, false) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) }
|
||||
return if (ok) use(download) else context.getString(R.string.user_apps_download_failed)
|
||||
} finally {
|
||||
download.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun githubMeta(repo: String, release: Release) =
|
||||
mapOf(GITHUB_REPO to repo, GITHUB_TAG to release.tag, GITHUB_PUBLISHED to release.published)
|
||||
|
||||
private fun addFromGitHub(context: Context, repo: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
onProgress(context.getString(R.string.user_apps_github_checking, repo), -1)
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
if (release == null) return problem
|
||||
return withDownload(context, release, onProgress) { file ->
|
||||
AppImageManager.import(context, file, name ?: repo.substringAfter('/'), icon, githubMeta(repo, release)) { onProgress(it, -1) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Images in [repo] that look like its icon, best first, then its owner's picture: links to show
|
||||
* as suggestions and to download once one is picked.
|
||||
*/
|
||||
fun githubIcons(repo: String): List<String> {
|
||||
val info = Downloader.downloadString("https://api.github.com/repos/$repo")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return emptyList()
|
||||
val branch = info.optString("default_branch").ifEmpty { return emptyList() }
|
||||
val avatar = info.optJSONObject("owner")?.optString("avatar_url")?.takeIf { it.startsWith("https://") }
|
||||
val tree = Downloader.downloadString("https://api.github.com/repos/$repo/git/trees/${Uri.encode(branch)}?recursive=1")
|
||||
?.let { runCatching { JSONObject(it).optJSONArray("tree") }.getOrNull() }
|
||||
val project = repo.substringAfter('/').lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
val images = (0 until (tree?.length() ?: 0)).mapNotNull { i ->
|
||||
val entry = tree!!.getJSONObject(i)
|
||||
val path = entry.optString("path")
|
||||
val size = entry.optLong("size")
|
||||
val file = path.substringAfterLast('/').lowercase()
|
||||
val stem = file.substringBeforeLast('.').replace(Regex("[^a-z0-9]"), "")
|
||||
if (entry.optString("type") != "blob" || size !in 512..ICON_FILE_LIMIT || file.substringAfterLast('.') !in ICON_TYPES) return@mapNotNull null
|
||||
if (IGNORED_DIRS.containsMatchIn(path)) return@mapNotNull null
|
||||
val score = listOf("icon" in stem, "logo" in stem, project.isNotEmpty() && project in stem, "icon" in path.lowercase()).count { it }
|
||||
if (score == 0) null else Triple(path, score, size)
|
||||
}.sortedWith(compareByDescending<Triple<String, Int, Long>> { it.second }.thenByDescending { it.third })
|
||||
val urls = images.take(ICON_SUGGESTIONS).map { (path, _, _) ->
|
||||
"https://raw.githubusercontent.com/$repo/${Uri.encode(branch)}/" + path.split('/').joinToString("/") { Uri.encode(it) }
|
||||
}
|
||||
return urls + listOfNotNull(avatar)
|
||||
}
|
||||
|
||||
/** Whether [app], added from GitHub, has a newer release than the one it is. */
|
||||
fun checkUpdate(context: Context, app: App): UpdateCheck {
|
||||
val repo = app.repo ?: return UpdateCheck.Failed(context.getString(R.string.user_apps_update_failed))
|
||||
val (release, problem) = latestRelease(context, repo)
|
||||
if (release == null) return UpdateCheck.Failed(problem ?: context.getString(R.string.user_apps_update_failed))
|
||||
val published = FileUtils.readString(File(AppImageManager.dir(context, app.key.substringAfter(':')), GITHUB_PUBLISHED))?.trim().orEmpty()
|
||||
// Releases are compared by date, so a newest one without an ARM64 build never reads as a downgrade.
|
||||
val newer = if (published.isNotEmpty() && release.published.isNotEmpty()) release.published > published else release.tag != app.version
|
||||
return if (newer) UpdateCheck.Available(release) else UpdateCheck.Current(app.version ?: release.tag)
|
||||
}
|
||||
|
||||
fun update(context: Context, app: App, release: Release, onProgress: (String, Int) -> Unit): String? {
|
||||
val repo = app.repo ?: return context.getString(R.string.user_apps_update_failed)
|
||||
return withDownload(context, release, onProgress) { file ->
|
||||
AppImageManager.replace(context, app.key.substringAfter(':'), file, githubMeta(repo, release)) { onProgress(it, -1) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A Flathub app ID from what was typed: the ID itself or its Flathub page. */
|
||||
fun flatpakId(input: String): String? {
|
||||
val t = input.trim().removeSuffix("/").substringAfterLast('/')
|
||||
return t.takeIf { Regex("^[A-Za-z][\\w-]*(\\.[A-Za-z0-9_][\\w-]*){2,}$").matches(it) }
|
||||
}
|
||||
|
||||
private fun addFlatpak(context: Context, id: String, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!FlatpakManager.ready(context)) FlatpakManager.setup(context, onProgress)?.let { return it }
|
||||
FlatpakManager.install(context, id, onProgress)?.let { return it }
|
||||
val custom = overrides(context, id)
|
||||
if (name != null) FileUtils.writeString(File(custom.apply { mkdirs() }, "name"), name)
|
||||
if (icon != null && !saveIcon(icon, File(custom, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A `/proc/stat` that moves.
|
||||
*
|
||||
* Android has refused apps `/proc/stat` since 8.0, and the session's stand-in for it was one
|
||||
* `cpu0` line of zeros - enough for glibc and Steam to start, but every CPU load in the session
|
||||
* read 0 % (the performance overlay showed one core, idle). The kernel still lets apps read each
|
||||
* core's idle residency (`cpuidle/stateN/time`, µs, the same label as the cpufreq files), which is
|
||||
* how overlays on Android work out CPU load: a core's busy time is the time that passed less the
|
||||
* time it spent idle. This writes `/proc/stat` from that, one line per core, refreshed twice a
|
||||
* second. Counters only grow - a sample's idle is never more than the time that passed - so a
|
||||
* reader taking the difference of two reads never sees a negative. Where `/proc/stat` is readable,
|
||||
* or the idle times are not, nothing is done.
|
||||
*/
|
||||
class CpuStatComponent(val file: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
private val cores = (0 until coreCount()).toList()
|
||||
private val busy = LongArray(cores.size)
|
||||
private val idle = LongArray(cores.size)
|
||||
private val lastIdleUs = LongArray(cores.size)
|
||||
private var lastMs = 0L
|
||||
private val bootSeconds = (System.currentTimeMillis() - SystemClock.elapsedRealtime()) / 1000
|
||||
|
||||
/** Writes the first sample; true when it should be bound over `/proc/stat`. */
|
||||
fun prepare(): Boolean {
|
||||
if (File("/proc/stat").canRead() || cores.isEmpty() || idleUs(0) == null) return false
|
||||
lastMs = SystemClock.elapsedRealtime()
|
||||
for (i in cores) lastIdleUs[i] = idleUs(i) ?: 0L
|
||||
write()
|
||||
Log.i(TAG, "hud: /proc/stat from the cores' idle times (${cores.size} cores)")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
running = true
|
||||
thread = Thread({
|
||||
while (running) {
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
if (!running) break
|
||||
sample()
|
||||
write()
|
||||
}
|
||||
}, "cpu-stat").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
private fun sample() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val elapsed = (now - lastMs) / MS_PER_TICK
|
||||
if (elapsed <= 0) return
|
||||
lastMs += elapsed * MS_PER_TICK
|
||||
for (i in cores) {
|
||||
// An offline core has no idle residency to count and runs nothing: all idle.
|
||||
val us = idleUs(i)
|
||||
val idleTicks = if (us == null) elapsed else ((us - lastIdleUs[i]) / US_PER_TICK).coerceIn(0, elapsed)
|
||||
if (us != null) lastIdleUs[i] = us
|
||||
idle[i] += idleTicks
|
||||
busy[i] += elapsed - idleTicks
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole file at once, renamed into place: a reader opens either the last sample or this one. */
|
||||
private fun write() {
|
||||
val text = StringBuilder()
|
||||
text.append("cpu ${busy.sum()} 0 0 ${idle.sum()} 0 0 0 0 0 0\n")
|
||||
for (i in cores) text.append("cpu$i ${busy[i]} 0 0 ${idle[i]} 0 0 0 0 0 0\n")
|
||||
text.append("intr 0\nctxt 0\nbtime $bootSeconds\nprocesses 1\nprocs_running 1\nprocs_blocked 0\n")
|
||||
try {
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
tmp.writeText(text.toString())
|
||||
if (!tmp.renameTo(file)) tmp.delete()
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
private const val PERIOD_MS = 500L
|
||||
/** /proc/stat counts in USER_HZ, 100 a second on every Linux the guest runs. */
|
||||
private const val MS_PER_TICK = 10L
|
||||
private const val US_PER_TICK = 10_000L
|
||||
|
||||
private fun coreCount(): Int =
|
||||
File("/sys/devices/system/cpu").list()?.count { it.matches(Regex("cpu\\d+")) } ?: 0
|
||||
|
||||
/** The core's total idle residency in µs, or null when it has none to read (offline). */
|
||||
private fun idleUs(core: Int): Long? {
|
||||
val states = File("/sys/devices/system/cpu/cpu$core/cpuidle").listFiles { f -> f.name.startsWith("state") }
|
||||
?: return null
|
||||
if (states.isEmpty()) return null
|
||||
var total = 0L
|
||||
for (state in states) {
|
||||
total += runCatching { File(state, "time").readText().trim().toLong() }.getOrNull() ?: return null
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The GPU memory in use, for the performance overlay's VRAM and PVRAM lines.
|
||||
*
|
||||
* Valve's mangoapp reads it from the kernel's gpu_mem_total tracepoint, which no Android app may
|
||||
* reach, so both lines read 0. Our libtracefs for mangoapp (tools/mangoapp/libtracefs-shim.c) hands
|
||||
* it what this writes to [file] - `/run/droiddeck-hud/gpu-mem` in the guest - every few seconds, in
|
||||
* bytes, from the best of what KGSL lets the app read:
|
||||
*
|
||||
* - `/sys/class/kgsl/kgsl/page_alloc`, every GPU page on the device - what the tracepoint's total
|
||||
* counts, where the policy allows it;
|
||||
* - else KGSL's own accounting of each of the session's processes
|
||||
* (`/sys/class/kgsl/kgsl/proc/<pid>/gpumem_mapped` and `gpumem_unmapped`);
|
||||
* - else the size of the KGSL mappings in each of the session's processes' `maps` - only the
|
||||
* buffers the CPU has mapped, so less than the GPU holds (about 40 % of KGSL's own count in a
|
||||
* game on an AYN Thor), but always readable: every process the app can see in `/proc` is one of
|
||||
* its own. `maps` rather than `smaps`, which gives the same sizes but walks page tables to do it:
|
||||
* in a game, 180,000 lines and most of a second of CPU a pass.
|
||||
*
|
||||
* Sampled only while the overlay shows (OverlayShown).
|
||||
*/
|
||||
class GpuMemComponent(private val file: File, private val root: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
private var source = ""
|
||||
|
||||
/** True when there is a source to write from. */
|
||||
fun prepare(): Boolean {
|
||||
val bytes = sample() ?: return false
|
||||
write(bytes)
|
||||
Log.i(TAG, "hud: gpu memory from $source (${bytes / (1024 * 1024)} MiB now)")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
running = true
|
||||
thread = Thread({
|
||||
while (running) {
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
if (running && OverlayShown.check(root)) sample()?.let { write(it) }
|
||||
}
|
||||
}, "gpu-mem").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private fun sample(): Long? {
|
||||
readLong("$KGSL_ROOT/page_alloc")?.let { source = "$KGSL_ROOT/page_alloc"; return it }
|
||||
val pids = sessionPids()
|
||||
var perProcess = 0L
|
||||
var anyProcess = false
|
||||
for (pid in pids) {
|
||||
val mapped = readLong("$KGSL_ROOT/proc/$pid/gpumem_mapped") ?: continue
|
||||
perProcess += mapped + (readLong("$KGSL_ROOT/proc/$pid/gpumem_unmapped") ?: 0L)
|
||||
anyProcess = true
|
||||
}
|
||||
if (anyProcess) { source = "$KGSL_ROOT/proc/<pid>/gpumem_*"; return perProcess }
|
||||
var mappedKb = 0L
|
||||
var anyMap = false
|
||||
for (pid in pids) {
|
||||
val kb = kgslMappedKb(pid) ?: continue
|
||||
mappedKb += kb
|
||||
anyMap = true
|
||||
}
|
||||
if (anyMap) { source = "/proc/<pid>/maps (KGSL mappings)"; return mappedKb * 1024 }
|
||||
return null
|
||||
}
|
||||
|
||||
/** The processes this app may see in /proc: Android hides every other uid's. */
|
||||
private fun sessionPids(): List<String> =
|
||||
File("/proc").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) }.orEmpty()
|
||||
|
||||
/** Size of a process's mappings of the KGSL device, in kB; null where its maps cannot be read. */
|
||||
private fun kgslMappedKb(pid: String): Long? = runCatching {
|
||||
var bytes = 0L
|
||||
File("/proc/$pid/maps").forEachLine { line ->
|
||||
if (line.endsWith("kgsl-3d0") || line.contains("/dev/dri/renderD")) {
|
||||
val dash = line.indexOf('-')
|
||||
val space = line.indexOf(' ')
|
||||
if (dash > 0 && space > dash) {
|
||||
bytes += line.substring(dash + 1, space).toLong(16) - line.substring(0, dash).toLong(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes / 1024
|
||||
}.getOrNull()
|
||||
|
||||
/** Whole, renamed into place: the reader opens either the last value or this one. */
|
||||
private fun write(bytes: Long) {
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
tmp.writeText("$bytes\n")
|
||||
if (!tmp.renameTo(file)) tmp.delete()
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
private const val KGSL_ROOT = "/sys/class/kgsl/kgsl"
|
||||
/** GPU memory moves slowly; a pass over every process's maps is ~40 ms of CPU in a game. */
|
||||
private const val PERIOD_MS = 5_000L
|
||||
|
||||
private fun readLong(path: String): Long? =
|
||||
runCatching { File(path).readText().trim().toLong() }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* The Adreno's stats as mangoapp (Deck mode's performance overlay) reads them.
|
||||
*
|
||||
* MangoHud takes an Adreno's load, clock and temperature from `gpu_busy_percentage`, `clock_mhz`
|
||||
* and `temp` in `/sys/class/kgsl/kgsl-3d0`, and ends the process on one of them it may not read -
|
||||
* not on one that is missing, which it skips. An enforcing SELinux policy (every retail phone)
|
||||
* refuses apps some or all of KGSL's sysfs, each vendor differently, so mangoapp died on start and
|
||||
* the session ran with no overlay. Where any of the three is refused, a directory of our own is
|
||||
* bound there instead. A value with no
|
||||
* readable source is left out, and the overlay shows no line for it. Where all three are
|
||||
* readable, nothing is done.
|
||||
*
|
||||
* Valve's build also reads load and clock where a mainline msm kernel has them (debugfs
|
||||
* `perf_now`, the GPU's devfreq `cur_freq` in Hz) - the paths LinuxRuntime.bindAdrenoStats points
|
||||
* at KGSL's own files when it may - so the same values are fed there too, in the same formats
|
||||
* ([binds]).
|
||||
*/
|
||||
class GpuStatsComponent(val dir: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
private val feeds = ArrayList<Feed>()
|
||||
|
||||
/** One of MangoHud's files and where its value comes from. */
|
||||
private class Feed(
|
||||
val file: File, val source: String, val read: () -> Long?, val format: String, val percent: Boolean = false,
|
||||
)
|
||||
|
||||
/** Where Valve's msm reader looks, and the feed for it, as host:guest binds. */
|
||||
fun binds(): List<String> {
|
||||
val list = mutableListOf(dir.path + ":" + KGSL)
|
||||
if (File(dir, "perf_now").exists()) list.add(File(dir, "perf_now").path + ":" + MSM_LOAD)
|
||||
if (File(dir, "cur_freq").exists()) list.add(File(dir, "cur_freq").path + ":" + MSM_CLOCK)
|
||||
return list
|
||||
}
|
||||
|
||||
/** Writes the directory; true when it should be bound over [KGSL]. */
|
||||
fun prepare(): Boolean {
|
||||
if (!File(KGSL).exists() || MANGOHUD_FILES.none { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }) return false
|
||||
return try {
|
||||
dir.mkdirs()
|
||||
dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) }
|
||||
feeds.clear()
|
||||
loadFeed()?.let { load ->
|
||||
feeds.add(load)
|
||||
// KGSL's gpu_busy_percentage reads "37 %"; padded after, so a shorter value
|
||||
// written over a longer one leaves no digits behind.
|
||||
feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true))
|
||||
}
|
||||
pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { clock ->
|
||||
feeds.add(clock)
|
||||
feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n"))
|
||||
}
|
||||
pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) }
|
||||
feeds.forEach { feed -> feed.read()?.let { write(feed, it) } }
|
||||
Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name ->
|
||||
"$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none")
|
||||
})
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hud: could not stand in for kgsl stats: $e")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (feeds.isEmpty()) return
|
||||
running = true
|
||||
thread = Thread({
|
||||
while (running) {
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
if (!running) break
|
||||
for (feed in feeds) feed.read()?.let { write(feed, it) }
|
||||
}
|
||||
}, "gpu-stats").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
/** Load: a file that states it in percent, else KGSL's busy and total of the last sample. */
|
||||
private fun loadFeed(): Feed? {
|
||||
pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }?.let { return it }
|
||||
val gpubusy = "$KGSL/gpubusy"
|
||||
val read = {
|
||||
runCatching {
|
||||
val parts = File(gpubusy).readText().trim().split(Regex("\\s+"))
|
||||
val total = parts[1].toLong()
|
||||
if (total > 0) (parts[0].toLong() * 100 / total).coerceIn(0, 100) else 0L
|
||||
}.getOrNull()
|
||||
}
|
||||
return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null
|
||||
}
|
||||
|
||||
/** The first of [sources] that reads as a number, its value put in MangoHud's unit by [unit]. */
|
||||
private fun pick(name: String, sources: List<String>, format: String, unit: (Long) -> Long?): Feed? {
|
||||
for (source in sources) {
|
||||
val read = { firstNumber(source)?.let(unit) }
|
||||
if (read() != null) return Feed(File(dir, name), source, read, format)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** In place and at one length, so a reader that keeps the file open sees each value whole. */
|
||||
private fun write(feed: Feed, value: Long) {
|
||||
try {
|
||||
val text = if (feed.percent) String.format(feed.format, "$value %") else String.format(feed.format, value)
|
||||
RandomAccessFile(feed.file, "rw").use { it.seek(0); it.write(text.toByteArray()) }
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
const val KGSL = "/sys/class/kgsl/kgsl-3d0"
|
||||
/** Must match LinuxRuntime.bindAdrenoStats. */
|
||||
const val MSM_LOAD = "/sys/kernel/debug/dri/0/perf_now"
|
||||
const val MSM_CLOCK = "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"
|
||||
private const val PERIOD_MS = 500L
|
||||
private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp")
|
||||
/** Qualcomm's own GPU summary, beside KGSL in vendor kernels, under the plain sysfs label. */
|
||||
private const val QCOM_GPU = "/sys/kernel/gpu"
|
||||
private val LOAD_SOURCES = listOf(
|
||||
"$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy",
|
||||
)
|
||||
/** The GPU's devfreq node is named by its register address, which differs between Snapdragons. */
|
||||
private val DEVFREQ_NODES = listOf(
|
||||
"kgsl-3d0", "3d00000.qcom,kgsl-3d0", "2c00000.qcom,kgsl-3d0", "5000000.qcom,kgsl-3d0", "5900000.qcom,kgsl-3d0",
|
||||
)
|
||||
|
||||
private fun clockSources(): List<String> {
|
||||
val sources = mutableListOf(
|
||||
"$KGSL/clock_mhz", "$KGSL/gpu_clock", "$KGSL/gpuclk", "$KGSL/devfreq/cur_freq", "$QCOM_GPU/gpu_clock",
|
||||
)
|
||||
DEVFREQ_NODES.forEach { sources.add("/sys/class/devfreq/$it/cur_freq") }
|
||||
File("/sys/class/devfreq").list()?.sorted()?.forEach { node ->
|
||||
if (node.contains("kgsl") || node.contains("gpu")) sources.add("/sys/class/devfreq/$node/cur_freq")
|
||||
}
|
||||
return sources.distinct()
|
||||
}
|
||||
|
||||
private fun tempSources(): List<String> =
|
||||
listOfNotNull("$KGSL/temp", "$KGSL/devfreq/temp", "$QCOM_GPU/temp", LinuxRuntime.gpuTempSource()).distinct()
|
||||
|
||||
/** The first integer in the file ("550", "37 %", "550000000"), or null. */
|
||||
private fun firstNumber(path: String): Long? =
|
||||
runCatching { Regex("-?\\d+").find(File(path).readText())?.value?.toLong() }.getOrNull()
|
||||
|
||||
/** Hz, kHz or MHz to MHz, as the files differ; a stopped clock reads 0. */
|
||||
private fun mhz(raw: Long): Long? = when {
|
||||
raw < 0 -> null
|
||||
raw > 10_000_000 -> raw / 1_000_000
|
||||
raw > 10_000 -> raw / 1_000
|
||||
else -> raw
|
||||
}
|
||||
|
||||
/** °C or m°C to MangoHud's m°C; nothing a GPU reads at is below 1 °C or above 1000. */
|
||||
private fun milliCelsius(raw: Long): Long? = when {
|
||||
raw <= 0 -> null
|
||||
raw < 1000 -> raw * 1000
|
||||
else -> raw
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* The session's `/sys/class/hwmon`, as mangoapp (Deck mode's performance overlay) looks there.
|
||||
*
|
||||
* MangoHud finds a CPU's temperature by an hwmon sensor named `cpuN_thermal`, and an Adreno's
|
||||
* temperature, fan and power by one whose name has `gpuss` in it - a mainline Arm kernel's names.
|
||||
* An Android kernel has neither (its hwmon holds the PMIC's sensors, or nothing), and where the
|
||||
* app may not list hwmon at all the overlay logged an error twice a second. So the overlay showed
|
||||
* no CPU temperature and no fan. This is two sensors named so:
|
||||
*
|
||||
* - `hwmon0` (`cpu0_thermal`): `temp1_input` linked to a CPU thermal zone the app can read;
|
||||
* - `hwmon1` (`gpuss0_thermal`): `temp1_input` linked to the GPU's zone, and `fan1_input` with
|
||||
* the device's fan speed where it has a fan the app can read - an AYN handheld's tachometer
|
||||
* (`/sys/class/gpio5_pwm2/speed`), or a `fan*_input` in the device's own hwmon;
|
||||
* - `hwmon2` (`slg4ax46073v`): the same fan, for Valve's own Qualcomm reader (msm.cpp), which
|
||||
* looks for its fan controller by that name and halves what it reads - that part reports twice
|
||||
* the real speed - so this one holds twice it.
|
||||
*
|
||||
* Nothing in a phone's own hwmon is read by the overlay, so standing in for it loses nothing.
|
||||
*/
|
||||
class HwmonComponent(val dir: File, private val root: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
private var fanSource: String? = null
|
||||
private val fanFile = File(dir, "hwmon1/fan1_input")
|
||||
private val valveFanFile = File(dir, "hwmon2/fan1_input")
|
||||
private var lastRpm = 0L
|
||||
private var lastRpmAt = 0L
|
||||
|
||||
/** Writes the sensors; true when the directory should be bound over `/sys/class/hwmon`. */
|
||||
fun prepare(): Boolean = try {
|
||||
dir.mkdirs()
|
||||
clear(dir)
|
||||
val cpu = LinuxRuntime.cpuTempSource()
|
||||
if (cpu != null) sensor("hwmon0", "cpu0_thermal").also { link(cpu, File(it, "temp1_input")) }
|
||||
val gpu = LinuxRuntime.gpuTempSource()
|
||||
fanSource = FAN_SOURCES().firstOrNull { readNumber(it) != null }
|
||||
if (gpu != null || fanSource != null) {
|
||||
val sensor = sensor("hwmon1", "gpuss0_thermal")
|
||||
if (gpu != null) link(gpu, File(sensor, "temp1_input"))
|
||||
if (fanSource != null) {
|
||||
sensor("hwmon2", "slg4ax46073v")
|
||||
writeFan(sampleFan())
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "hud: hwmon cpu temp ${cpu ?: "none"}, gpu temp ${gpu ?: "none"}, fan ${fanSource ?: "none"}")
|
||||
cpu != null || gpu != null || fanSource != null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hud: could not write the session's hwmon: $e")
|
||||
false
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (fanSource == null) return
|
||||
running = true
|
||||
thread = Thread({
|
||||
while (running) {
|
||||
try { Thread.sleep(FAN_PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
// A tachometer read busy-waits 100 ms in the kernel: only while the overlay shows.
|
||||
if (running && OverlayShown.check(root)) writeFan(sampleFan())
|
||||
}
|
||||
}, "hwmon-fan").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The fan's speed in RPM. An AYN tachometer counts pulses over a short window per read and
|
||||
* often catches none, reading 0 while the fan turns; a reading is held until a newer one
|
||||
* comes or [FAN_HOLD_MS] passes with only zeros, when the fan is taken to have stopped.
|
||||
*/
|
||||
private fun sampleFan(): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val rpm = fanSource?.let { readNumber(it) } ?: 0L
|
||||
if (rpm > 0) {
|
||||
lastRpm = rpm
|
||||
lastRpmAt = now
|
||||
} else if (now - lastRpmAt > FAN_HOLD_MS) {
|
||||
lastRpm = 0
|
||||
}
|
||||
return lastRpm
|
||||
}
|
||||
|
||||
/** In place and at one length, so a reader that keeps the file open sees each value whole. */
|
||||
private fun writeFan(rpm: Long) {
|
||||
for ((file, value) in listOf(fanFile to rpm, valveFanFile to rpm * 2)) {
|
||||
try {
|
||||
RandomAccessFile(file, "rw").use { it.seek(0); it.write(String.format("%6d\n", value).toByteArray()) }
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sensor(name: String, label: String): File =
|
||||
File(dir, name).apply { mkdirs(); File(this, "name").writeText("$label\n") }
|
||||
|
||||
private fun link(target: String, link: File) {
|
||||
java.nio.file.Files.deleteIfExists(link.toPath())
|
||||
Os.symlink(target, link.path)
|
||||
}
|
||||
|
||||
/** Only files and links are made here; deleting a link must not follow it into sysfs. */
|
||||
private fun clear(root: File) {
|
||||
root.listFiles()?.forEach { f ->
|
||||
if (!java.nio.file.Files.isSymbolicLink(f.toPath()) && f.isDirectory) clear(f)
|
||||
java.nio.file.Files.deleteIfExists(f.toPath())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** The AYN tachometer reads 0 if read again within a couple of seconds. */
|
||||
private const val FAN_PERIOD_MS = 3_000L
|
||||
private const val FAN_HOLD_MS = 10_000L
|
||||
|
||||
private val FAN_SOURCES = {
|
||||
val sources = mutableListOf("/sys/class/gpio5_pwm2/speed", "/sys/class/gpio5_pwm/speed")
|
||||
File("/sys/class/hwmon").listFiles()?.sortedBy { it.name }?.forEach { hwmon ->
|
||||
hwmon.listFiles { f -> f.name.matches(Regex("fan\\d+_input")) }?.sortedBy { it.name }
|
||||
?.forEach { sources.add(it.path) }
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
private fun readNumber(path: String): Long? =
|
||||
runCatching { File(path).readText().trim().toLong() }.getOrNull()?.takeIf { it >= 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Whether Deck mode's performance overlay is on screen. The client sets its level by rewriting
|
||||
* mangoapp's config (bannerlator-session makes it, `/tmp/mangohud.*`), and `no_display` is the
|
||||
* level that hides it. The feeds whose reads cost something - the fan's tachometer busy-waits in
|
||||
* the kernel, GPU memory walks every process's mappings - only run while it shows.
|
||||
*/
|
||||
object OverlayShown {
|
||||
fun check(root: File): Boolean {
|
||||
val config = File(root, "tmp").listFiles { f -> f.name.startsWith("mangohud.") }
|
||||
?.maxByOrNull { it.lastModified() } ?: return false
|
||||
return runCatching { !config.readText().contains("no_display") }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,9 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* is on, what the screen is actually being shown - "60 → 118 fps" is the proof that the engine
|
||||
* is doing something, and "FG starting" or a reason is the proof that it is not.
|
||||
*
|
||||
* The base rate is counted here from the compositor's per-frame callback, so it is right with or
|
||||
* without an engine. The presented rate comes from the engine's own telemetry while it generates.
|
||||
* The base rate is the game window's own frames, counted here from the compositor's per-frame
|
||||
* callback with or without an engine - what Steam's overlay counts too. The presented rate comes
|
||||
* from the engine's telemetry while it generates.
|
||||
*/
|
||||
class PerfHud(context: Context) {
|
||||
/** Empty when the HUD is off or not yet started. */
|
||||
@@ -99,10 +100,7 @@ class PerfHud(context: Context) {
|
||||
return when {
|
||||
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
|
||||
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
|
||||
stats != null && stats[3] > 1f -> {
|
||||
val source = if (stats[2] > 1f) stats[2] else base
|
||||
String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3])
|
||||
}
|
||||
stats != null && stats[3] > 1f -> String.format(Locale.US, "%s %.0f → %.0f fps", label, base, stats[3])
|
||||
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardCopyOption.COPY_ATTRIBUTES
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
/** Android shared storage cannot create the links in Wine prefixes or native Steam depots. */
|
||||
object SecondaryLibrary {
|
||||
private val bootstrapTools = linkedMapOf(
|
||||
"4185400" to "SteamLinuxRuntime_4-arm64",
|
||||
"4427310" to "Proton Experimental (ARM64)",
|
||||
"4628740" to "Proton 11.0 (ARM64)",
|
||||
)
|
||||
|
||||
fun privateRoot(files: File, library: File): File {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(library.canonicalPath.toByteArray())
|
||||
.joinToString("") { "%02x".format(it.toInt() and 255) }
|
||||
return File(files, "steam-libraries/$digest")
|
||||
}
|
||||
|
||||
/** Parent binds come first; the more specific private directories override them in PRoot. */
|
||||
fun binds(files: File, library: File): List<String> {
|
||||
val private = privateRoot(files, library)
|
||||
val apps = File(library, "steamapps")
|
||||
val tools = LinkedHashMap(bootstrapTools)
|
||||
// Include other installed Proton/runtime versions, without routing ordinary game depots.
|
||||
apps.listFiles()?.filter { it.name.matches(Regex("appmanifest_\\d+\\.acf")) }?.forEach { manifest ->
|
||||
val text = manifest.readText()
|
||||
val id = Regex("\"appid\"\\s+\"(\\d+)\"").find(text)?.groupValues?.get(1)
|
||||
val dir = Regex("\"installdir\"\\s+\"([^\"]+)\"").find(text)?.groupValues?.get(1)
|
||||
if (id != null && dir != null && File(dir).name == dir &&
|
||||
(dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime"))) tools[id] = dir
|
||||
}
|
||||
val overrides = ArrayList<Pair<File, String>>()
|
||||
for (name in listOf("compatdata", "shadercache")) {
|
||||
val target = File(private, "steamapps/$name")
|
||||
migrate(File(apps, name), target)
|
||||
overrides.add(target to "steamapps/$name")
|
||||
}
|
||||
for ((id, dir) in tools) {
|
||||
for (relative in listOf("steamapps/common/$dir", "steamapps/downloading/$id")) {
|
||||
val target = File(private, relative)
|
||||
migrate(File(library, relative), target)
|
||||
// FUSE discarded the executable bits. Steam will restore the depot's exact modes
|
||||
// on verification; its launchers must already be executable when importing it.
|
||||
target.walkTopDown().filter { it.isFile }.forEach { it.setExecutable(true, true) }
|
||||
overrides.add(target to relative)
|
||||
// Steam normalizes depot install directories to lowercase on Android's
|
||||
// case-insensitive shared storage. PRoot binds are case-sensitive, so both
|
||||
// spellings must reach the same private directory during the commit.
|
||||
val lower = relative.lowercase(Locale.ROOT)
|
||||
if (lower != relative) overrides.add(target to lower)
|
||||
}
|
||||
}
|
||||
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
|
||||
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy once into a staging directory; retain the source and never merge over a live prefix. */
|
||||
private fun migrate(source: File, target: File) {
|
||||
val migrated = File(target.parentFile, ".${target.name}.migrated")
|
||||
if (target.isDirectory) {
|
||||
if (!migrated.isFile) migrated.writeText(source.canonicalPath)
|
||||
return
|
||||
}
|
||||
check(target.parentFile!!.isDirectory || target.parentFile!!.mkdirs()) { "cannot create ${target.parent}" }
|
||||
if (!source.isDirectory || migrated.isFile) {
|
||||
check(target.mkdirs()) { "cannot create $target" }
|
||||
if (!migrated.isFile) migrated.writeText(source.canonicalPath)
|
||||
return
|
||||
}
|
||||
val stage = File(target.parentFile, "${target.name}.migrating")
|
||||
check(!stage.exists() || stage.deleteRecursively()) { "cannot clear $stage" }
|
||||
check(stage.mkdirs()) { "cannot create $stage" }
|
||||
try {
|
||||
Files.walk(source.toPath()).use { paths ->
|
||||
paths.forEach { path ->
|
||||
val destination = stage.toPath().resolve(source.toPath().relativize(path))
|
||||
if (Files.isDirectory(path, NOFOLLOW_LINKS)) Files.createDirectories(destination)
|
||||
else Files.copy(path, destination, NOFOLLOW_LINKS, COPY_ATTRIBUTES)
|
||||
}
|
||||
}
|
||||
check(stage.renameTo(target)) { "cannot finish migration to $target" }
|
||||
migrated.writeText(source.canonicalPath)
|
||||
} catch (e: Exception) {
|
||||
stage.deleteRecursively()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import java.io.File
|
||||
|
||||
/** Everything the session needs written into the runtime before it starts. */
|
||||
@@ -29,6 +30,7 @@ object SessionFiles {
|
||||
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
|
||||
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
|
||||
"usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat",
|
||||
"usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install",
|
||||
@@ -49,6 +51,7 @@ object SessionFiles {
|
||||
"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",
|
||||
"usr/local/bin/bannerlator-script-run" to "usr/local/bin/bannerlator-script-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",
|
||||
@@ -151,6 +154,9 @@ object SessionFiles {
|
||||
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
|
||||
preload.append("/usr/local/lib/libfakeinput.so\n")
|
||||
}
|
||||
// Last, so the others' opens and stats reach it through RTLD_NEXT. It stays idle in any
|
||||
// process its proot did not describe to it (ProotFastPath).
|
||||
if (ProotFastPath.enabled(context)) preload.append(ProotFastPath.LIBRARY + "\n")
|
||||
val etc = File(root, "etc").apply { mkdirs() }
|
||||
val staged = File(etc, "ld.so.preload.staged")
|
||||
if (!FileUtils.writeString(staged, preload.toString())
|
||||
|
||||
@@ -30,20 +30,13 @@ 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. */
|
||||
/** The Flathub Store (beta): its rail item. 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
|
||||
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
|
||||
@@ -302,6 +295,17 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's path lookups take proot's fast path (ProotFastPath): answered inside
|
||||
* each process instead of a round trip through the tracer. On by default; it needs proot's
|
||||
* seccomp filter, so it is off whenever proot runs without one.
|
||||
*/
|
||||
fun prootFastPath(context: Context): Boolean = prefs(context).getBoolean("prootFastPath", true)
|
||||
|
||||
fun setProotFastPath(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("prootFastPath", on).apply()
|
||||
}
|
||||
|
||||
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
|
||||
|
||||
@JvmStatic
|
||||
@@ -353,21 +357,20 @@ object SessionPrefs {
|
||||
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. On for new installs (settleDeckModeDefault). */
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On by default (settleDeckModeDefault). */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/**
|
||||
* Deck mode became the default for new installs; an install from before keeps what it ran with
|
||||
* (off), so an update never changes its interface or restarts the client on its own. Run once at
|
||||
* process start, before anything reads or writes these prefs: a new install has neither prefs
|
||||
* nor a runtime yet. The answer is written down, so it is decided once.
|
||||
* Deck mode is the default: every install, new or from before, is moved to it once. An install
|
||||
* from before 0.3.0 had off written down for it whether or not anyone chose it, so the move
|
||||
* can't tell a choice from that default and moves everyone; turning it off afterwards sticks.
|
||||
* Run once at process start, before anything reads or writes these prefs.
|
||||
*/
|
||||
fun settleDeckModeDefault(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("steamDeckMode")) return
|
||||
val existing = p.all.isNotEmpty() || java.io.File(context.filesDir, "linuxfs").exists()
|
||||
p.edit().putBoolean("steamDeckMode", !existing).apply()
|
||||
if (p.getBoolean("deckModeMoved", false)) return
|
||||
p.edit().putBoolean("steamDeckMode", true).putBoolean("deckModeMoved", true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -277,7 +278,9 @@ class SessionService : Service() {
|
||||
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
|
||||
// same set-up as a Steam session: it gets what the client and its games are started with.
|
||||
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
addClientEnvironment(guest, runtimeDir, steamHere)
|
||||
addClientEnvironment(guest, steamHere)
|
||||
// Where the fast path's description of proot's view goes, once the binds are known.
|
||||
val fastPathAt = guest.size
|
||||
|
||||
val pulse = startAudio(guest, sessionDir)
|
||||
|
||||
@@ -363,6 +366,19 @@ class SessionService : Service() {
|
||||
FileUtils.clear(File(cacheDir, "shm"))
|
||||
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir)
|
||||
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
|
||||
val fastPathKey = if (ProotFastPath.enabled(this)) {
|
||||
val prootBinds = LinuxRuntime.binds(
|
||||
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds,
|
||||
)
|
||||
val root = LinuxRuntime.rootDir(this)
|
||||
ProotFastPath.key(root, prootBinds)?.also { key ->
|
||||
val env = ProotFastPath.guestEnv(root, prootBinds, key)
|
||||
guest.addAll(fastPathAt, env)
|
||||
shellGuest.addAll(fastPathAt, env)
|
||||
Log.i(TAG, "proot: fast path on (${prootBinds.size} binds)")
|
||||
}
|
||||
} else null
|
||||
|
||||
val command = LinuxRuntime.command(
|
||||
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
|
||||
@@ -379,6 +395,7 @@ class SessionService : Service() {
|
||||
hostEnv["PROOT_NO_SECCOMP"] = "1"
|
||||
Log.i(TAG, "proot: seccomp acceleration off by request")
|
||||
}
|
||||
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> hostEnv[k] = v } }
|
||||
val prootLibs = LinuxRuntime.prootLibraryPath(this)
|
||||
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
|
||||
|
||||
@@ -483,7 +500,7 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
/** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */
|
||||
private fun addClientEnvironment(guest: MutableList<String>, runtimeDir: File, steamHere: Boolean) {
|
||||
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
|
||||
guest.add("/usr/bin/env")
|
||||
guest.add("-i")
|
||||
guest.add("HOME=/root")
|
||||
@@ -494,7 +511,7 @@ class SessionService : Service() {
|
||||
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
|
||||
// session bundle sit hours off the device's. Bannerlator carries the same line.
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
|
||||
guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR)
|
||||
guest.add("XDG_SESSION_TYPE=wayland")
|
||||
guest.add("WAYLAND_DISPLAY=wayland-0")
|
||||
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
|
||||
@@ -535,6 +552,10 @@ class SessionService : Service() {
|
||||
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
|
||||
// the old folder itself once it has gone a week untouched.
|
||||
guest.add("MESA_DISK_CACHE_DATABASE=1")
|
||||
// glibc's per-thread rseq registration is refused by Android's app seccomp policy, so
|
||||
// every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap
|
||||
// 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too.
|
||||
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
@@ -689,6 +710,33 @@ class SessionService : Service() {
|
||||
battery.attach(this)
|
||||
components.add(battery)
|
||||
binds.add(battery.dir.path + ":/sys/class/power_supply")
|
||||
// The overlay's CPU and GPU temperatures and the fan, as hwmon sensors it knows by name.
|
||||
val hwmon = HwmonComponent(File(filesDir, "session/sys/hwmon"), LinuxRuntime.rootDir(this))
|
||||
if (hwmon.prepare()) {
|
||||
hwmon.attach(this)
|
||||
components.add(hwmon)
|
||||
binds.add(hwmon.dir.path + ":/sys/class/hwmon")
|
||||
}
|
||||
// CPU load for everything in the session that reads /proc/stat, the overlay among them.
|
||||
val cpuStat = CpuStatComponent(File(filesDir, "session/proc-stat"))
|
||||
if (cpuStat.prepare()) {
|
||||
cpuStat.attach(this)
|
||||
components.add(cpuStat)
|
||||
binds.add(cpuStat.file.path + ":/proc/stat")
|
||||
}
|
||||
// The GPU memory in use for the overlay's VRAM lines, which it would read from tracefs.
|
||||
val gpuMem = GpuMemComponent(File(LinuxRuntime.rootDir(this), "run/droiddeck-hud/gpu-mem"), LinuxRuntime.rootDir(this))
|
||||
if (gpuMem.prepare()) {
|
||||
gpuMem.attach(this)
|
||||
components.add(gpuMem)
|
||||
}
|
||||
// The GPU's load and temperature for the performance overlay, where KGSL's sysfs is refused.
|
||||
val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"))
|
||||
if (gpuStats.prepare()) {
|
||||
gpuStats.attach(this)
|
||||
components.add(gpuStats)
|
||||
binds.addAll(gpuStats.binds())
|
||||
}
|
||||
// Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this
|
||||
// listener, which drives the phone's vibrator (see RumbleComponent).
|
||||
if (controllersOn) components.add(RumbleComponent().also { it.attach(this) })
|
||||
@@ -709,11 +757,14 @@ class SessionService : Service() {
|
||||
val problem = GameStorage.prepare(library.path)
|
||||
if (problem == null) {
|
||||
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
|
||||
binds.add("${library.path}:/mnt/droiddeck-sd")
|
||||
// Links, prefixes and Steam entries made before the rename still name the old path.
|
||||
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
|
||||
binds.add("${library.path}:/mnt/bannerlator-sd")
|
||||
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")")
|
||||
try {
|
||||
binds.addAll(SecondaryLibrary.binds(filesDir, File(library.path)))
|
||||
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "game storage: private directories could not be prepared; internal only this session", e)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "game storage: $problem; internal only this session")
|
||||
}
|
||||
@@ -731,6 +782,8 @@ class SessionService : Service() {
|
||||
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
|
||||
}
|
||||
}
|
||||
// Folders of added scripts outside internal storage, where their links point.
|
||||
binds.addAll(com.droiddeck.launcher.runtime.UserApps.binds(this))
|
||||
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
|
||||
if (roms != null && roms.isDirectory && roms.canRead()) {
|
||||
File(home, "ROMs").mkdirs()
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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,99 @@
|
||||
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.R
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
|
||||
/**
|
||||
* The Desktop page's added apps and the one add or remove in progress. State changes only on the
|
||||
* main thread; the work runs on a thread of its own and posts back.
|
||||
*/
|
||||
object UserAppsState {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
var items by mutableStateOf<List<UserApps.App>>(emptyList())
|
||||
private set
|
||||
/** What is being added or removed; null when idle. */
|
||||
var working by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var percent by mutableStateOf(-1)
|
||||
private set
|
||||
/** Why the last add or remove failed, shown on the page until the next one. */
|
||||
var lastError by mutableStateOf<String?>(null)
|
||||
private set
|
||||
/** The app whose updates are being looked up, and what each lookup found, by app key. */
|
||||
var checking by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var updates by mutableStateOf<Map<String, UserApps.UpdateCheck>>(emptyMap())
|
||||
private set
|
||||
/** Bumped per listing; only the newest one is shown. */
|
||||
private var listing = 0
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
val seq = ++listing
|
||||
Thread({
|
||||
val list = runCatching { UserApps.list(app) }.getOrDefault(emptyList())
|
||||
main.post { if (seq == listing) items = list }
|
||||
}, "user-apps-list").start()
|
||||
}
|
||||
|
||||
fun add(context: Context, request: UserApps.Request, label: String) =
|
||||
run(context, label, R.string.user_apps_added) { app, progress -> UserApps.add(app, request, progress) }
|
||||
|
||||
fun remove(context: Context, target: UserApps.App) {
|
||||
updates = updates - target.key
|
||||
run(context, target.name, R.string.user_apps_removed) { app, progress -> UserApps.remove(app, target, progress) }
|
||||
}
|
||||
|
||||
fun edit(context: Context, target: UserApps.App, name: String, icon: String?) =
|
||||
run(context, name, R.string.user_apps_saved) { app, _ -> UserApps.edit(app, target, name, icon) }
|
||||
|
||||
fun checkUpdate(context: Context, target: UserApps.App) {
|
||||
if (checking != null) return
|
||||
val app = context.applicationContext
|
||||
checking = target.key
|
||||
updates = updates - target.key
|
||||
Thread({
|
||||
val result = try {
|
||||
UserApps.checkUpdate(app, target)
|
||||
} catch (e: Exception) {
|
||||
UserApps.UpdateCheck.Failed(e.message ?: e.toString())
|
||||
}
|
||||
main.post { checking = null; updates = updates + (target.key to result) }
|
||||
}, "user-apps-check").start()
|
||||
}
|
||||
|
||||
fun update(context: Context, target: UserApps.App, release: UserApps.Release) =
|
||||
run(context, target.name, R.string.user_apps_updated) { app, progress ->
|
||||
UserApps.update(app, target, release, progress).also { if (it == null) main.post { updates = updates - target.key } }
|
||||
}
|
||||
|
||||
private fun run(context: Context, label: String, done: Int, work: (Context, (String, Int) -> Unit) -> String?) {
|
||||
if (working != null) return
|
||||
val app = context.applicationContext
|
||||
working = label; stage = null; percent = -1; lastError = null
|
||||
Thread({
|
||||
val problem = try {
|
||||
work(app) { s, p -> main.post { stage = s; percent = p } }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
main.post {
|
||||
working = null; stage = null; percent = -1
|
||||
lastError = problem?.let { "$label: $it" }
|
||||
if (problem == null) Toast.makeText(app, app.getString(done, label), Toast.LENGTH_SHORT).show()
|
||||
refresh(app)
|
||||
StoreState.refresh(app)
|
||||
}
|
||||
}, "user-apps-work").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.filled.Check
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
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.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.store.UserAppsState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
private enum class AddSource(val label: Int, val hint: Int) {
|
||||
SCRIPT(R.string.add_app_tab_script, R.string.add_app_script_hint),
|
||||
APPIMAGE(R.string.add_app_tab_appimage, R.string.add_app_appimage_hint),
|
||||
GITHUB(R.string.add_app_tab_github, R.string.add_app_github_hint),
|
||||
FLATPAK(R.string.add_app_tab_flatpak, R.string.add_app_flatpak_hint),
|
||||
}
|
||||
|
||||
private val ICON_TYPES = listOf("png", "jpg", "jpeg", "webp")
|
||||
private val FieldShape = RoundedCornerShape(8.dp)
|
||||
|
||||
/** What an icon choice shows: a picked file, or a suggestion's link. */
|
||||
private fun iconModel(ref: String): Any = if (ref.startsWith("https://")) ref else File(ref)
|
||||
|
||||
/** The Desktop page's Add: a script, an AppImage, a GitHub release or a Flatpak, with a name and icon. */
|
||||
@Composable
|
||||
internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: (UserApps.Request, String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
|
||||
var source by rememberSaveable { mutableStateOf(AddSource.SCRIPT) }
|
||||
var scriptPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var imagePath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var repo by rememberSaveable { mutableStateOf("") }
|
||||
var flatpak by rememberSaveable { mutableStateOf("") }
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var iconPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val pickScript = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { scriptPath = it.path }
|
||||
}
|
||||
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { imagePath = it.path }
|
||||
}
|
||||
val pickIcon = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { iconPath = it.path }
|
||||
}
|
||||
|
||||
val repoId = UserApps.githubRepo(repo)
|
||||
val flatpakId = UserApps.flatpakId(flatpak)
|
||||
val custom = name.trim().takeIf { it.isNotEmpty() }
|
||||
val request: Pair<UserApps.Request, String>? = when (source) {
|
||||
AddSource.SCRIPT -> scriptPath?.takeIf { it.endsWith(".sh", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.Script(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.APPIMAGE -> imagePath?.takeIf { it.endsWith(".appimage", ignoreCase = true) }?.let { File(it) }
|
||||
?.let { UserApps.Request(UserApps.Source.AppImage(it), custom, iconPath) to (custom ?: it.nameWithoutExtension) }
|
||||
AddSource.GITHUB -> repoId?.let { UserApps.Request(UserApps.Source.GitHub(it), custom, iconPath) to (custom ?: it) }
|
||||
AddSource.FLATPAK -> flatpakId?.let { UserApps.Request(UserApps.Source.Flatpak(it), custom, iconPath) to (custom ?: it) }
|
||||
}
|
||||
val busy = UserAppsState.working
|
||||
val tabFocus = remember { AddSource.entries.map { FocusRequester() } }
|
||||
val inputMode = LocalInputModeManager.current.inputMode
|
||||
// Once, as it opens: typing a name switches the input mode too, and must keep its field.
|
||||
LaunchedEffect(Unit) {
|
||||
if (inputMode == InputMode.Keyboard) {
|
||||
repeat(2) { androidx.compose.runtime.withFrameNanos { } }
|
||||
runCatching { tabFocus[source.ordinal].requestFocus() }
|
||||
}
|
||||
}
|
||||
val step = { by: Int -> source = AddSource.entries[(source.ordinal + by + AddSource.entries.size) % AddSource.entries.size] }
|
||||
|
||||
AppDialog(shown, close, "addApp", wide = true, modifier = Modifier.bumpers(onPrevious = { step(-1) }, onNext = { step(1) })) {
|
||||
DialogHeader(stringResource(R.string.user_apps_back), stringResource(R.string.add_app_title)) {
|
||||
TabStrip(AddSource.entries.map { stringResource(it.label) }, source.ordinal, { source = AddSource.entries[it] }, focusRequesters = tabFocus)
|
||||
}
|
||||
val glyph = if (source == AddSource.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps
|
||||
Columns(
|
||||
first = {
|
||||
Panel(stringResource(R.string.add_app_source)) {
|
||||
AnimatedContent(
|
||||
targetState = source,
|
||||
transitionSpec = {
|
||||
val dir = if (targetState.ordinal > initialState.ordinal) 1 else -1
|
||||
(fadeIn(Motion.tw(260, 60)) + slideInHorizontally(Motion.tw(320, 60)) { dir * it / 12 })
|
||||
.togetherWith(fadeOut(Motion.tw(140)) + slideOutHorizontally(Motion.tw(140)) { -dir * it / 16 })
|
||||
},
|
||||
label = "addSource",
|
||||
) { src ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Small(stringResource(src.hint))
|
||||
when (src) {
|
||||
AddSource.SCRIPT -> {
|
||||
FileRow(scriptPath) { pickScript.launch(InAppFilePicker.buildIntent(ctx, listOf("sh"), ctx.getString(R.string.add_app_pick_script))) }
|
||||
scriptPath?.let { FolderNote(it) }
|
||||
}
|
||||
AddSource.APPIMAGE -> FileRow(imagePath) {
|
||||
pickImage.launch(InAppFilePicker.buildIntent(ctx, listOf("appimage"), ctx.getString(R.string.add_app_pick_appimage)))
|
||||
}
|
||||
AddSource.GITHUB -> CheckedField(
|
||||
repo, { repo = it.take(200) }, stringResource(R.string.add_app_repository),
|
||||
stringResource(R.string.add_app_repository_placeholder),
|
||||
stringResource(R.string.add_app_repository_invalid).takeIf { repo.isNotBlank() && repoId == null },
|
||||
)
|
||||
AddSource.FLATPAK -> CheckedField(
|
||||
flatpak, { flatpak = it.take(200) }, stringResource(R.string.add_app_flatpak_id),
|
||||
stringResource(R.string.add_app_flatpak_placeholder),
|
||||
stringResource(R.string.add_app_flatpak_invalid).takeIf { flatpak.isNotBlank() && flatpakId == null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
second = {
|
||||
Panel(stringResource(R.string.add_app_appearance)) {
|
||||
AppPreview(iconPath?.let(::iconModel), glyph, request?.second, stringResource(source.label))
|
||||
NameField(name, { name = it }, stringResource(R.string.add_app_name_placeholder))
|
||||
IconActions(
|
||||
onChoose = { pickIcon.launch(InAppFilePicker.buildIntent(ctx, ICON_TYPES, ctx.getString(R.string.add_app_pick_icon))) },
|
||||
clear = iconPath?.let { R.string.add_app_clear_icon to { iconPath = null } },
|
||||
)
|
||||
if (source == AddSource.GITHUB && repoId != null) IconSuggestions(repoId, iconPath) { iconPath = it }
|
||||
}
|
||||
},
|
||||
)
|
||||
DialogFooter(
|
||||
when {
|
||||
!runtimeReady -> stringResource(R.string.add_app_runtime_required)
|
||||
busy != null -> stringResource(R.string.add_app_busy, busy)
|
||||
else -> null
|
||||
},
|
||||
stringResource(R.string.add_app_confirm), request != null && runtimeReady && busy == null,
|
||||
onCancel = close,
|
||||
) { request?.let { (r, label) -> onAdd(r, label); close() } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An added app's name and icon, changed on its page. [icon]: null keeps the current one, "" goes
|
||||
* back to the default, else a picked file or a suggestion.
|
||||
*/
|
||||
@Composable
|
||||
internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (String, String?) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var name by rememberSaveable(app.key) { mutableStateOf(app.name) }
|
||||
var icon by rememberSaveable(app.key) { mutableStateOf<String?>(null) }
|
||||
val pickIcon = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { icon = it.path }
|
||||
}
|
||||
val preview: Any? = when (val i = icon) {
|
||||
null -> app.icon
|
||||
"" -> null
|
||||
else -> iconModel(i)
|
||||
}
|
||||
val busy = UserAppsState.working
|
||||
val changed = name.trim() != app.name || icon != null
|
||||
AppDialog(shown, close, "editApp", wide = false) {
|
||||
DialogHeader(stringResource(app.kind.label()), stringResource(R.string.edit_app_title, app.name))
|
||||
Rise(1) {
|
||||
Panel(stringResource(R.string.add_app_appearance)) {
|
||||
AppPreview(
|
||||
preview, if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps,
|
||||
name.trim().ifEmpty { null }, stringResource(app.kind.label()),
|
||||
)
|
||||
NameField(name, { name = it }, app.name)
|
||||
IconActions(
|
||||
onChoose = { pickIcon.launch(InAppFilePicker.buildIntent(ctx, ICON_TYPES, ctx.getString(R.string.add_app_pick_icon))) },
|
||||
clear = if (preview == null) null else R.string.add_app_icon_reset to { icon = "" },
|
||||
)
|
||||
app.repo?.let { repo -> IconSuggestions(repo, icon) { icon = it } }
|
||||
}
|
||||
}
|
||||
DialogFooter(
|
||||
busy?.let { stringResource(R.string.add_app_busy, it) },
|
||||
stringResource(R.string.edit_app_save), name.isNotBlank() && changed && busy == null,
|
||||
onCancel = close,
|
||||
) { onSave(name.trim(), icon); close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Open from the first frame; [onDismiss] once the closing animation has run. */
|
||||
@Composable
|
||||
internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolean> {
|
||||
val shown = remember { MutableTransitionState(false).apply { targetState = true } }
|
||||
LaunchedEffect(shown.currentState, shown.isIdle) { if (shown.isIdle && !shown.currentState && !shown.targetState) onDismiss() }
|
||||
return shown
|
||||
}
|
||||
|
||||
/**
|
||||
* The card both dialogs sit in, sized to fit a landscape handheld without scrolling: it scales in
|
||||
* like the app's menus, rises above the keyboard, and closes on a tap outside or B.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppDialog(
|
||||
shown: MutableTransitionState<Boolean>, close: () -> Unit, label: String, wide: Boolean,
|
||||
modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
// The page's ring stays out of sight behind it.
|
||||
VeilRing()
|
||||
Dialog(onDismissRequest = close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
|
||||
// The page's focus memory stays the page's: nothing in here is a place to come back to.
|
||||
CompositionLocalProvider(LocalFrontFocus provides null) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize().imePadding()
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = close),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visibleState = shown,
|
||||
enter = fadeIn(Motion.tw(180)) + scaleIn(Motion.sp(0.7f), initialScale = 0.94f),
|
||||
exit = fadeOut(Motion.tw(140)) + scaleOut(Motion.tw(140), targetScale = 0.96f),
|
||||
label = label,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.widthIn(max = if (wide) 860.dp else 560.dp)
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 24).dp)
|
||||
.shadow(24.dp, Shape16, ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(Shape16)
|
||||
.background(pal.surfaceVariant.copy(alpha = 0.97f))
|
||||
.border(1.dp, pal.signal.copy(alpha = 0.22f), Shape16)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
|
||||
.controllerBack(close)
|
||||
.then(modifier)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The eyebrow and title, with [trailing] (the tabs) beside them when there is room, else under them. */
|
||||
@Composable
|
||||
internal fun DialogHeader(eyebrow: String, title: String, trailing: (@Composable () -> Unit)? = null) {
|
||||
val heading = @Composable {
|
||||
Column {
|
||||
Eyebrow(eyebrow)
|
||||
Text(title, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Rise(0) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
when {
|
||||
trailing == null -> heading()
|
||||
maxWidth >= 600.dp -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Box(Modifier.weight(1f)) { heading() }
|
||||
trailing()
|
||||
}
|
||||
else -> Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { heading(); trailing() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Side by side and of one height on a wide card, stacked on a narrow one. */
|
||||
@Composable
|
||||
private fun Columns(first: @Composable ColumnScope.() -> Unit, second: @Composable ColumnScope.() -> Unit) {
|
||||
Rise(1) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth >= 600.dp) Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.height(IntrinsicSize.Max)) {
|
||||
Column(Modifier.weight(1.15f).fillMaxHeight(), content = first)
|
||||
Column(Modifier.weight(1f).fillMaxHeight(), content = second)
|
||||
} else Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
first()
|
||||
second()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A titled inset of the card; it fills the height its column is given. */
|
||||
@Composable
|
||||
private fun Panel(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight().clip(Shape14)
|
||||
.background(colors.surface.copy(alpha = 0.6f)).border(1.dp, LocalPalette.current.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text(title.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Why the main button is off (or nothing), then Cancel and the main button, at the card's foot. */
|
||||
@Composable
|
||||
internal fun DialogFooter(note: String?, confirm: String, enabled: Boolean, onCancel: () -> Unit, onConfirm: () -> Unit) {
|
||||
val lineColor = LocalPalette.current.line
|
||||
Rise(2) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.drawBehind { drawLine(lineColor, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) }.padding(top = 14.dp),
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { if (note != null) Small(note) }
|
||||
SecondaryButton(stringResource(R.string.add_app_cancel), onClick = onCancel)
|
||||
PrimaryButton(confirm, enabled = enabled, onClick = onConfirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Small(text: String, error: Boolean = false) =
|
||||
Text(text, fontSize = 12.sp, lineHeight = 16.sp, color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
private fun hideKeyboard(view: View) {
|
||||
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? InputMethodManager)
|
||||
?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
|
||||
/** The app's text field, with what is wrong with its value under it. */
|
||||
@Composable
|
||||
private fun CheckedField(value: String, onChange: (String) -> Unit, label: String, placeholder: String, problem: String?) {
|
||||
val view = LocalView.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
AdbTextField(
|
||||
value, onChange, label, KeyboardType.Uri, ImeAction.Done, placeholder = placeholder, compact = true,
|
||||
onDone = { hideKeyboard(view) },
|
||||
)
|
||||
if (problem != null) Small(problem, error = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameField(name: String, onName: (String) -> Unit, placeholder: String) {
|
||||
val view = LocalView.current
|
||||
AdbTextField(
|
||||
name, { onName(it.take(80)) }, stringResource(R.string.add_app_name), KeyboardType.Text, ImeAction.Done,
|
||||
placeholder = placeholder, compact = true, onDone = { hideKeyboard(view) },
|
||||
)
|
||||
}
|
||||
|
||||
/** The app as the Desktop page will show it: its icon, its name ([name] null: not known yet) and kind. */
|
||||
@Composable
|
||||
private fun AppPreview(icon: Any?, glyph: ImageVector, name: String?, kind: String) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape12).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape12).padding(10.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp).clip(Shape12).background(colors.surface)) {
|
||||
AnimatedContent(icon, transitionSpec = { (fadeIn(Motion.tw(260)) + scaleIn(Motion.sp(0.6f), 0.85f)) togetherWith fadeOut(Motion.tw(160)) }, label = "appIcon") { model ->
|
||||
if (model != null) AsyncImage(model = model, contentDescription = stringResource(R.string.add_app_icon), contentScale = ContentScale.Fit, modifier = Modifier.size(40.dp))
|
||||
else Icon(glyph, stringResource(R.string.add_app_icon_default), tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
name ?: stringResource(R.string.add_app_preview_name), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (name != null) colors.onBackground else colors.onSurfaceVariant,
|
||||
)
|
||||
Text(kind, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon's picker and [clear] (a label and what it does). */
|
||||
@Composable
|
||||
private fun IconActions(onChoose: () -> Unit, clear: Pair<Int, () -> Unit>?) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SecondaryButton(stringResource(R.string.add_app_choose_icon), compact = true, onClick = onChoose)
|
||||
AnimatedVisibility(clear != null, enter = fadeIn(Motion.tw(180)) + expandHorizontally(Motion.tw(220)), exit = fadeOut(Motion.tw(120)) + shrinkHorizontally(Motion.tw(180))) {
|
||||
clear?.let { (label, action) -> SecondaryButton(stringResource(label), compact = true, onClick = action) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Icons found in [repo], looked up off the main thread once the name has settled; [selected] is ringed. */
|
||||
@Composable
|
||||
private fun IconSuggestions(repo: String, selected: String?, onPick: (String) -> Unit) {
|
||||
val found by produceState<List<String>?>(null, repo) {
|
||||
value = null
|
||||
delay(500)
|
||||
value = withContext(Dispatchers.IO) { runCatching { UserApps.githubIcons(repo) }.getOrDefault(emptyList()) }
|
||||
}
|
||||
val list = found
|
||||
if (list != null && list.isEmpty()) return
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(44.dp)) {
|
||||
Small(stringResource(if (list == null) R.string.add_app_icon_searching else R.string.add_app_icon_suggestions))
|
||||
if (list != null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
list.forEachIndexed { i, url -> Rise(i) { SuggestedIcon(url, url == selected) { onPick(url) } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuggestedIcon(url: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val scale by animateFloatAsState(if (hot || selected) 1.06f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "suggestScale")
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(colors.surface)
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(url).crossfade(true).build(), contentDescription = stringResource(R.string.add_app_icon), contentScale = ContentScale.Fit, modifier = Modifier.size(34.dp))
|
||||
// The chosen one is ticked; the ring stays with focus.
|
||||
if (selected) Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(14.dp).clip(androidx.compose.foundation.shape.CircleShape).background(pal.signal),
|
||||
) { Icon(Icons.Filled.Check, contentDescription = null, tint = pal.onSignal, modifier = Modifier.size(10.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The chosen file, or none yet, laid out like the app's text fields, with the button that picks it. */
|
||||
@Composable
|
||||
private fun FileRow(path: String?, onChoose: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column {
|
||||
Text(stringResource(R.string.add_app_file), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.weight(1f).height(44.dp).background(colors.surfaceVariant, FieldShape).border(1.dp, pal.line2, FieldShape).padding(horizontal = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
path?.substringAfterLast('/') ?: stringResource(R.string.add_app_no_file), fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (path != null) colors.onBackground else colors.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SecondaryButton(stringResource(if (path == null) R.string.add_app_choose_file else R.string.add_app_change_file), compact = true, onClick = onChoose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the script's folder will be linked or copied in, worked out off the main thread. */
|
||||
@Composable
|
||||
private fun FolderNote(scriptPath: String) {
|
||||
var plan by remember(scriptPath) { mutableStateOf<UserApps.ScriptPlan?>(null) }
|
||||
LaunchedEffect(scriptPath) {
|
||||
plan = withContext(Dispatchers.IO) { runCatching { UserApps.planScript(File(scriptPath)) }.getOrNull() }
|
||||
}
|
||||
val p = plan
|
||||
Small(
|
||||
when {
|
||||
p == null -> stringResource(R.string.add_app_checking_folder)
|
||||
p.copy -> stringResource(R.string.add_app_folder_copied, FileUtils.sizeToString(p.bytes))
|
||||
else -> stringResource(R.string.add_app_folder_linked, p.folder.path)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -114,16 +117,18 @@ fun ComponentsPage(
|
||||
var confirmTitle by remember { mutableStateOf("") }
|
||||
var about by remember { mutableStateOf(false) }
|
||||
var protonMenu by remember { mutableStateOf(false) }
|
||||
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
|
||||
var confirmVerb by remember { mutableStateOf(R.string.comp_swap) }
|
||||
val ctx = LocalContext.current
|
||||
fun ask(title: String, body: String, verb: Int, action: () -> Unit) { confirmTitle = title; confirmVerb = verb; confirm = body to action }
|
||||
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
val gpuTab = comp == GPU_TAB
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
|
||||
title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint),
|
||||
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false,
|
||||
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
@@ -131,9 +136,9 @@ fun ComponentsPage(
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
|
||||
title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint),
|
||||
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools),
|
||||
canRestore = gpu.canRestoreBundled,
|
||||
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
|
||||
@@ -146,14 +151,14 @@ fun ComponentsPage(
|
||||
val views = snapshot?.protons ?: emptyList()
|
||||
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
|
||||
val label = ComponentsManager.LABEL[comp] ?: comp
|
||||
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
|
||||
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked"
|
||||
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else stringResource(R.string.comp_never)
|
||||
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else stringResource(R.string.comp_nightlies_checked, checked)
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
// ---- title, and the one thing that goes online -----------------------------------------
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
stringResource(R.string.comp_title), fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
maxLines = 1,
|
||||
)
|
||||
// The status gives way to the title, not the other way round.
|
||||
@@ -161,9 +166,9 @@ fun ComponentsPage(
|
||||
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||
) else Spacer(Modifier.weight(1f))
|
||||
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
|
||||
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
if (!gpuTab) ToolIcon(Icons.Outlined.Info, stringResource(R.string.comp_about)) { about = true }
|
||||
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_drivers), busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
|
||||
else ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_nightlies), busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
}
|
||||
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -175,26 +180,27 @@ fun ComponentsPage(
|
||||
) {
|
||||
if (view != null && !gpuTab) Box {
|
||||
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
|
||||
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
|
||||
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
|
||||
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = stringResource(R.string.comp_proton),
|
||||
note = stringResource(R.string.comp_proton_note)) { first ->
|
||||
views.forEachIndexed { i, v ->
|
||||
val swaps = v.components.values.count { it.activeFile != null }
|
||||
MenuItem(
|
||||
v.proton.name, checked = v.proton.id == view.proton.id,
|
||||
detail = v.proton.version + (if (swaps > 0) " · $swaps swapped" else " · all original") + (if (v.inUseByGame) " · game running" else ""),
|
||||
detail = (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version))
|
||||
.let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it },
|
||||
focusRequester = if (i == 0) first else null,
|
||||
) { onProton(v.proton.id); protonMenu = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
val tabs = listOf(GPU_TAB) + comps
|
||||
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
|
||||
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: stringResource(R.string.comp_gpu_drivers) }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
|
||||
}
|
||||
|
||||
// ---- what is in use -----------------------------------------------------------------------
|
||||
if (view != null && !gpuTab) {
|
||||
val st = view.components.getValue(comp)
|
||||
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
|
||||
val reappliedAt = if (view.reappliedAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else null
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface)
|
||||
@@ -202,12 +208,13 @@ fun ComponentsPage(
|
||||
) {
|
||||
Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape))
|
||||
Text(
|
||||
(if (st.queued != null) "Next: ${st.queued} · after the game closes" else "$label in use: ${st.inUse}") + fixedAt,
|
||||
(if (st.queued != null) stringResource(R.string.comp_next_queued, st.queued) else stringResource(R.string.comp_in_use, label, st.inUse))
|
||||
.let { if (reappliedAt != null) stringResource(R.string.comp_reapplied, it, reappliedAt) else it },
|
||||
fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (st.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued)
|
||||
if (st.queued != null) FocusText(stringResource(R.string.common_cancel), pal.signal, onClick = onCancelQueued)
|
||||
else if (!narrow) Text(
|
||||
if (view.inUseByGame) "Waits until the game closes" else "Changes apply the next time a game starts",
|
||||
if (view.inUseByGame) stringResource(R.string.comp_waits) else stringResource(R.string.comp_applies_next_game),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1,
|
||||
)
|
||||
}
|
||||
@@ -218,16 +225,16 @@ fun ComponentsPage(
|
||||
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
|
||||
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
|
||||
Row(modifier = Modifier.padding(top = 14.dp)) {
|
||||
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
|
||||
SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip)
|
||||
}
|
||||
}
|
||||
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Reading the Protons…", fontSize = 14.sp, color = colors.onSurfaceVariant)
|
||||
Text(stringResource(R.string.comp_reading), fontSize = 14.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
view == null -> Text(
|
||||
"No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.",
|
||||
stringResource(R.string.comp_no_proton),
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
else -> {
|
||||
@@ -240,12 +247,12 @@ fun ComponentsPage(
|
||||
val rows = originals.map { o ->
|
||||
val isCurrent = o.protonVersion == build
|
||||
InstalledItem(
|
||||
"Original · ${o.protonVersion}",
|
||||
o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · earlier build",
|
||||
stringResource(R.string.comp_original, o.protonVersion),
|
||||
o.label.substringAfterLast(" · ", "").ifEmpty { o.label }.let { if (isCurrent) stringResource(R.string.comp_this_build, it) else stringResource(R.string.comp_earlier_build, it) },
|
||||
"ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion,
|
||||
)
|
||||
} + stored.map { s ->
|
||||
InstalledItem(s.version, String.format("%.1f MB", s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null)
|
||||
InstalledItem(s.version, stringResource(R.string.common_size_mb, s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null)
|
||||
}
|
||||
val storedNames = snapshot.packages.map { it.file }.toSet()
|
||||
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
|
||||
@@ -255,9 +262,9 @@ fun ComponentsPage(
|
||||
}
|
||||
|
||||
val installed: @Composable () -> Unit = {
|
||||
SettingsGroup("Installed · ${rows.size}") {
|
||||
SettingsGroup(stringResource(R.string.comp_installed_count, rows.size)) {
|
||||
if (rows.isEmpty()) Text(
|
||||
"Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.",
|
||||
stringResource(R.string.comp_nothing_stored, label),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
rows.forEachIndexed { i, item ->
|
||||
@@ -265,22 +272,22 @@ fun ComponentsPage(
|
||||
item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier,
|
||||
onSelect = {
|
||||
if (item.selected) return@InstalledLine
|
||||
val waits = if (view.inUseByGame) " A game is running on this Proton, so it waits until the game closes." else " It applies the next time a game starts."
|
||||
if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle.$waits") { onSwap(item.file) }
|
||||
else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?$waits") { onRestore(item.protonVersion!!) }
|
||||
val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long)
|
||||
if (item.file != null) ask(ctx.getString(R.string.comp_swap_title, label), ctx.getString(R.string.comp_swap_body, item.name, p.name, waits), R.string.comp_swap) { onSwap(item.file) }
|
||||
else ask(ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_body, item.protonVersion, p.name, waits), R.string.common_restore) { onRestore(item.protonVersion!!) }
|
||||
},
|
||||
onDelete = {
|
||||
if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) }
|
||||
else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) }
|
||||
if (item.file != null) ask(ctx.getString(R.string.common_delete_named, item.name) + "?", ctx.getString(R.string.comp_delete_package_body), R.string.common_delete) { onDeletePackage(item.file) }
|
||||
else ask(ctx.getString(R.string.comp_delete_original_title), ctx.getString(R.string.comp_delete_original_body, item.protonVersion, p.name), R.string.common_delete) { onDeleteOriginal(item.protonVersion!!) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val nightly: @Composable () -> Unit = {
|
||||
SettingsGroup("On the Nightlies · ${available.size}") {
|
||||
SettingsGroup(stringResource(R.string.comp_nightlies_count, available.size)) {
|
||||
if (available.isEmpty()) Text(
|
||||
if (catalogAt == 0L) "Refresh to list the packages on the Nightlies." else "Nothing new since the last check.",
|
||||
if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
available.forEach { d -> AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } }
|
||||
@@ -293,9 +300,9 @@ fun ComponentsPage(
|
||||
Column(Modifier.weight(1f)) { nightly() }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
|
||||
SmallButton("Import .wcp", onClick = onImport)
|
||||
if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText("Restore this Proton's original", pal.signal) {
|
||||
ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(build) }
|
||||
SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport)
|
||||
if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText(stringResource(R.string.comp_restore_original), pal.signal) {
|
||||
ask(ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_original_body, p.name, label), R.string.common_restore) { onRestore(build) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,17 +312,14 @@ fun ComponentsPage(
|
||||
|
||||
if (about) AlertDialog(
|
||||
onDismissRequest = { about = false },
|
||||
title = { Text("Components") },
|
||||
title = { Text(stringResource(R.string.comp_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" +
|
||||
"Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" +
|
||||
"Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\n\n" +
|
||||
"Each Proton build's own files are kept as its original, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.",
|
||||
stringResource(R.string.comp_about_text),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = { FocusText("OK", pal.signal) { about = false } },
|
||||
confirmButton = { FocusText(stringResource(R.string.common_ok), pal.signal) { about = false } },
|
||||
)
|
||||
confirm?.let { (body, action) ->
|
||||
AlertDialog(
|
||||
@@ -324,13 +328,13 @@ fun ComponentsPage(
|
||||
text = { Text(body, fontSize = 14.sp) },
|
||||
// Opens on Cancel, so a stray A press on a controller never swaps or deletes anything.
|
||||
confirmButton = {
|
||||
val del = confirmTitle.startsWith("Delete")
|
||||
FocusText(if (del) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", if (del) colors.error else pal.signal) { confirm = null; action() }
|
||||
val del = confirmVerb == R.string.common_delete
|
||||
FocusText(stringResource(confirmVerb), if (del) colors.error else pal.signal) { confirm = null; action() }
|
||||
},
|
||||
dismissButton = {
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
|
||||
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
|
||||
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -392,7 +396,13 @@ private fun Tag(text: String) {
|
||||
else -> colors.onSurfaceVariant to Color.White.copy(alpha = 0.07f)
|
||||
}
|
||||
Text(
|
||||
text.lowercase().replaceFirstChar { it.uppercase() }, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1,
|
||||
when (text) {
|
||||
"ORIGINAL" -> stringResource(R.string.comp_tag_original)
|
||||
"IN USE" -> stringResource(R.string.comp_tag_in_use)
|
||||
"NEW" -> stringResource(R.string.comp_tag_new)
|
||||
"STORED" -> stringResource(R.string.comp_tag_stored)
|
||||
else -> text.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(bg).padding(horizontal = 7.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
@@ -443,7 +453,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
|
||||
.glideBorder(delHot, ROW_SHAPE, colors.error)
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
|
||||
.controllerConfirm(onClick = onDelete),
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, item.name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
@@ -460,11 +470,11 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(d.file.removeSuffix(".wcp"), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
"${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
"${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (progress == null) SmallButton("Download", enabled = enabled, accent = true, onClick = onDownload)
|
||||
if (progress == null) SmallButton(stringResource(R.string.common_download), enabled = enabled, accent = true, onClick = onDownload)
|
||||
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -36,17 +38,17 @@ private fun Swatch(color: Int) {
|
||||
@Composable
|
||||
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
|
||||
ChoiceRow(
|
||||
host, "controller-osc", "On-screen controls", "When the touch pad appears in a session",
|
||||
listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never"),
|
||||
oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
|
||||
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
|
||||
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
SettingsRow("Color", "Tint of the on-screen buttons", highlighted = tintOpen) {
|
||||
SettingsRow(stringResource(R.string.ctrl_color), stringResource(R.string.ctrl_color_hint), highlighted = tintOpen) {
|
||||
Box {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: stringResource(R.string.ctrl_custom), tintOpen) {
|
||||
host.open = if (tintOpen) null else "controller-tint"
|
||||
}
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Color") { firstItemFocus ->
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = stringResource(R.string.ctrl_color)) { firstItemFocus ->
|
||||
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
|
||||
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTint(color)
|
||||
@@ -56,36 +58,36 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
}
|
||||
}
|
||||
}
|
||||
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "Sticks appear when touched near their saved positions and hide when released", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
|
||||
ChoiceRow(host, "controller-opacity", stringResource(R.string.ctrl_opacity), null, ControllerPrefs.opacities.map { it to stringResource(R.string.ctrl_percent, it) }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), stringResource(R.string.ctrl_size_hint), ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", stringResource(R.string.ctrl_stick_click), stringResource(R.string.ctrl_stick_click_hint), c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", stringResource(R.string.ctrl_adaptive), stringResource(R.string.ctrl_adaptive_hint), c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton("Edit") { a.onEditLayout() }
|
||||
SecondaryButton("Reset", enabled = c.customLayout) { a.onResetLayout() }
|
||||
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
|
||||
SecondaryButton(stringResource(R.string.ctrl_reset), enabled = c.customLayout) { a.onResetLayout() }
|
||||
}
|
||||
}
|
||||
val remapped = c.mapping.count { (id, target) -> id != target }
|
||||
ActionRow("Button mapping", if (remapped == 0) "Every button sends its own input" else "$remapped of ${c.mapping.size} buttons remapped", "Configure", a.onMapping)
|
||||
ActionRow("Reset controller", "Restore the default color, opacity, size, stick behavior, mapping and layout", "Reset", a.onResetAll)
|
||||
ActionRow(stringResource(R.string.ctrl_mapping), if (remapped == 0) stringResource(R.string.ctrl_mapping_default) else stringResource(R.string.ctrl_mapping_count, remapped, c.mapping.size), stringResource(R.string.ctrl_configure), a.onMapping)
|
||||
ActionRow(stringResource(R.string.ctrl_reset_all), stringResource(R.string.ctrl_reset_all_hint), stringResource(R.string.ctrl_reset), a.onResetAll)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ControllerMappingPage(mapping: Map<String, String>, onPick: (String, String) -> Unit, onReset: () -> Unit, onBack: () -> Unit) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsPage(
|
||||
host, title = "Button mapping", eyebrow = "Controller",
|
||||
lede = "Choose what each on-screen button sends to the game. Hidden removes the button.",
|
||||
host, title = stringResource(R.string.ctrl_mapping), eyebrow = stringResource(R.string.setup_controller),
|
||||
lede = stringResource(R.string.ctrl_mapping_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsGroup("On-screen buttons") {
|
||||
SettingsGroup(stringResource(R.string.ctrl_onscreen_buttons)) {
|
||||
for ((id, name) in ControllerPrefs.mappable) {
|
||||
ChoiceRow(host, "map-$id", name, null, ControllerPrefs.targets, mapping[id] ?: id) { onPick(id, it) }
|
||||
}
|
||||
}
|
||||
SettingsGroup("Defaults") {
|
||||
ActionRow("Reset mapping", "Every button sends its own input again", "Reset", onReset)
|
||||
SettingsGroup(stringResource(R.string.ctrl_defaults)) {
|
||||
ActionRow(stringResource(R.string.ctrl_reset_mapping), stringResource(R.string.ctrl_mapping_default), stringResource(R.string.ctrl_reset), onReset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
@@ -36,7 +37,7 @@ class DeckControlsPanel(
|
||||
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 enum class Tab(val label: Int) { GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
|
||||
private val content = FrameLayout(context)
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
@@ -58,14 +59,14 @@ class DeckControlsPanel(
|
||||
orientation = HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dp(12), dp(10), dp(12), dp(6))
|
||||
addView(roundButton("×", "Close second-screen controls", onClose))
|
||||
addView(roundButton("×", context.getString(R.string.deck_close), 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
|
||||
text = context.getString(tab.label)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
@@ -81,9 +82,9 @@ class DeckControlsPanel(
|
||||
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(roundButton("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu, wide = true))
|
||||
addView(space(dp(8)))
|
||||
addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true))
|
||||
addView(roundButton("•••", context.getString(R.string.drawer_open_qam), onQam, wide = true))
|
||||
}
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
@@ -114,9 +115,9 @@ class DeckControlsPanel(
|
||||
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)),
|
||||
addView(column(GripButton(context, "L4", context.getString(R.string.deck_left_upper), DeckControls.L4), GripButton(context, "L5", context.getString(R.string.deck_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)),
|
||||
addView(column(GripButton(context, "R4", context.getString(R.string.deck_right_upper), DeckControls.R4), GripButton(context, "R5", context.getString(R.string.deck_right_lower), DeckControls.R5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
|
||||
@@ -255,7 +256,7 @@ class DeckControlsPanel(
|
||||
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) })
|
||||
HoldButton(context, context.getString(R.string.deck_click), if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_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
|
||||
@@ -282,7 +283,7 @@ class DeckControlsPanel(
|
||||
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)
|
||||
canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.hoverable
|
||||
@@ -98,11 +100,11 @@ fun DriverPage(
|
||||
.clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh),
|
||||
) {
|
||||
if (checking) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
else Icon(Icons.Outlined.Refresh, contentDescription = "Check for new drivers", tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
else Icon(Icons.Outlined.Refresh, contentDescription = stringResource(R.string.comp_check_drivers), tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
},
|
||||
) {
|
||||
SettingsGroup("Installed") {
|
||||
SettingsGroup(stringResource(R.string.driver_installed)) {
|
||||
val focusRow = rows.firstOrNull { it.id == selected } ?: rows.firstOrNull()
|
||||
rows.forEachIndexed { i, row ->
|
||||
InstalledRow(
|
||||
@@ -113,14 +115,14 @@ fun DriverPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup("Available to download") {
|
||||
SettingsGroup(stringResource(R.string.driver_available)) {
|
||||
if (downloads.isEmpty()) Text(
|
||||
"Nothing new from the last check. Tap the refresh button to look for newer releases.",
|
||||
stringResource(R.string.driver_nothing_new),
|
||||
fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
) else for (d in downloads) {
|
||||
SettingsRow(d.label, d.detail) {
|
||||
val p = d.progress
|
||||
if (p == null) SecondaryButton("Download") { onDownload(d.key) }
|
||||
if (p == null) SecondaryButton(stringResource(R.string.common_download)) { onDownload(d.key) }
|
||||
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(120.dp)) {
|
||||
Text("$p%", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -131,27 +133,27 @@ fun DriverPage(
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
|
||||
SecondaryButton(importLabel, onClick = onImport)
|
||||
if (canRestore) FocusText("Restore built-in drivers", pal.signal, onClick = onRestore)
|
||||
if (canRestore) FocusText(stringResource(R.string.driver_restore_builtin), pal.signal, onClick = onRestore)
|
||||
}
|
||||
}
|
||||
confirm?.let { row ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirm = null },
|
||||
title = { Text("Delete ${row.name}?") },
|
||||
title = { Text(stringResource(R.string.common_delete_named, row.name) + "?") },
|
||||
text = {
|
||||
Text(
|
||||
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
|
||||
else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default."
|
||||
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
|
||||
if (row.tag == DriverRow.BUNDLED) stringResource(R.string.driver_delete_builtin)
|
||||
else if (row.tag == DriverRow.BUNDLE) stringResource(R.string.driver_delete_bundle)
|
||||
else if (row.id == selected) stringResource(R.string.driver_delete_in_use) else stringResource(R.string.driver_delete_plain),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
},
|
||||
// Opens on Cancel, so a stray A press on a controller never deletes anything.
|
||||
confirmButton = { FocusText("Delete", colors.error) { confirm = null; onDelete(row.id) } },
|
||||
confirmButton = { FocusText(stringResource(R.string.common_delete), colors.error) { confirm = null; onDelete(row.id) } },
|
||||
dismissButton = {
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
|
||||
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
|
||||
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -214,7 +216,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
|
||||
.focusRequester(delFocus)
|
||||
.focusProperties { left = mainFocus }
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete),
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${row.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) }
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, row.name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) }
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.view.View
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,6 +45,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.hypot
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
||||
// One focus ring for a whole screen that slides from control to control. When focus moves, the
|
||||
// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so
|
||||
@@ -63,6 +66,8 @@ internal class FocusGlide(val view: View) {
|
||||
val hot = mutableStateListOf<GlideSource>()
|
||||
/** Bumped when the current control moves (a scroll, a relayout). */
|
||||
var moves by mutableIntStateOf(0)
|
||||
/** Off while a page floods in or out (PageFlood); back on, it appears on whatever has focus. */
|
||||
var hidden by mutableStateOf(false)
|
||||
|
||||
val current: GlideSource? get() = hot.lastOrNull()
|
||||
|
||||
@@ -82,6 +87,20 @@ internal class FocusGlide(val view: View) {
|
||||
|
||||
internal val LocalFocusGlide = staticCompositionLocalOf<FocusGlide?> { null }
|
||||
|
||||
/** Floods on screen (a launch, a stop, the return from one): every ring keeps out of the way of them. */
|
||||
internal object RingVeil {
|
||||
var count by mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
/** Keeps the focus ring off while this is in the composition. */
|
||||
@Composable
|
||||
internal fun VeilRing() {
|
||||
DisposableEffect(Unit) {
|
||||
RingVeil.count++
|
||||
onDispose { RingVeil.count-- }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float =
|
||||
when (val o = shape.createOutline(size, dir, density)) {
|
||||
is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x
|
||||
@@ -129,6 +148,12 @@ private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
|
||||
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
|
||||
private const val SETTLE_MS = 450L
|
||||
|
||||
/** Moves closer together than this are a held direction: the ring runs along as one, no stretch. */
|
||||
private const val REPEAT_MS = 180L
|
||||
|
||||
/** A move further than this goes as a droplet: pinched to a drop, carried across, opened onto the control. */
|
||||
private val FAR = 360.dp
|
||||
|
||||
/**
|
||||
* Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the
|
||||
* host's space, so put the host inside anything that moves as a whole (a sheet that slides in).
|
||||
@@ -141,9 +166,12 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
val glide = remember(view) { FocusGlide(view) }
|
||||
val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom
|
||||
val corner = remember { Animatable(0f) }
|
||||
/** How solid the ring is: 1 while it travels as a drop, 0 (an outline) on a control. */
|
||||
val solid = remember { Animatable(0f) }
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val jobs = remember { arrayOfNulls<Job>(5) }
|
||||
// An edge each, the corner, and a droplet carrying all of them.
|
||||
val jobs = remember { arrayOfNulls<Job>(6) }
|
||||
val cur = glide.current
|
||||
var lastColor by remember { mutableStateOf(Color.Transparent) }
|
||||
if (cur != null) lastColor = cur.color
|
||||
@@ -156,11 +184,48 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
var movedAt = 0L
|
||||
var dirSpecs: List<AnimationSpec<Float>> = List(4) { follow() }
|
||||
var delays = LongArray(4)
|
||||
var lastMove = 0L
|
||||
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, wait: Long) {
|
||||
// A drop cut short by another move turns back into an outline on the way.
|
||||
if (jobs[5]?.isActive == true || solid.targetValue > 0f) scope.launch { solid.animateTo(0f, Motion.tw(120)) }
|
||||
jobs[5]?.cancel()
|
||||
// Picks up the speed it is going at: restarting from rest on every step of a held
|
||||
// direction is what made it stutter.
|
||||
val speed = edges[i].velocity
|
||||
jobs[i]?.cancel()
|
||||
jobs[i] = scope.launch {
|
||||
if (wait > 0) delay(Motion.ms(wait.toInt()).toLong())
|
||||
edges[i].animateTo(v, spec)
|
||||
edges[i].animateTo(v, spec, initialVelocity = if (wait > 0) 0f else speed)
|
||||
}
|
||||
}
|
||||
/** Pinch into a solid dot where it is, carry it over, and open it into [b] as it arrives. */
|
||||
fun droplet(s: GlideSource, b: Rect) {
|
||||
jobs.forEach { it?.cancel() }
|
||||
val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size
|
||||
val endCorner = cornerOf(s.shape, size, dir, density)
|
||||
jobs[5] = scope.launch {
|
||||
val r = with(density) { 6.dp.toPx() }
|
||||
fun dot(c: Offset) = listOf(c.x - r, c.y - r, c.x + r, c.y + r)
|
||||
val here = Offset((edges[0].value + edges[2].value) / 2f, (edges[1].value + edges[3].value) / 2f)
|
||||
// Pinch into a solid drop where it is.
|
||||
coroutineScope {
|
||||
launch { solid.animateTo(1f, Motion.tw(80)) }
|
||||
launch { corner.animateTo(r, Motion.tw(90)) }
|
||||
dot(here).forEachIndexed { i, v -> launch { edges[i].animateTo(v, Motion.tw(90)) } }
|
||||
}
|
||||
// Across as a dot, every edge together, on a timed ease.
|
||||
val distance = hypot(b.center.x - here.x, b.center.y - here.y) / density.density
|
||||
val travel = (180 + distance * 0.12f).toInt().coerceIn(220, 380)
|
||||
dot(b.center).forEachIndexed { i, v ->
|
||||
jobs[i] = launch { edges[i].animateTo(v, Motion.tw(travel, easing = FastOutSlowInEasing)) }
|
||||
}
|
||||
// Takes the control's shape as it arrives, not after: no resting as a dot.
|
||||
delay(Motion.ms((travel * 0.78f).toInt()).toLong())
|
||||
launch { solid.animateTo(0f, Motion.tw(150)) }
|
||||
launch { corner.animateTo(endCorner, Motion.sp(0.8f, 600f)) }
|
||||
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v ->
|
||||
jobs[i] = launch { edges[i].animateTo(v, Motion.sp(0.7f, 750f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) {
|
||||
@@ -169,7 +234,14 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
jobs[4]?.cancel()
|
||||
jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) }
|
||||
}
|
||||
snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) ->
|
||||
snapshotFlow { Triple(glide.current, glide.moves, glide.hidden || RingVeil.count > 0) }.collectLatest { (src, _, hidden) ->
|
||||
if (hidden) {
|
||||
jobs.forEach { it?.cancel() }
|
||||
alpha.snapTo(0f)
|
||||
solid.snapTo(0f)
|
||||
shown = null
|
||||
return@collectLatest
|
||||
}
|
||||
if (src == null) {
|
||||
// Focus passing between two controls can let go of one a moment before the next
|
||||
// takes it: wait a beat, so that reads as a move and not a fade out and back in.
|
||||
@@ -186,6 +258,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
if (b == null) {
|
||||
if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
|
||||
} else if (first) {
|
||||
var dropped = false
|
||||
val prev = shown
|
||||
val wasShowing = prev != null && alpha.targetValue > 0f
|
||||
shown = src
|
||||
@@ -195,7 +268,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
val out = with(density) { 3.dp.toPx() }
|
||||
val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out)
|
||||
jobs.forEach { it?.cancel() }
|
||||
scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
|
||||
scope.launch { solid.snapTo(0f); edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
|
||||
dirSpecs = List(4) { Motion.sp(0.6f, 700f) }
|
||||
delays = LongArray(4)
|
||||
cornerTo(src, b, snap = true)
|
||||
@@ -207,15 +280,24 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
val horizontal = abs(dx) >= abs(dy)
|
||||
val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1)
|
||||
val trailIdx = (leadIdx + 2) % 4
|
||||
dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
|
||||
delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L }
|
||||
cornerTo(src, b, snap = false)
|
||||
val now = System.nanoTime()
|
||||
val held = (now - lastMove) / 1_000_000 < REPEAT_MS
|
||||
lastMove = now
|
||||
scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
|
||||
dropped = !held && hypot(dx, dy) > with(density) { FAR.toPx() }
|
||||
if (dropped) droplet(src, b)
|
||||
else {
|
||||
// A held direction runs along as one piece; a single press stretches.
|
||||
dirSpecs = if (held) List(4) { Motion.sp(0.9f, 900f) }
|
||||
else List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
|
||||
delays = LongArray(4) { i -> if (i == trailIdx && !held) 30L else 0L }
|
||||
cornerTo(src, b, snap = false)
|
||||
}
|
||||
}
|
||||
movedAt = System.nanoTime()
|
||||
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) }
|
||||
if (!dropped) listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) }
|
||||
last = b
|
||||
} else if (b != last) {
|
||||
} else if (b != last && jobs[5]?.isActive != true) {
|
||||
// It moved under the ring: keep the stretch while the move is fresh, else follow.
|
||||
val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS
|
||||
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) }
|
||||
@@ -242,7 +324,12 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable
|
||||
val sw = (r - l - w).coerceAtLeast(0f)
|
||||
val sh = (b - t - w).coerceAtLeast(0f)
|
||||
val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f)
|
||||
drawRoundRect(
|
||||
val f = solid.value
|
||||
if (f > 0.01f) drawRoundRect(
|
||||
color, topLeft = Offset(l, t), size = Size(r - l, b - t),
|
||||
cornerRadius = CornerRadius(corner.value), alpha = a * f,
|
||||
)
|
||||
if (f < 0.99f) drawRoundRect(
|
||||
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
|
||||
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -26,6 +39,7 @@ import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -39,6 +53,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -68,11 +90,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.store.UserAppsState
|
||||
import java.io.File
|
||||
|
||||
// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators.
|
||||
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun Pane(
|
||||
s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier,
|
||||
@@ -89,18 +115,58 @@ internal fun Pane(
|
||||
else -> null
|
||||
}
|
||||
Backdrop(backdropArt)
|
||||
val pal = LocalPalette.current
|
||||
// Pages keep drawing while they leave, so one can fold away instead of blinking out.
|
||||
val pages = remember { HashMap<String, @Composable () -> Unit>() }
|
||||
// Pages opened from a control (the cog), with where that control sat, in this pane's coordinates.
|
||||
val origins = remember { HashMap<String, Origin>() }
|
||||
val paneAt = remember { arrayOf(Offset.Zero) }
|
||||
val livePage by rememberUpdatedState(s.pageKey)
|
||||
// Picking another game changes the detail beside the list, not the whole page.
|
||||
val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected
|
||||
// The game the Games page shows. It must outlive the selection: while the page animates out,
|
||||
// drawing it with what is selected now (another rail item) turned it into a copy of that page.
|
||||
val gameShown = remember { arrayOf("games") }
|
||||
if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected
|
||||
if (page != null && s.pageKey != null) {
|
||||
pages[s.pageKey] = page
|
||||
if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) }
|
||||
}
|
||||
AnimatedContent(
|
||||
// Picking another game changes the detail beside the list, not the whole page.
|
||||
targetState = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected,
|
||||
targetState = target,
|
||||
modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() },
|
||||
transitionSpec = {
|
||||
(fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
|
||||
.apply { targetContentZIndex = 1f }
|
||||
when {
|
||||
// Into a page from its control: the control floods the pane (PageFlood) as it sinks back.
|
||||
targetState in origins -> (fadeIn(Motion.tw(120)))
|
||||
.togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f))
|
||||
.apply { targetContentZIndex = 1f }
|
||||
// Back out of it: the flood draws back into the control as the pane comes forward again.
|
||||
initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
|
||||
.togetherWith(ExitTransition.None)
|
||||
.apply { targetContentZIndex = -1f }
|
||||
else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
|
||||
.apply { targetContentZIndex = 1f }
|
||||
}
|
||||
},
|
||||
label = "pane",
|
||||
) { key ->
|
||||
if (page != null && key == s.pageKey) page()
|
||||
else Content(s, if (key == "games") selected else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
|
||||
val shown = if (page != null && key == s.pageKey) page else pages[key]
|
||||
if (shown != null) {
|
||||
DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } }
|
||||
val from = origins[key]
|
||||
if (from == null) shown()
|
||||
else {
|
||||
// Keeps a leaving page on screen while its flood draws back into the control.
|
||||
transition.animateFloat(
|
||||
transitionSpec = { if (targetState == EnterExitState.PostExit) tween(PAGE_RETURN_MS) else snap() },
|
||||
label = "pageReturn",
|
||||
) { if (it == EnterExitState.PostExit) 1f else 0f }
|
||||
PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() }
|
||||
}
|
||||
}
|
||||
else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +239,8 @@ private fun Content(
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
|
||||
when {
|
||||
selected == "android-apps" && s.isHomeApp -> {
|
||||
Rise(0) { PageHeader("Android apps") { Chip("${s.androidApps.size} apps", ok = false) } }
|
||||
if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") }
|
||||
Rise(0) { PageHeader(stringResource(R.string.content_android_apps)) { Chip(stringResource(R.string.content_apps_count, s.androidApps.size), ok = false) } }
|
||||
if (s.androidApps.isEmpty()) Rise(3) { Note(stringResource(R.string.content_no_android_apps)) }
|
||||
else Rise(3, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(s.androidApps.map { app ->
|
||||
Tile(
|
||||
@@ -191,30 +257,45 @@ private fun Content(
|
||||
selected == "desktop" -> {
|
||||
val installed = s.emulators.filter { it.installed }
|
||||
val available = s.emulators.filter { !it.installed }
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
|
||||
val apps = UserAppsState.items
|
||||
var adding by rememberSaveable { mutableStateOf(false) }
|
||||
Rise(0) {
|
||||
PageHeader("Desktop") {
|
||||
if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false)
|
||||
PageHeader(stringResource(R.string.drawer_desktop)) {
|
||||
if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false)
|
||||
}
|
||||
}
|
||||
Rise(2) { DesktopCard(s, a) }
|
||||
if (installed.isNotEmpty()) {
|
||||
Rise(3) { SectionTitle("Emulators", "${installed.size} installed") }
|
||||
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
|
||||
// Add closes the last grid: the installable emulators, or the installed ones once none are left.
|
||||
val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) }
|
||||
val addAfterReady = available.isEmpty()
|
||||
if (ready.isNotEmpty() || addAfterReady) {
|
||||
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
|
||||
Rise(4) {
|
||||
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { adding = true })
|
||||
}
|
||||
}
|
||||
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) }
|
||||
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
|
||||
if (!addAfterReady) {
|
||||
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
|
||||
Rise(6) {
|
||||
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { adding = true })
|
||||
}
|
||||
}
|
||||
if (adding) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
|
||||
}
|
||||
selected.startsWith("user:") -> {
|
||||
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
|
||||
if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect)
|
||||
}
|
||||
selected.startsWith("emu:") -> {
|
||||
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
|
||||
if (e == null) Note("Not installed.") else {
|
||||
if (e == null) Note(stringResource(R.string.content_not_installed)) else {
|
||||
val pkgId = Library.packageId(e.id)
|
||||
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
|
||||
Rise(0) {
|
||||
BackLink("Desktop") { onSelect("desktop") }
|
||||
BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") }
|
||||
}
|
||||
Rise(1) {
|
||||
Row(
|
||||
@@ -225,7 +306,7 @@ private fun Content(
|
||||
Column {
|
||||
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(
|
||||
e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed",
|
||||
e.system.replaceFirstChar { it.uppercase() }.let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -234,21 +315,21 @@ private fun Content(
|
||||
if (e.installed) {
|
||||
Rise(3) {
|
||||
Actions {
|
||||
PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) }
|
||||
PrimaryButton(stringResource(R.string.content_open_named, e.name), enabled = !s.busy, main = true) { a.onEmulator(e) }
|
||||
BusyChip(s)
|
||||
SecondaryButton("ROMs folder", onClick = a.onRoms)
|
||||
SecondaryButton(stringResource(R.string.setup_tool_roms), onClick = a.onRoms)
|
||||
if (pkg != null) SecondaryButton(
|
||||
if (pkg.kind == "appimage") "Remove" else "Hide",
|
||||
if (pkg.kind == "appimage") stringResource(R.string.store_remove) else stringResource(R.string.common_hide),
|
||||
enabled = s.packageBusyId == null && !s.sessionRunning,
|
||||
) { a.onRemovePackage(pkg.id) }
|
||||
}
|
||||
}
|
||||
Rise(4) { SectionTitle("Games", e.games.size.toString()) }
|
||||
Rise(4) { SectionTitle(stringResource(R.string.content_games), e.games.size.toString()) }
|
||||
if (e.games.isEmpty()) Rise(5) {
|
||||
Note(
|
||||
if (s.romsDir == null) "Choose a ROMs folder."
|
||||
else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch."
|
||||
else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.",
|
||||
if (s.romsDir == null) stringResource(R.string.content_choose_roms)
|
||||
else if (e.id == "retroarch") stringResource(R.string.content_retroarch_roms)
|
||||
else stringResource(R.string.content_add_roms, e.system, e.system.substringBefore(' ')),
|
||||
)
|
||||
}
|
||||
else Rise(5, Modifier.fillMaxWidth()) {
|
||||
@@ -258,46 +339,46 @@ private fun Content(
|
||||
if (pkg != null) Rise(2) {
|
||||
Actions {
|
||||
PrimaryButton(
|
||||
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}",
|
||||
if (s.packageBusyId == pkg.id) stringResource(R.string.store_installing) else stringResource(R.string.content_install_named, e.name),
|
||||
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
|
||||
) { a.onInstallPackage(pkg.id) }
|
||||
if (s.sessionRunning) ActionChip("Stop session to install", ok = false)
|
||||
else if (!s.ready) ActionChip("Runtime required", ok = false)
|
||||
if (s.sessionRunning) ActionChip(stringResource(R.string.content_stop_to_install), ok = false)
|
||||
else if (!s.ready) ActionChip(stringResource(R.string.content_runtime_required), ok = false)
|
||||
}
|
||||
}
|
||||
Rise(3) {
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
|
||||
Note(when {
|
||||
s.packageCatalogLoading -> "Loading install details…"
|
||||
pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable."
|
||||
!s.ready -> "Install the Linux runtime from Setup before installing desktop apps."
|
||||
s.sessionRunning -> "Stop the active session before installing desktop apps."
|
||||
s.packageCatalogLoading -> stringResource(R.string.content_loading_details)
|
||||
pkg == null -> stringResource(R.string.content_details_unavailable)
|
||||
!s.ready -> stringResource(R.string.content_needs_runtime)
|
||||
s.sessionRunning -> stringResource(R.string.content_stop_first)
|
||||
pkg.notes.isNotBlank() -> pkg.notes
|
||||
else -> "Install ${e.name} into the Linux desktop runtime."
|
||||
else -> stringResource(R.string.content_install_into, e.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (s.packageBusyId == pkg?.id) Rise(4) {
|
||||
val stage = s.packageStage
|
||||
Text(
|
||||
if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…",
|
||||
if (stage != null && s.packagePercent >= 0) stringResource(R.string.store_busy_percent_short, stage, s.packagePercent) else stage ?: stringResource(R.string.store_starting),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
if (pkg?.kind == "tar") Rise(5) { Note("Hiding this package takes it off Desktop; its files stay in the Linux runtime.") }
|
||||
if (pkg?.kind == "tar") Rise(5) { Note(stringResource(R.string.content_hide_note)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.startsWith("rom:") -> {
|
||||
val pair = romFor(s, selected)
|
||||
if (pair == null) Note("That game is gone from the ROMs folder.") else {
|
||||
if (pair == null) Note(stringResource(R.string.content_rom_gone)) else {
|
||||
val (e, g) = pair
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BackLink(e.name) { onSelect("emu:${e.id}") }
|
||||
Eyebrow("Desktop · ${e.system}")
|
||||
Eyebrow(stringResource(R.string.content_desktop_system, e.system))
|
||||
}
|
||||
}
|
||||
Rise(1) { Title(g.name) }
|
||||
@@ -308,7 +389,7 @@ private fun Content(
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Actions {
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
|
||||
PrimaryButton("Launch in ${e.name}", enabled = !s.busy, main = true) { a.onRom(g) }
|
||||
PrimaryButton(stringResource(R.string.content_launch_in, e.name), enabled = !s.busy, main = true) { a.onRom(g) }
|
||||
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
|
||||
}
|
||||
}
|
||||
@@ -317,7 +398,7 @@ private fun Content(
|
||||
}
|
||||
val others = e.games.filter { it !== g }
|
||||
if (others.isNotEmpty()) {
|
||||
Rise(3) { SectionTitle("Also in ${e.name}", null) }
|
||||
Rise(3) { SectionTitle(stringResource(R.string.content_also_in, e.name), null) }
|
||||
Rise(4, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(others.map { x ->
|
||||
val index = e.games.indexOf(x)
|
||||
@@ -327,7 +408,7 @@ private fun Content(
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Note("That page is gone. Pick a section on the left.")
|
||||
else -> Note(stringResource(R.string.content_page_gone))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +422,7 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
val actions: @Composable () -> Unit = {
|
||||
Actions {
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop)
|
||||
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
|
||||
Cog(onClick = a.onDesktopSettings)
|
||||
BusyChip(s)
|
||||
}
|
||||
@@ -356,8 +437,8 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
|
||||
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!narrow) actions()
|
||||
}
|
||||
@@ -365,16 +446,29 @@ private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Emulators as wide tiles - three across, or a list on a narrow page. */
|
||||
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
|
||||
private sealed class GridItem(val key: String) {
|
||||
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
|
||||
class User(val app: UserApps.App) : GridItem("user:${app.key}")
|
||||
data object Add : GridItem("add")
|
||||
}
|
||||
|
||||
/** Wide tiles - three across, or a list on a narrow page. */
|
||||
@Composable
|
||||
private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSelect: (String) -> Unit) {
|
||||
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> 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)) {
|
||||
emulators.chunked(columns).forEachIndexed { r, row ->
|
||||
items.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, e ->
|
||||
key(e.id) {
|
||||
EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") }
|
||||
row.forEachIndexed { i, item ->
|
||||
key(item.key) {
|
||||
val m = Modifier.weight(1f).fillMaxHeight()
|
||||
val isFirst = first && r == 0 && i == 0
|
||||
when (item) {
|
||||
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
|
||||
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
|
||||
GridItem.Add -> AddTile(m, isFirst, onAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
@@ -383,6 +477,173 @@ private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSe
|
||||
}
|
||||
}
|
||||
|
||||
/** A grid tile's frame: the emulator tiles' look, focus glide and press. */
|
||||
@Composable
|
||||
private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> 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 = "tileScale")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("tile:$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 if (filled) colors.surface else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileText(title: String, detail: String, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun UserApps.Kind.label() = when (this) {
|
||||
UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script
|
||||
UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage
|
||||
UserApps.Kind.FLATPAK -> R.string.user_apps_kind_flatpak
|
||||
}
|
||||
|
||||
/** An added app's icon: its own, or the kind's when it has none. */
|
||||
@Composable
|
||||
private fun UserAppIcon(app: UserApps.App, size: Int) {
|
||||
if (app.icon != null) AppIcon(app.icon, app.name, size)
|
||||
else Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(size.dp).clip(RoundedCornerShape((size / 5).dp)).background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Icon(
|
||||
if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size((size / 2).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) =
|
||||
TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) {
|
||||
UserAppIcon(app, 44)
|
||||
TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f))
|
||||
}
|
||||
|
||||
/** The grid's last tile: opens the Add dialog. */
|
||||
@Composable
|
||||
private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12),
|
||||
) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) }
|
||||
TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/** The add or remove under way, or why the last one failed. */
|
||||
@Composable
|
||||
private fun UserAppsProgress() {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val working = UserAppsState.working
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
if (working != null) {
|
||||
val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting)
|
||||
val percent = UserAppsState.percent
|
||||
Text(
|
||||
if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
} else UserAppsState.lastError?.let { Note(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */
|
||||
@Composable
|
||||
private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ctx = LocalContext.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
var confirm by remember(app.key) { mutableStateOf(false) }
|
||||
var editing by rememberSaveable(app.key) { mutableStateOf(false) }
|
||||
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
|
||||
Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } }
|
||||
Rise(1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
UserAppIcon(app, 52)
|
||||
Column {
|
||||
Text(app.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(
|
||||
app.repo?.let { stringResource(R.string.user_apps_github_version, it, app.version.orEmpty()) } ?: stringResource(app.kind.label()),
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Rise(3) {
|
||||
Actions {
|
||||
PrimaryButton(stringResource(R.string.user_apps_open, app.name), enabled = !s.busy, main = true) { a.onUserApp(app) }
|
||||
BusyChip(s)
|
||||
SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true }
|
||||
if (app.repo != null) UpdateButton(app, s)
|
||||
SecondaryButton(
|
||||
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
|
||||
enabled = !s.sessionRunning && UserAppsState.working == null,
|
||||
) {
|
||||
if (!confirm) confirm = true
|
||||
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
|
||||
}
|
||||
if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false)
|
||||
}
|
||||
}
|
||||
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(4) { UserAppsProgress() }
|
||||
if (editing) EditAppDialog(app, onDismiss = { editing = false }) { name, icon -> UserAppsState.edit(ctx, app, name, icon) }
|
||||
val detail = app.detail
|
||||
if (detail != null) Rise(4) {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Note(
|
||||
if (app.kind != UserApps.Kind.SCRIPT) detail
|
||||
else stringResource(if (app.copied) R.string.user_apps_script_copied else R.string.user_apps_script_linked, detail),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up a newer release of an app added from GitHub, then installs it. */
|
||||
@Composable
|
||||
private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
|
||||
val ctx = LocalContext.current
|
||||
val idle = UserAppsState.working == null && UserAppsState.checking == null
|
||||
when (val found = UserAppsState.updates[app.key]) {
|
||||
is UserApps.UpdateCheck.Available -> SecondaryButton(
|
||||
stringResource(R.string.user_apps_update_to, found.release.tag), enabled = idle && !s.sessionRunning,
|
||||
) { UserAppsState.update(ctx, app, found.release) }
|
||||
else -> {
|
||||
SecondaryButton(
|
||||
stringResource(if (UserAppsState.checking == app.key) R.string.user_apps_checking_updates else R.string.user_apps_check_updates),
|
||||
enabled = idle,
|
||||
) { UserAppsState.checkUpdate(ctx, app) }
|
||||
when (found) {
|
||||
is UserApps.UpdateCheck.Current -> ActionChip(stringResource(R.string.user_apps_up_to_date, found.tag), ok = true)
|
||||
is UserApps.UpdateCheck.Failed -> ActionChip(found.message, ok = false)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One emulator: its icon, system and games. One not yet installed says so in words and keeps full
|
||||
* contrast - dimming it read as disabled rather than one press from installing.
|
||||
@@ -391,29 +652,16 @@ private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSe
|
||||
private fun EmulatorTile(e: Library.Emulator, 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 = "emuScale")
|
||||
val system = e.system.replaceFirstChar { it.uppercase() }
|
||||
val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("tile:emu:${e.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 if (e.installed) colors.surface else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
|
||||
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
|
||||
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!e.installed) Text(
|
||||
"Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -66,10 +68,10 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
|
||||
if (current == null) {
|
||||
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
Rise(0) { PageHeader("Games") }
|
||||
Rise(1) { Note("Games you install in Steam show up here. Open Steam, install one, and it appears after the session.") }
|
||||
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
|
||||
Rise(1) { Note(stringResource(R.string.games_empty)) }
|
||||
Rise(2) {
|
||||
Actions { PrimaryButton("Play Steam", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
|
||||
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -86,7 +88,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
|
||||
}
|
||||
}
|
||||
Rise(1) { SectionTitle("Launch settings", null) }
|
||||
Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
|
||||
Rise(2) { LaunchSettings(s, a, host) }
|
||||
}
|
||||
return
|
||||
@@ -104,7 +106,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
|
||||
GameActions(current, s, a)
|
||||
}
|
||||
SectionTitle("Launch settings", null)
|
||||
SectionTitle(stringResource(R.string.games_launch_settings), null)
|
||||
LaunchSettings(s, a, host)
|
||||
}
|
||||
}
|
||||
@@ -114,12 +116,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
@Composable
|
||||
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
|
||||
Actions {
|
||||
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
|
||||
PrimaryButton(stringResource(R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
|
||||
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
|
||||
SecondaryButton("Game files", compact = true) { a.onBrowseFiles(dir) }
|
||||
SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) }
|
||||
}
|
||||
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
|
||||
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
|
||||
SecondaryButton(stringResource(R.string.games_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)
|
||||
}
|
||||
@@ -141,28 +143,32 @@ private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndA
|
||||
}
|
||||
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."
|
||||
found == null -> stringResource(R.string.games_saves_looking)
|
||||
found.isEmpty() -> stringResource(R.string.games_saves_none)
|
||||
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))
|
||||
stringResource(
|
||||
R.string.games_saves_summary,
|
||||
if (found.size == 1) found[0].relPath else stringResource(R.string.games_saves_folders, found.size),
|
||||
found.sumOf { it.files },
|
||||
if (mb < 1) stringResource(R.string.games_kb, (mb * 1024).toInt()) else stringResource(R.string.common_size_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) {
|
||||
SecondaryButton(stringResource(R.string.games_manage_saves), compact = true) { open = !open }
|
||||
AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.games_saves), note = summary) { first ->
|
||||
MenuItem(stringResource(R.string.games_import_saves), checked = false, detail = stringResource(R.string.games_import_saves_hint), focusRequester = first) {
|
||||
open = false; a.onSaveImport(g)
|
||||
}
|
||||
MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") {
|
||||
MenuItem(stringResource(R.string.games_export_gamehub), checked = false, detail = stringResource(R.string.games_export_gamehub_hint)) {
|
||||
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") {
|
||||
MenuItem(stringResource(R.string.games_export_winlator), checked = false, detail = stringResource(R.string.games_export_winlator_hint)) {
|
||||
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) {
|
||||
MenuItem(stringResource(R.string.games_open_save_folder), checked = false, detail = d.relPath) {
|
||||
open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath))
|
||||
}
|
||||
}
|
||||
@@ -197,7 +203,7 @@ private fun GameList(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
|
||||
Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
|
||||
}
|
||||
for (g in games) key(g.appId) {
|
||||
@@ -220,7 +226,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
|
||||
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
|
||||
.clip(Shape12)
|
||||
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
|
||||
.glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp)
|
||||
.glideBorder(focused, Shape12, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
|
||||
.controllerConfirm(onClick = onLaunch)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
@@ -231,7 +237,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played",
|
||||
playedSpan(g.lastPlayed)?.replaceFirstChar { it.uppercase() } ?: stringResource(R.string.games_never_played),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -256,30 +262,35 @@ internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit
|
||||
/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */
|
||||
@Composable
|
||||
internal fun BusyChip(s: FrontEndState) {
|
||||
if (s.busy) ActionChip(if (s.percent >= 0) "Runtime busy · ${s.percent}%" else "Runtime busy", ok = false)
|
||||
if (s.busy) ActionChip(if (s.percent >= 0) stringResource(R.string.games_runtime_busy_percent, s.percent) else stringResource(R.string.games_runtime_busy), ok = false)
|
||||
}
|
||||
|
||||
/** Whether Steam can start, said where Play is rather than only in Setup. */
|
||||
@Composable
|
||||
internal fun RuntimeChip(s: FrontEndState) = when {
|
||||
s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false)
|
||||
!s.ready -> Chip("Runtime installs on first Play", ok = false)
|
||||
s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false)
|
||||
else -> Chip("● Runtime ready", ok = true)
|
||||
s.busy -> Chip(if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage, ok = false)
|
||||
!s.ready -> Chip(stringResource(R.string.games_runtime_first_play), ok = false)
|
||||
s.available != null && s.available != s.installed -> Chip(stringResource(R.string.games_runtime_update), ok = false)
|
||||
else -> Chip(stringResource(R.string.games_runtime_ready), ok = true)
|
||||
}
|
||||
|
||||
/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */
|
||||
private fun lastPlayedText(lastPlayed: Long): String? {
|
||||
@Composable
|
||||
private fun lastPlayedText(lastPlayed: Long): String? =
|
||||
playedSpan(lastPlayed)?.let { stringResource(R.string.games_last_played, it.replaceFirstChar { c -> c.lowercase() }) }
|
||||
|
||||
/** "3 days ago" from Steam's unix seconds; null for a game never played. */
|
||||
private fun playedSpan(lastPlayed: Long): String? {
|
||||
if (lastPlayed <= 0L) return null
|
||||
val span = android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
return android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return "Last played " + span.replaceFirstChar { it.lowercase() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun libraryLabel(library: String): String = when (library) {
|
||||
"internal" -> "Internal storage"
|
||||
Library.ADDED -> "Added game"
|
||||
"internal" -> stringResource(R.string.games_internal)
|
||||
Library.ADDED -> stringResource(R.string.games_added)
|
||||
else -> library
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -36,7 +38,6 @@ 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.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
@@ -55,8 +56,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -77,42 +77,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
|
||||
// The front end's side rail: the page list, the resume entry and the controller hints under it.
|
||||
|
||||
/** What the pad's face buttons do here, along the bottom edge as on a console. */
|
||||
@Composable
|
||||
internal fun ControllerHints(select: String = "Select", tabs: Boolean = false) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End),
|
||||
modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp),
|
||||
) {
|
||||
if (tabs) HintGlyph("LB RB", "Tabs")
|
||||
HintGlyph("A", select)
|
||||
HintGlyph("B", "Back")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HintGlyph(button: String, action: String) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Face buttons are round; bumpers are a wider keycap.
|
||||
val shape = if (button.length == 1) CircleShape else RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(20.dp).widthIn(min = 20.dp).clip(shape).background(colors.onBackground).padding(horizontal = if (button.length == 1) 0.dp else 6.dp),
|
||||
) {
|
||||
Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
|
||||
}
|
||||
Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
// The front end's side rail: the page list and the resume entry under it.
|
||||
|
||||
internal val AttentionAmber = Color(0xFFFFB547)
|
||||
|
||||
@@ -144,24 +109,24 @@ internal fun SideRail(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
// 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, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
|
||||
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
|
||||
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, 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, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
RailItem("Updates", Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
|
||||
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
|
||||
AnimatedVisibility(s.busy, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) {
|
||||
val barWidth = if (iconOnly) 44.dp else 60.dp
|
||||
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth))
|
||||
else LinearProgressIndicator(modifier = Modifier.width(barWidth))
|
||||
Text(if (s.percent >= 0) "${s.percent}%" else if (iconOnly) "…" else "Working", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
|
||||
Text(if (s.percent >= 0) stringResource(R.string.rail_percent, s.percent) else if (iconOnly) "…" else stringResource(R.string.setup_check_busy), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
var lastRunning by remember { mutableStateOf("") }
|
||||
@@ -235,8 +200,9 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val focused by src.collectIsFocusedAsState()
|
||||
val hovered by src.collectIsHoveredAsState()
|
||||
val resumeDescription = stringResource(R.string.rail_resume_named, name)
|
||||
val pulse = rememberInfiniteTransition(label = "pulse")
|
||||
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(Motion.ms(1600).coerceAtLeast(1), easing = Motion.Ease), RepeatMode.Restart), label = "ring")
|
||||
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring")
|
||||
// Animations off: the live dot alone, no pulsing ring.
|
||||
val still = Motion.scale == 0f
|
||||
Column(
|
||||
@@ -250,7 +216,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
|
||||
.glideBorder(focused, Shape14, pal.signal)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
|
||||
.semantics { contentDescription = "Resume $name" }
|
||||
.semantics { contentDescription = resumeDescription }
|
||||
.padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) {
|
||||
@@ -259,7 +225,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
|
||||
}
|
||||
// Icons only: the live dot alone says something is running; its description says what.
|
||||
if (!iconOnly) {
|
||||
Text("Resume", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false)
|
||||
Text(stringResource(R.string.resume_session), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false)
|
||||
Text(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import java.io.File
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -16,16 +18,15 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -114,9 +116,8 @@ 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. */
|
||||
/** The Flathub Store, a beta the user turns on in Setup. */
|
||||
val storeEnabled: Boolean = false,
|
||||
val appImagesEnabled: Boolean = false,
|
||||
/** The Updates page: DroidDeck's own builds and the channel followed. */
|
||||
val updates: UpdatesState = UpdatesState(),
|
||||
)
|
||||
@@ -129,9 +130,8 @@ class FrontEndActions(
|
||||
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 = { _, _ -> },
|
||||
/** An app added on the Desktop page. */
|
||||
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
|
||||
val onRom: (Library.Rom) -> Unit,
|
||||
val onResume: () -> Unit,
|
||||
val onSteamSettings: () -> Unit,
|
||||
@@ -159,7 +159,6 @@ class FrontEndActions(
|
||||
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 = { _, _ -> },
|
||||
@@ -185,8 +184,10 @@ internal object Motion {
|
||||
scale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
|
||||
}
|
||||
val Ease = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
|
||||
/** A wall-clock wait (a coroutine delay) scaled as the animations around it are. Compose already
|
||||
* scales its own animation clock by the same setting, so animation specs take unscaled times. */
|
||||
fun ms(base: Int) = (base * scale).roundToInt()
|
||||
fun <T> tw(base: Int, delay: Int = 0): FiniteAnimationSpec<T> = if (scale == 0f) snap() else tween(ms(base), ms(delay), Ease)
|
||||
fun <T> tw(base: Int, delay: Int = 0, easing: Easing = Ease): FiniteAnimationSpec<T> = if (scale == 0f) snap() else tween(base, delay, easing)
|
||||
fun <T> sp(damping: Float = 0.7f, stiffness: Float = Spring.StiffnessMediumLow): FiniteAnimationSpec<T> =
|
||||
if (scale == 0f) snap() else spring(damping, stiffness)
|
||||
}
|
||||
@@ -357,13 +358,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
|
||||
// Back (and B) from a ROM or an emulator steps out one level, as its "‹" link does, instead
|
||||
// of leaving the app: a ROM -> its emulator, an emulator -> Desktop.
|
||||
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
|
||||
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
|
||||
BackHandler(
|
||||
enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) &&
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:")),
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")),
|
||||
) {
|
||||
selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':')
|
||||
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
|
||||
}
|
||||
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
|
||||
// The Store turned off in Setup takes its page with it.
|
||||
@@ -379,7 +380,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
s.pageKey?.startsWith("settings:steam") == true -> "steam"
|
||||
s.pageKey?.startsWith("settings:") == true -> "desktop"
|
||||
selected.startsWith("app:") -> "games"
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop"
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
|
||||
else -> s.pageKey ?: selected
|
||||
}
|
||||
// Bumped each time a rail item is picked, so a controller moves on into the new page.
|
||||
@@ -418,6 +419,17 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
// A tile or button that opens a page goes away with the page it was on, and focus with it;
|
||||
// the pad then had nothing to move from (a press landed back on the rail's first item). So
|
||||
// once the new page is in, a controller lands on its main button.
|
||||
// Backing out of a page: it stays on screen while it leaves (and holds focus there), so hand
|
||||
// focus back now to the control it was opened from. Not when the page closed because
|
||||
// another rail item was picked: focus is on the rail then, and stays there.
|
||||
var openPage by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(s.pageKey) {
|
||||
val closed = openPage != null && s.pageKey == null
|
||||
openPage = s.pageKey
|
||||
if (!closed || frontFocus.focusedRail != null || processSettingsPageVisible || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
|
||||
withFrameNanos { }
|
||||
runCatching { frontFocus.paneEntry().requestFocus() }
|
||||
}
|
||||
LaunchedEffect(selected, s.pageKey, processSettingsPageVisible) {
|
||||
if (processSettingsPageVisible) return@LaunchedEffect
|
||||
// Past the old page's exit (170ms), then the first frame the new page takes focus.
|
||||
@@ -453,17 +465,6 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
onOpenDeveloperOptions = requestDeveloperOptions,
|
||||
onRequestWirelessAdb = requestWirelessAdbFix,
|
||||
)
|
||||
// Only while a pad or keyboard drives the launcher; a touch hides it again.
|
||||
AnimatedVisibility(
|
||||
inputModeManager.inputMode == InputMode.Keyboard,
|
||||
enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
val onGame = frontFocus.focusedRail == null && frontFocus.last?.startsWith("game:") == true
|
||||
ControllerHints(
|
||||
select = if (onGame) "Launch" else "Select",
|
||||
tabs = railSelection == "setup" && s.pageKey == null && frontFocus.focusedRail == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +515,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
if (showDeveloperDisplayChoice) {
|
||||
DeveloperDisplayChoiceDialog(
|
||||
displays = s.secondScreenDisplays.map { display ->
|
||||
display.id to if (s.secondScreenDisplays.size == 1) "Bottom screen" else display.label
|
||||
display.id to if (s.secondScreenDisplays.size == 1) stringResource(R.string.screen_bottom) else display.label
|
||||
},
|
||||
onMainScreen = {
|
||||
showDeveloperDisplayChoice = false
|
||||
@@ -530,7 +531,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
val hasBackTarget = (s.pageKey != null && page != null) ||
|
||||
((s.pageKey == null || page == null) &&
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:")))
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")))
|
||||
// At the top of a section, Back goes to the rail - the launcher itself is never backed out of.
|
||||
BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) {
|
||||
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import android.os.Build
|
||||
@@ -106,10 +107,9 @@ private fun CheckRow(state: CheckState, title: String, detail: String?, divider:
|
||||
CheckState.BUSY -> Icons.Filled.Refresh
|
||||
},
|
||||
contentDescription = when (state) {
|
||||
CheckState.OK -> "Done"
|
||||
CheckState.WARN -> "Needs attention"
|
||||
CheckState.BUSY -> "Working"
|
||||
},
|
||||
CheckState.OK -> stringResource(R.string.setup_check_done)
|
||||
CheckState.WARN -> stringResource(R.string.setup_check_warn)
|
||||
CheckState.BUSY -> stringResource(R.string.setup_check_busy) },
|
||||
tint = tint, modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -130,6 +130,7 @@ internal fun SetupPanel(
|
||||
onRequestWirelessAdb: (Boolean) -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
val ctx = LocalContext.current
|
||||
var processLimitBusy by remember { mutableStateOf(false) }
|
||||
var processLimitMessage by remember { mutableStateOf<String?>(null) }
|
||||
val setProcessLimit: (Boolean) -> Unit = { enabled ->
|
||||
@@ -140,16 +141,16 @@ internal fun SetupPanel(
|
||||
if (error == null) {
|
||||
a.onRefreshPhantomStatus()
|
||||
} else {
|
||||
processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device."
|
||||
processLimitMessage = ctx.getString(R.string.setup_limit_adb_failed)
|
||||
onRequestWirelessAdb(enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
val runtime = when {
|
||||
s.busy -> "Working…"
|
||||
!s.ready -> "Install"
|
||||
s.available != null && s.available != s.installed -> "Update"
|
||||
else -> "Manage"
|
||||
s.busy -> stringResource(R.string.common_working)
|
||||
!s.ready -> stringResource(R.string.setup_install)
|
||||
s.available != null && s.available != s.installed -> stringResource(R.string.common_update)
|
||||
else -> stringResource(R.string.setup_manage)
|
||||
}
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -164,7 +165,7 @@ internal fun SetupPanel(
|
||||
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf("Overview", "Controller", "Session", "Launcher")
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
@@ -184,56 +185,56 @@ internal fun SetupPanel(
|
||||
onNext = { pick((tab + 1) % tabs.size) },
|
||||
),
|
||||
) {
|
||||
PageHeader("Setup") {
|
||||
Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", ok = readyCount == checks)
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
|
||||
}
|
||||
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
SectionTitle("System check", null)
|
||||
SectionTitle(stringResource(R.string.setup_system_check), null)
|
||||
// What Steam needs, one row each: green when done, one button when not. The
|
||||
// process-limit controls only open under their row.
|
||||
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
CheckRow(
|
||||
if (gpuOk) CheckState.OK else CheckState.WARN,
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU"
|
||||
else -> "GPU not supported"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_supported)
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested)
|
||||
else -> stringResource(R.string.setup_gpu_unsupported)
|
||||
},
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}"
|
||||
else -> "Steam draws with an Adreno driver; $gpuName may show a black screen"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> stringResource(R.string.setup_gpu_detail, gpu.name, gpuName)
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.name, gpu.supportText.replaceFirstChar { it.lowercase() })
|
||||
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
|
||||
},
|
||||
)
|
||||
CheckRow(
|
||||
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
|
||||
"Linux runtime",
|
||||
stringResource(R.string.setup_runtime),
|
||||
when {
|
||||
s.busy -> if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage
|
||||
!s.ready -> "Not installed · about 3 GB, installed on the first Play"
|
||||
s.available != null && s.available != s.installed -> "${s.installed ?: "Installed"} · update available"
|
||||
else -> "${s.installed ?: "Installed"} · up to date"
|
||||
s.busy -> if (s.percent >= 0) stringResource(R.string.setup_runtime_progress, s.stage, s.percent) else s.stage
|
||||
!s.ready -> stringResource(R.string.setup_runtime_missing)
|
||||
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
|
||||
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
|
||||
},
|
||||
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
|
||||
CheckRow(
|
||||
if (limitBlocks) CheckState.WARN else CheckState.OK,
|
||||
"Child-process limit",
|
||||
stringResource(R.string.setup_limit),
|
||||
when (s.phantomProcessStatus) {
|
||||
PhantomProcessStatus.ENABLED -> "On · Android may close Steam. Turning it off takes a minute"
|
||||
PhantomProcessStatus.UNSET -> "Not set · the ROM default may close Steam. Turning it off takes a minute"
|
||||
PhantomProcessStatus.UNREADABLE -> "Could not be checked · turning it off takes a minute"
|
||||
PhantomProcessStatus.ENABLED -> stringResource(R.string.setup_limit_on)
|
||||
PhantomProcessStatus.UNSET -> stringResource(R.string.setup_limit_unset)
|
||||
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
|
||||
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
|
||||
},
|
||||
) {
|
||||
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
|
||||
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
|
||||
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
|
||||
SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails }
|
||||
SecondaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_details), compact = true) { showLimitDetails = !showLimitDetails }
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
|
||||
AnimatedVisibility(showLimitDetails, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
|
||||
// One sentence and at most three buttons. The computer route and its
|
||||
// raw command live on the full page Wireless debugging opens.
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
|
||||
@@ -244,11 +245,11 @@ internal fun SetupPanel(
|
||||
)
|
||||
Actions {
|
||||
if (limitBlocks) {
|
||||
PrimaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions)
|
||||
SecondaryButton("Use Wireless debugging", compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
|
||||
SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus)
|
||||
PrimaryButton(stringResource(R.string.setup_dev_options), compact = true, onClick = onOpenDeveloperOptions)
|
||||
SecondaryButton(stringResource(R.string.setup_use_wireless), compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
|
||||
SecondaryButton(stringResource(R.string.setup_check_again), compact = true, onClick = a.onRefreshPhantomStatus)
|
||||
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
|
||||
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
|
||||
SecondaryButton(stringResource(R.string.setup_limit_turn_on), compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
|
||||
}
|
||||
}
|
||||
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
|
||||
@@ -257,25 +258,25 @@ internal fun SetupPanel(
|
||||
}
|
||||
CheckRow(
|
||||
if (signedIn) CheckState.OK else CheckState.WARN,
|
||||
"Steam account",
|
||||
s.offlineAccount?.let { if (s.offline) "Signed in as $it · offline mode" else "Signed in as $it" } ?: "Press Play and sign in to Steam",
|
||||
stringResource(R.string.setup_account),
|
||||
s.offlineAccount?.let { if (s.offline) stringResource(R.string.setup_signed_in_offline, it) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in),
|
||||
divider = false,
|
||||
)
|
||||
}
|
||||
SectionTitle("Tools", null)
|
||||
SectionTitle(stringResource(R.string.setup_tools), null)
|
||||
ToolGrid(s, a)
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
if (controller != null && a.controller != null) SettingsGroup("Controller") {
|
||||
if (controller != null && a.controller != null) SettingsGroup(stringResource(R.string.setup_controller)) {
|
||||
ControllerRows(host, s.oscMode, controller, a.controller)
|
||||
}
|
||||
if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.")
|
||||
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
|
||||
}
|
||||
2 -> {
|
||||
SettingsGroup("Session") {
|
||||
SettingsGroup(stringResource(R.string.setup_session)) {
|
||||
ChoiceRow(
|
||||
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
@@ -287,22 +288,22 @@ internal fun SetupPanel(
|
||||
FrameGenMenu(s, a, host)
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
|
||||
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
|
||||
ActionRow("Saved session logs", "The newest ${com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS} are kept", "Clear logs", a.onClearLogs)
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
|
||||
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
|
||||
ToggleRow(
|
||||
host, "offline", "Offline mode",
|
||||
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
|
||||
host, "offline", stringResource(R.string.setup_offline),
|
||||
s.offlineAccount?.let { stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
|
||||
s.offline, enabled = s.offlineAccount != null,
|
||||
) { a.onOffline() }
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup("Launcher") {
|
||||
SettingsRow("Theme", "Choose the launcher appearance") {
|
||||
SettingsGroup(stringResource(R.string.setup_launcher)) {
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = "Theme") { firstItemFocus ->
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
@@ -313,32 +314,26 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
ToggleRow(
|
||||
host, "home-screen", "Use as a Home screen",
|
||||
if (s.homeScreenEnabled) "DroidDeck can be the phone's Home app" else "Off: DroidDeck is never offered as a Home app",
|
||||
host, "home-screen", stringResource(R.string.setup_home),
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", "Fullscreen",
|
||||
if (s.launcherFullscreen) "Hide the Android status and navigation bars" else "Show the Android status and navigation bars",
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
s.launcherFullscreen,
|
||||
) { a.onLauncherFullscreen(it) }
|
||||
if (s.homeScreenEnabled) {
|
||||
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
|
||||
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
|
||||
}
|
||||
}
|
||||
SettingsGroup("Linux apps (beta)") {
|
||||
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
|
||||
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",
|
||||
host, "store-enabled", stringResource(R.string.setup_store),
|
||||
if (s.storeEnabled) stringResource(R.string.setup_store_on)
|
||||
else stringResource(R.string.setup_store_off),
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,10 +347,10 @@ internal fun SetupPanel(
|
||||
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
|
||||
val columns = if (LocalNarrowPane.current) 2 else 4
|
||||
val tools = listOf(
|
||||
ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles),
|
||||
ToolSpec(Icons.Outlined.Extension, "Proton versions", "Install ARM64 Proton builds", a.onProtons),
|
||||
ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance),
|
||||
ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms),
|
||||
ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles),
|
||||
ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons),
|
||||
ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance),
|
||||
ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
for (row in tools.chunked(columns)) {
|
||||
@@ -409,7 +404,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
val columns = if (LocalNarrowPane.current) 2 else 3
|
||||
val controller = a.controller
|
||||
val cards = buildList<@Composable (Modifier) -> Unit> {
|
||||
add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } }
|
||||
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
|
||||
add { m ->
|
||||
Box(m) {
|
||||
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
@@ -418,7 +413,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
FrameGenMenu(s, a, host)
|
||||
}
|
||||
}
|
||||
if (controller != null) add { m -> SettingCard("Controls", "Button mapping", "card:controls", m, controller.onMapping) }
|
||||
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -41,6 +43,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -178,6 +181,8 @@ internal fun PrimaryButton(
|
||||
.clip(Shape12)
|
||||
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
|
||||
.shine(hot, 0.45f)
|
||||
// Off, its fill can match the surface it sits on: the outline keeps it a button.
|
||||
.then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12))
|
||||
// The grow and shine alone barely show on the light fill: outline it when a
|
||||
// controller is on it, as the other controls are.
|
||||
.glideBorder(hot, Shape12, pal.signal)
|
||||
@@ -225,11 +230,19 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit)
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog")
|
||||
// A press spins it on a half turn as the page it opens blooms out of it (PageOrigin).
|
||||
var spins by remember { mutableIntStateOf(0) }
|
||||
val rot by animateFloatAsState((if (hot) 90f else 0f) + spins * 180f, Motion.sp(0.55f), label = "cog")
|
||||
val pal = LocalPalette.current
|
||||
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
|
||||
val cornerPx = with(androidx.compose.ui.platform.LocalDensity.current) { (if (size > 48.dp) 14.dp else 12.dp).toPx() }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
|
||||
) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
|
||||
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).onGloballyPositioned { placed[0] = it }.clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current) {
|
||||
placed[0]?.takeIf { it.isAttached }?.let { PageOrigin.mark(Origin(it.boundsInRoot(), cornerPx)) }
|
||||
spins++
|
||||
onClick()
|
||||
},
|
||||
) { Icon(Icons.Filled.Settings, stringResource(R.string.drawer_page_settings), tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
|
||||
}
|
||||
@@ -1,19 +1,46 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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 com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.GameEnvironmentOptions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -21,31 +48,56 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Environment variables for Proton games: one shared profile and one per game, in the app's own
|
||||
// dialog, with each variable's actions in a menu off its value as everywhere else.
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
|
||||
// would otherwise give the pad nothing to move from.
|
||||
var byPad by remember { mutableStateOf(false) }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
|
||||
TextButton(onClick = { open = true }, modifier = modifier.controllerConfirm { open = true }) {
|
||||
Text(stringResource(R.string.game_env_edit))
|
||||
SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) {
|
||||
byPad = inputMode.inputMode == InputMode.Keyboard
|
||||
open = true
|
||||
}
|
||||
}
|
||||
if (open) GameEnvironmentEditor { open = false }
|
||||
if (open) GameEnvironmentEditor(byPad) { open = false }
|
||||
}
|
||||
|
||||
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun PadFocus(byPad: Boolean, target: FocusRequester) {
|
||||
val inputMode = LocalInputModeManager.current
|
||||
LaunchedEffect(Unit) {
|
||||
if (!byPad) return@LaunchedEffect
|
||||
inputMode.requestInputMode(InputMode.Keyboard)
|
||||
repeat(2) { withFrameNanos { } }
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameEnvironmentEditor(onClose: () -> Unit) {
|
||||
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
|
||||
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var appId by remember { mutableStateOf("") }
|
||||
var scopeMenu by remember { mutableStateOf(false) }
|
||||
var addMenu by remember { mutableStateOf(false) }
|
||||
var otherId by remember { mutableStateOf<String?>(null) }
|
||||
var menu by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var editByPad by remember { mutableStateOf(false) }
|
||||
val dialogInput = LocalInputModeManager.current
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -62,69 +114,90 @@ private fun GameEnvironmentEditor(onClose: () -> Unit) {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onClose() },
|
||||
title = { Text(stringResource(R.string.game_env_title)) },
|
||||
confirmButton = { TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.game_env_close)) } },
|
||||
text = {
|
||||
Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.game_env_hint))
|
||||
Text(stringResource(R.string.game_env_defaults), style = MaterialTheme.typography.bodySmall)
|
||||
if (error) Text(stringResource(R.string.game_env_error), color = MaterialTheme.colorScheme.error)
|
||||
if (config == null && !error) Text(stringResource(R.string.game_env_loading))
|
||||
val current = config
|
||||
if (current != null) {
|
||||
val profileName = if (scope.isEmpty()) stringResource(R.string.game_env_shared)
|
||||
else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope)
|
||||
Text(stringResource(R.string.game_env_scope), style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
TextButton(onClick = { scopeMenu = true }, enabled = !busy) { Text(profileName) }
|
||||
DropdownMenu(expanded = scopeMenu, onDismissRequest = { scopeMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_shared)) }, onClick = { scope = ""; scopeMenu = false })
|
||||
val profiles = (games + current.games.keys.filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first }
|
||||
for ((id, name) in profiles) DropdownMenuItem(text = { Text(name) }, onClick = { scope = id; scopeMenu = false })
|
||||
}
|
||||
val current = config
|
||||
val shared = stringResource(R.string.game_env_shared)
|
||||
val profileName = if (scope.isEmpty()) shared
|
||||
else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope)
|
||||
|
||||
AppDialog(shown, close, "gameEnv", wide = false) {
|
||||
PadFocus(byPad, firstFocus)
|
||||
DialogHeader(stringResource(R.string.game_env_eyebrow), stringResource(R.string.game_env_title))
|
||||
Small(stringResource(R.string.game_env_applies))
|
||||
// Which profile: everything shared, or one game's.
|
||||
Panel {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.game_env_scope), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
ValueChip(profileName, menu == "scope", modifier = Modifier.focusRequester(firstFocus).widthIn(max = 260.dp)) {
|
||||
menu = if (menu == "scope") null else "scope"
|
||||
}
|
||||
OutlinedTextField(value = appId, onValueChange = { appId = it }, singleLine = true, enabled = !busy,
|
||||
label = { Text(stringResource(R.string.game_env_app_id)) }, modifier = Modifier.fillMaxWidth())
|
||||
TextButton(onClick = { scope = appId.toLong().toString(); appId = "" }, enabled = !busy && appId.isNotEmpty() && GameEnvironment.validScope(appId)) {
|
||||
Text(stringResource(R.string.game_env_open))
|
||||
}
|
||||
Text(stringResource(R.string.game_env_overrides), style = MaterialTheme.typography.bodySmall)
|
||||
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
|
||||
if (entries.isEmpty()) Text(stringResource(R.string.game_env_empty))
|
||||
for ((name, value) in entries) {
|
||||
HorizontalDivider()
|
||||
Text(name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(value ?: stringResource(R.string.game_env_unset))
|
||||
if (!current.entries(scope).containsKey(name)) Text(stringResource(R.string.game_env_inherited), style = MaterialTheme.typography.labelSmall)
|
||||
Row {
|
||||
TextButton(onClick = { editing = name to (value ?: "") }, enabled = !busy) { Text(stringResource(R.string.game_env_edit)) }
|
||||
if (value != null) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) + (name to null))) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.game_env_remove))
|
||||
}
|
||||
}
|
||||
if (current.entries(scope).containsKey(name)) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) - name)) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.game_env_restore))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { addMenu = true }, enabled = !busy) { Text(stringResource(R.string.game_env_add)) }
|
||||
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { editing = "" to ""; addMenu = false })
|
||||
for (option in GameEnvironmentOptions.all) DropdownMenuItem(text = { Text(option.name) }, onClick = { editing = option.name to option.value; addMenu = false })
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { save(current.withEntries(scope, emptyMap())) }, enabled = !busy && current.entries(scope).isNotEmpty()) {
|
||||
Text(stringResource(R.string.game_env_reset))
|
||||
AnchoredMenu(menu == "scope", onDismiss = { if (menu == "scope") menu = null }, title = stringResource(R.string.game_env_scope)) { first ->
|
||||
MenuItem(shared, checked = scope.isEmpty(), focusRequester = first) { scope = ""; otherId = null; menu = null }
|
||||
val profiles = (games + current?.games?.keys.orEmpty().filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first }
|
||||
for ((id, name) in profiles) MenuItem(name, checked = scope == id) { scope = id; otherId = null; menu = null }
|
||||
MenuItem(stringResource(R.string.game_env_other), checked = false) { otherId = ""; menu = null }
|
||||
}
|
||||
}
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
otherId?.let { id ->
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
AdbTextField(
|
||||
id, { otherId = it.filter(Char::isDigit) }, stringResource(R.string.game_env_app_id), KeyboardType.Number, ImeAction.Done,
|
||||
modifier = Modifier.weight(1f), compact = true,
|
||||
onDone = { if (GameEnvironment.validScope(id)) { scope = id.toLong().toString(); otherId = null } },
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.game_env_open), enabled = id.isNotEmpty() && GameEnvironment.validScope(id)) {
|
||||
scope = id.toLong().toString(); otherId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error) Small(stringResource(R.string.game_env_error), error = true)
|
||||
if (current == null && !error) Small(stringResource(R.string.game_env_loading))
|
||||
if (current != null) {
|
||||
val own = current.entries(scope)
|
||||
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
|
||||
if (entries.isEmpty()) Small(stringResource(R.string.game_env_empty))
|
||||
else Panel {
|
||||
entries.entries.forEachIndexed { i, (name, value) ->
|
||||
if (i > 0) Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).heightIn(min = 1.dp, max = 1.dp).background(LocalPalette.current.line))
|
||||
VariableRow(
|
||||
name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy,
|
||||
onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null },
|
||||
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = name to (value ?: "") },
|
||||
// A value set here or inherited can be switched off for this profile; one set here can go back to inheriting.
|
||||
onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null,
|
||||
onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null,
|
||||
shared = scope.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
// Footer: add a variable, reset the profile, done.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||
// Only once the settings are in: a button drawn disabled first comes up without its box.
|
||||
if (current != null) Box {
|
||||
SecondaryButton(stringResource(R.string.game_env_add), enabled = !busy) { menu = if (menu == "add") null else "add" }
|
||||
AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first ->
|
||||
for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) {
|
||||
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = option.name to option.value
|
||||
}
|
||||
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = "" to "" }
|
||||
}
|
||||
}
|
||||
if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) {
|
||||
save(current.withEntries(scope, emptyMap()))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close)
|
||||
}
|
||||
}
|
||||
editing?.let { (name, value) ->
|
||||
VariableDialog(name, value, onDismiss = { editing = null }) { key, content ->
|
||||
VariableDialog(name, value, profileName, editByPad, onDismiss = { editing = null }) { key, content ->
|
||||
config?.let {
|
||||
val entries = it.entries(scope).toMutableMap()
|
||||
if (name.isNotEmpty() && name != key) entries[name] = null
|
||||
@@ -136,99 +209,111 @@ private fun GameEnvironmentEditor(onClose: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The panel the profile and the variables sit in, as a settings group does. */
|
||||
@Composable
|
||||
private fun VariableDialog(initialName: String, initialValue: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.game_env_edit)) },
|
||||
confirmButton = { TextButton(onClick = { onSave(name, value) }, enabled = valid) { Text(stringResource(R.string.game_env_save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.game_env_cancel)) } },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
VariableNamePicker(name) { selected ->
|
||||
name = selected
|
||||
value = GameEnvironmentOptions.find(selected)?.value.orEmpty()
|
||||
}
|
||||
VariableValueEditor(name, value) { value = it }
|
||||
GameEnvironmentOptions.find(name)?.takeIf { it.detail != 0 }?.let {
|
||||
Text(stringResource(it.detail), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (!GameEnvironment.supported(name)) Text(stringResource(R.string.game_env_unused))
|
||||
if (!valid && name.isNotEmpty()) Text(stringResource(R.string.game_env_invalid), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
|
||||
Column(Modifier.fillMaxWidth().clip(Shape14).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, Shape14), content = content)
|
||||
|
||||
/** One variable: its name, what it is set to (and whether that comes from the shared profile), and a menu of what to do with it. */
|
||||
@Composable
|
||||
private fun VariableNamePicker(name: String, onChange: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) }
|
||||
Text(stringResource(R.string.game_env_name), style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (custom) stringResource(R.string.game_env_custom) else name)
|
||||
private fun VariableRow(
|
||||
name: String, value: String?, inherited: Boolean, open: Boolean, enabled: Boolean, shared: Boolean,
|
||||
onToggle: () -> Unit, onDismiss: () -> Unit, onEdit: () -> Unit, onUnset: (() -> Unit)?, onRestore: (() -> Unit)?,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(name, fontSize = 13.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (inherited) Text(stringResource(if (shared) R.string.game_env_default else R.string.game_env_inherited), fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = {
|
||||
custom = true; onChange(""); expanded = false
|
||||
})
|
||||
for (option in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }) {
|
||||
DropdownMenuItem(text = { Text(option.name) }, onClick = {
|
||||
custom = false; onChange(option.name); expanded = false
|
||||
})
|
||||
Box {
|
||||
ValueChip(value ?: stringResource(R.string.game_env_off), open, enabled = enabled, modifier = Modifier.widthIn(max = 200.dp), onClick = onToggle)
|
||||
AnchoredMenu(open, onDismiss = onDismiss, title = name) { first ->
|
||||
MenuItem(stringResource(R.string.game_env_change), checked = false, focusRequester = first, onClick = onEdit)
|
||||
if (onUnset != null) MenuItem(stringResource(if (shared) R.string.game_env_remove else R.string.game_env_unset), checked = false, onClick = onUnset)
|
||||
if (onRestore != null) MenuItem(stringResource(if (shared) R.string.game_env_restore_default else R.string.game_env_restore), checked = false, onClick = onRestore)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true,
|
||||
label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableDialog(initialName: String, initialValue: String, profile: String, byPad: Boolean, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value)
|
||||
val option = GameEnvironmentOptions.find(name)
|
||||
val nameFocus = remember { FocusRequester() }
|
||||
AppDialog(shown, close, "gameEnvVar", wide = false) {
|
||||
PadFocus(byPad, nameFocus)
|
||||
DialogHeader(profile, stringResource(if (initialName.isEmpty()) R.string.game_env_add else R.string.game_env_change_title))
|
||||
VariableNamePicker(name, nameFocus) { selected ->
|
||||
name = selected
|
||||
value = GameEnvironmentOptions.find(selected)?.value.orEmpty()
|
||||
}
|
||||
VariableValueEditor(name, value) { value = it }
|
||||
if (!GameEnvironment.supported(name)) Small(stringResource(R.string.game_env_unused))
|
||||
if (!valid && name.isNotEmpty()) Small(stringResource(R.string.game_env_invalid), error = true)
|
||||
DialogFooter(
|
||||
note = option?.takeIf { it.detail != 0 }?.let { stringResource(it.detail) },
|
||||
confirm = stringResource(R.string.game_env_save), enabled = valid, onCancel = close,
|
||||
) { onSave(name, value); close() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableNamePicker(name: String, focus: FocusRequester, onChange: (String) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) }
|
||||
SettingsRow(stringResource(R.string.game_env_name), null, highlighted = open) {
|
||||
Box {
|
||||
ValueChip(if (custom) stringResource(R.string.game_env_custom) else name, open, modifier = Modifier.focusRequester(focus).widthIn(max = 260.dp)) { open = !open }
|
||||
AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_env_name)) { first ->
|
||||
for ((i, option) in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }.withIndex()) {
|
||||
MenuItem(option.name, checked = !custom && option.name == name, focusRequester = if (i == 0) first else null) {
|
||||
custom = false; onChange(option.name); open = false
|
||||
}
|
||||
}
|
||||
MenuItem(stringResource(R.string.game_env_custom), checked = custom) { custom = true; onChange(""); open = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) AdbTextField(name, onChange, stringResource(R.string.game_env_name), KeyboardType.Ascii, ImeAction.Next, compact = true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) {
|
||||
val option = GameEnvironmentOptions.find(name)
|
||||
var expanded by remember(name) { mutableStateOf(false) }
|
||||
var open by remember(name) { mutableStateOf(false) }
|
||||
var custom by remember(name) { mutableStateOf(option == null || value !in option.choices && option.type != GameEnvironmentOptions.Type.MULTIPLE) }
|
||||
when {
|
||||
option?.type == GameEnvironmentOptions.Type.TOGGLE -> Row(
|
||||
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
Text(stringResource(R.string.game_env_value))
|
||||
Switch(checked = value == option.choices.last(), onCheckedChange = {
|
||||
onChange(if (it) option.choices.last() else option.choices.first())
|
||||
})
|
||||
}
|
||||
// On/off variables pick from their two values like any other choice: one way to set a value.
|
||||
option != null && option.choices.isNotEmpty() -> {
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) })
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom_value)) }, onClick = { custom = true; expanded = false })
|
||||
for (choice in option.choices) {
|
||||
val selected = choice in value.split(',')
|
||||
DropdownMenuItem(text = { Text(choice) }, leadingIcon = {
|
||||
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) Checkbox(checked = selected, onCheckedChange = null)
|
||||
}, onClick = {
|
||||
custom = false
|
||||
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) {
|
||||
onChange(GameEnvironmentOptions.toggle(value, choice))
|
||||
} else {
|
||||
onChange(choice); expanded = false
|
||||
SettingsRow(stringResource(R.string.game_env_value), null, highlighted = open) {
|
||||
Box {
|
||||
ValueChip(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) }, open, modifier = Modifier.widthIn(max = 260.dp)) { open = !open }
|
||||
AnchoredMenu(open, onDismiss = { open = false }, title = name) { first ->
|
||||
val multiple = option.type == GameEnvironmentOptions.Type.MULTIPLE
|
||||
for ((i, choice) in option.choices.withIndex()) {
|
||||
MenuItem(choice, checked = if (multiple) choice in value.split(',') else !custom && choice == value, focusRequester = if (i == 0) first else null) {
|
||||
custom = false
|
||||
if (multiple) onChange(GameEnvironmentOptions.toggle(value, choice)) else { onChange(choice); open = false }
|
||||
}
|
||||
})
|
||||
}
|
||||
MenuItem(stringResource(R.string.game_env_custom_value), checked = custom) { custom = true; open = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
|
||||
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
|
||||
if (custom) AdbTextField(value, onChange, stringResource(R.string.game_env_value), KeyboardType.Ascii, ImeAction.Done, compact = true)
|
||||
}
|
||||
else -> OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Text),
|
||||
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
|
||||
else -> AdbTextField(
|
||||
value, onChange, stringResource(R.string.game_env_value),
|
||||
if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Ascii, ImeAction.Done, compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -120,29 +122,28 @@ internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvance
|
||||
DeviceCard(s)
|
||||
if (!s.unsupported) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
}
|
||||
if (s.auto) AutoCard(s)
|
||||
else {
|
||||
val shown = s.pairs.filter { it.suits || showAll }
|
||||
if (shown.isEmpty()) Text(
|
||||
if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.",
|
||||
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
|
||||
if (s.pairs.any { !it.suits }) FocusText(
|
||||
if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal,
|
||||
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
|
||||
) { showAll = !showAll }
|
||||
}
|
||||
}
|
||||
SettingsGroup("Advanced") {
|
||||
val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." }
|
||||
SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") }
|
||||
SettingsGroup(stringResource(R.string.gpu_advanced)) {
|
||||
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
|
||||
}
|
||||
SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") }
|
||||
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +175,7 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
|
||||
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
|
||||
)
|
||||
HorizontalDivider(color = pal.line)
|
||||
DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,14 +218,14 @@ private fun AutoCard(s: GpuDriversState) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck",
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
|
||||
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair."
|
||||
else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.",
|
||||
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
|
||||
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (s.busy != null) {
|
||||
@@ -243,12 +244,13 @@ private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: B
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
// Selection is the radio; the ring is only ever focus.
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
|
||||
.clip(Shape14).background(colors.surface).background(fill)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
@@ -269,12 +271,12 @@ private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean,
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val usable = enabled && p.complete && !p.active
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
|
||||
.clip(Shape12).background(colors.surface).background(fill)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12)
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(enabled = usable, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
@@ -284,7 +286,7 @@ private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean,
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (p.recommended) Text(
|
||||
"RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -295,10 +297,10 @@ private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean,
|
||||
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
|
||||
}
|
||||
p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.ui
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -48,6 +48,8 @@ import kotlin.math.sin
|
||||
// loading screen opens on that blue and gathers it into the ball of the throbber.
|
||||
// Stopping one from the drawer runs it the other way: Stop grows until the page is blue, and the
|
||||
// front end opens on that blue and draws it back down into the button the session came from.
|
||||
// Any other way out (backing out of the loading screen, closing the ended screen, the session
|
||||
// ending) spreads the blue back out of the throbber's ball instead (FloodSpread).
|
||||
|
||||
/** Carries the flood's colour to the session, which opens on it. */
|
||||
const val EXTRA_FLOOD = "com.droiddeck.launcher.FLOOD"
|
||||
@@ -139,6 +141,7 @@ internal fun LaunchFlood(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val start = fromColors ?: (colors.primary to pal.primary2)
|
||||
VeilRing()
|
||||
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
|
||||
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
|
||||
val edges = remember { List(4) { Animatable(0f) } }
|
||||
@@ -211,6 +214,7 @@ internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, o
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val edges = remember { List(4) { Animatable(1f) } }
|
||||
VeilRing()
|
||||
val fade = remember { Animatable(1f) }
|
||||
val landed by rememberUpdatedState(onLanded)
|
||||
val progress by rememberUpdatedState(onProgress)
|
||||
@@ -221,7 +225,7 @@ internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, o
|
||||
// A beat of plain blue first, so the change of screen does not read as a cut.
|
||||
delay(Motion.ms(220).toLong())
|
||||
if (to == null || Motion.scale == 0f) {
|
||||
fade.animateTo(0f, tween(Motion.ms(240).coerceAtLeast(1)))
|
||||
fade.animateTo(0f, Motion.tw(240, easing = FastOutSlowInEasing))
|
||||
} else {
|
||||
val travel = listOf(to.left, to.top, page.width - to.right, page.height - to.bottom).map { it.coerceAtLeast(0f) }
|
||||
val far = travel.max().coerceAtLeast(1f)
|
||||
@@ -253,6 +257,7 @@ internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, o
|
||||
@Composable
|
||||
internal fun FloodGather(flood: Color, ball: Pair<Offset, Float>?, onLanded: () -> Unit) {
|
||||
val gather = remember { Animatable(0f) }
|
||||
VeilRing()
|
||||
var started by remember { mutableStateOf(false) }
|
||||
val landed by rememberUpdatedState(onLanded)
|
||||
LaunchedEffect(ball != null) {
|
||||
@@ -260,7 +265,7 @@ internal fun FloodGather(flood: Color, ball: Pair<Offset, Float>?, onLanded: ()
|
||||
started = true
|
||||
// A beat of plain blue first, so the change of screen does not read as a cut.
|
||||
delay(Motion.ms(90).toLong())
|
||||
gather.animateTo(1f, tween(Motion.ms(640).coerceAtLeast(1), easing = Gather))
|
||||
gather.animateTo(1f, Motion.tw(640, easing = Gather))
|
||||
landed()
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize().pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
|
||||
@@ -274,3 +279,31 @@ internal fun FloodGather(flood: Color, ball: Pair<Offset, Float>?, onLanded: ()
|
||||
drawCircle(flood, radius = lerp(far, r, gather.value), center = c)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the loading screen's throbber ball sits now (centre and radius, root px), for a flood leaving from it. */
|
||||
internal object ThrobberSpot {
|
||||
var ball: Pair<Offset, Float>? = null
|
||||
}
|
||||
|
||||
private val Spread = CubicBezierEasing(0.7f, 0f, 0.4f, 1f)
|
||||
|
||||
/**
|
||||
* Leaving a session: [flood] spreading out of [ball] (centre and radius in root px; the middle of
|
||||
* the screen when there is none) until it covers the screen, the way FloodGather drew it in. Calls
|
||||
* [onCovered] then, for the front end to open on the same blue. Swallows touches while it runs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FloodSpread(flood: Color, ball: Pair<Offset, Float>?, onCovered: () -> Unit) {
|
||||
VeilRing()
|
||||
val grow = remember { Animatable(0f) }
|
||||
val covered by rememberUpdatedState(onCovered)
|
||||
LaunchedEffect(Unit) {
|
||||
grow.animateTo(1f, Motion.tw(520, easing = Spread))
|
||||
covered()
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize().pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
|
||||
val (c, r) = ball ?: (Offset(size.width / 2f, size.height / 2f) to 0f)
|
||||
val far = maxOf(hypot(c.x, c.y), hypot(size.width - c.x, c.y), hypot(c.x, size.height - c.y), hypot(size.width - c.x, size.height - c.y))
|
||||
drawCircle(flood, radius = lerp(r, far, grow.value), center = c)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -25,17 +27,17 @@ fun ChooseAppDisplayDialog(
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Launch ${app.label}") },
|
||||
title = { Text(stringResource(R.string.main_launch_named, app.label)) },
|
||||
text = {
|
||||
Text(
|
||||
secondaryDisplay?.let { "Choose a screen. Secondary: ${it.label}." }
|
||||
?: "The secondary display is no longer available.",
|
||||
secondaryDisplay?.let { stringResource(R.string.main_choose_screen, it.label) }
|
||||
?: stringResource(R.string.main_secondary_gone),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onPrimary) { Text("Primary screen") } },
|
||||
confirmButton = { TextButton(onClick = onPrimary) { Text(stringResource(R.string.main_primary)) } },
|
||||
dismissButton = {
|
||||
TextButton(onClick = onSecondary, enabled = secondaryDisplay != null) {
|
||||
Text("Secondary screen")
|
||||
Text(stringResource(R.string.main_secondary))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -48,6 +50,6 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () ->
|
||||
title = { Text(title) },
|
||||
text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = { onDismiss(); onConfirm() }) { Text(confirm) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -151,32 +152,32 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
title = if (steam) "Steam session" else "Desktop session",
|
||||
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
|
||||
onBack = a.onDismiss,
|
||||
scroll = pageScroll,
|
||||
) {
|
||||
SettingsGroup("Display") {
|
||||
SettingsGroup(stringResource(R.string.mode_display)) {
|
||||
val default = SessionPrefs.defaultResolutionCap(s.mode)
|
||||
var editCustom by remember { mutableStateOf(false) }
|
||||
val custom = s.customResolution
|
||||
ChoiceRow(
|
||||
host, "res", "Resolution", "Applies next session.",
|
||||
listOf(720 to "Up to 720p", 900 to "Up to 900p", 1080 to "Up to 1080p", 0 to "The panel's own")
|
||||
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) } +
|
||||
(CUSTOM to (custom?.let { "Custom · ${it.first}×${it.second}" } ?: "Custom…")),
|
||||
if (custom != null) CUSTOM else s.resolutionCap, note = "720p can improve menu responsiveness.",
|
||||
host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session),
|
||||
listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native))
|
||||
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
|
||||
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
|
||||
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
|
||||
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
|
||||
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "shape", "Screen ratio",
|
||||
if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.",
|
||||
host, "shape", stringResource(R.string.mode_ratio),
|
||||
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
|
||||
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "fps", "Frame limit", "Applies next session.",
|
||||
host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
|
||||
note = "Caps the whole session. The screen switches to a rate the cap divides evenly.",
|
||||
note = stringResource(R.string.mode_fps_note),
|
||||
onPick = a.onFpsLimit,
|
||||
)
|
||||
if (editCustom) CustomResolutionDialog(
|
||||
@@ -185,94 +186,93 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onDismiss = { editCustom = false },
|
||||
)
|
||||
}
|
||||
SettingsGroup("HDR") {
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", "HDR10 output",
|
||||
s.hdrReason?.let { "Not available: $it." }
|
||||
?: "Restart the app to apply.",
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Drivers") {
|
||||
SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") {
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", "Touch", null,
|
||||
listOf("auto" to "Auto", "touchpad" to "Touchpad", "direct" to "Direct"), s.touchMode,
|
||||
note = "Auto uses touchpad on desktop and direct input in Steam. Touchpad: drag to move, tap to click.",
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf("auto" to stringResource(R.string.common_auto), "touchpad" to stringResource(R.string.mode_touch_touchpad), "direct" to stringResource(R.string.mode_touch_direct)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", "On-screen controls", null,
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to "Auto",
|
||||
SessionPrefs.OSC_ALWAYS to "Always",
|
||||
SessionPrefs.OSC_STEAM_QAM to "Steam + QAM",
|
||||
SessionPrefs.OSC_NEVER to "Never",
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", "Controller", "What your controller is to Steam. Applies next session.",
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to "Steam Deck controller",
|
||||
SessionPrefs.CONTROLLER_XBOX360 to "Xbox 360 controller",
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), 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.",
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Session") {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", "Background behavior",
|
||||
"How this session behaves when the app leaves the screen or the display turns off.",
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to "Auto",
|
||||
SessionPrefs.SUSPEND_MANUAL to "Manual",
|
||||
SessionPrefs.SUSPEND_NEVER to "Never",
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
),
|
||||
s.suspendPolicy,
|
||||
note = "Auto pauses in the background and resumes when visible. Manual pauses there and waits for Resume. Never keeps the session running.",
|
||||
note = stringResource(R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
}
|
||||
if (steam) SettingsGroup("Startup") {
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", "Run Steam when DroidDeck starts",
|
||||
"Open the Steam session when you launch DroidDeck.",
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
if (steam) SettingsGroup("Decky") {
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
s.deckyStage != null -> s.deckyStage
|
||||
s.deckyChecking -> "Checking compatible releases…"
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> "Ready to install ${s.deckyLatestRelease.tag}."
|
||||
s.deckyInstalled == null -> "No compatible build found. Check again later."
|
||||
s.deckyLatestRelease == null -> "Installed · ${s.deckyInstalled}"
|
||||
updateAvailable -> "Update available · ${s.deckyLatestRelease.tag}"
|
||||
else -> "Up to date · ${s.deckyInstalled}"
|
||||
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
|
||||
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
|
||||
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
|
||||
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
|
||||
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
|
||||
}
|
||||
SettingsRow("Loader", status) {
|
||||
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
|
||||
val action = when {
|
||||
s.deckyStage != null -> "Working…"
|
||||
s.deckyChecking -> "Checking…"
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> "Install latest"
|
||||
s.deckyInstalled != null && updateAvailable -> "Update"
|
||||
else -> "Check"
|
||||
s.deckyStage != null -> stringResource(R.string.common_working)
|
||||
s.deckyChecking -> stringResource(R.string.common_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
|
||||
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
|
||||
else -> stringResource(R.string.common_check)
|
||||
}
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
@@ -283,15 +283,15 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
else s.deckyLatestRelease?.let(a.onDeckyInstall)
|
||||
}
|
||||
if (s.deckyInstalled != null) SecondaryButton(
|
||||
"Uninstall",
|
||||
stringResource(R.string.common_uninstall),
|
||||
enabled = s.deckyStage == null && !s.deckySessionRunning,
|
||||
) { confirmDeckyRemoval = true }
|
||||
}
|
||||
}
|
||||
if (s.deckyInstalled != null) SettingsRow(
|
||||
"Decky",
|
||||
if (s.deckyEnabled) "Starts with Steam. Other apps on this device may be able to control Steam while enabled."
|
||||
else "Off. Decky stays off and Steam's remote debugging port stays closed.",
|
||||
stringResource(R.string.mode_decky),
|
||||
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
|
||||
else stringResource(R.string.mode_decky_off),
|
||||
) {
|
||||
Switch(
|
||||
checked = s.deckyEnabled,
|
||||
@@ -306,48 +306,48 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && s.steamChannel != null) SettingsGroup("Client") {
|
||||
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "steamdeck", "Steam Deck mode",
|
||||
"Enables Steam's Deck interface and Quick Access performance overlay controls. Applies next session.",
|
||||
host, "steamdeck", stringResource(R.string.mode_deck_mode),
|
||||
stringResource(R.string.mode_deck_mode_hint),
|
||||
s.steamDeckMode, onChange = a.onSteamDeckMode,
|
||||
)
|
||||
if (s.steamDeckMode) ToggleRow(
|
||||
host, "mangoapp", "Performance overlay",
|
||||
"The Quick Access Menu's frame-rate and usage overlay. Turn off if games crash or go black with Deck mode on. Applies next session.",
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
|
||||
if (s.steamDeckMode) SettingsRow("Client branch", "Steam Deck public beta: Deck mode needs it, so Steam doesn't keep offering an update") {}
|
||||
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
|
||||
else ChoiceRow(
|
||||
host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.",
|
||||
listOf("publicbeta" to "Public beta", "steamdeck_publicbeta" to "Steam Deck public beta"), s.steamChannel,
|
||||
note = "Public beta is what every session ran on before. Steam Deck public beta is the one Armada bootstraps from, and the one Deck mode always uses.",
|
||||
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
|
||||
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
|
||||
note = stringResource(R.string.mode_branch_note),
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
}
|
||||
if (steam && s.addedGamesDirs != null) SettingsGroup("Added games") {
|
||||
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
|
||||
for (dir in s.addedGamesDirs) {
|
||||
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
|
||||
ActionRow(
|
||||
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) "no game folders with a .exe found" else "$n game${if (n == 1) "" else "s"}") + ". " + stringResource(R.string.added_games_forget_hint),
|
||||
"Forget", onClick = { a.onForgetAddedGamesDir(dir) },
|
||||
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) stringResource(R.string.mode_added_none) else pluralStringResource(R.plurals.mode_added_count, n, n)) + ". " + stringResource(R.string.added_games_forget_hint),
|
||||
stringResource(R.string.common_forget), onClick = { a.onForgetAddedGamesDir(dir) },
|
||||
)
|
||||
}
|
||||
ActionRow(
|
||||
if (s.addedGamesDirs.isEmpty()) "Games folder" else "Another games folder",
|
||||
if (s.addedGamesDirs.isEmpty()) stringResource(R.string.mode_games_folder) else stringResource(R.string.mode_games_folder_another),
|
||||
stringResource(R.string.added_games_import_hint),
|
||||
"Add…", onClick = a.onPickAddedGamesDir,
|
||||
stringResource(R.string.common_add_ellipsis), onClick = a.onPickAddedGamesDir,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "addedArt", "Artwork from Steam",
|
||||
"A game with no art of its own gets the store's capsule, header, hero and logo for the same title, looked up by folder name. Your own art wins: drop cover.jpg (or poster, boxart, folder, the folder's name), header.jpg, hero.jpg, logo.png or icon.png into the game's folder or its art subfolder.",
|
||||
host, "addedArt", stringResource(R.string.mode_added_art),
|
||||
stringResource(R.string.mode_added_art_hint),
|
||||
s.addedGamesArt, onChange = a.onAddedGamesArt,
|
||||
)
|
||||
for (g in s.addedGames) ChoiceRow(
|
||||
host, "added:" + g.folderPath, g.folderName, "Launches ${g.exeName}" + (if (s.addedGamesDirs.size > 1) " · in " + g.folderPath.substringBeforeLast('/').substringAfterLast('/') else ""),
|
||||
g.candidates + ("__pick__" to "Choose another file…"), g.exePath,
|
||||
note = "The .exe files found in the game's folder; the one named after the folder, else the largest, is picked unless you choose.",
|
||||
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
|
||||
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
|
||||
note = stringResource(R.string.mode_added_exe_note),
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
@@ -359,44 +359,44 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
GameEnvironmentRow()
|
||||
if (s.forceFullscreen != null) ToggleRow(
|
||||
host, "fill", "Stretch games to fill the screen",
|
||||
"Keeps games that resize their own window (FlatOut) full screen. Turn it off if a game shows up small in a corner (Quake 3). Applies next session.",
|
||||
host, "fill", stringResource(R.string.mode_fill),
|
||||
stringResource(R.string.mode_fill_hint),
|
||||
s.forceFullscreen, onChange = a.onForceFullscreen,
|
||||
)
|
||||
if (s.stretch16x9 != null) ToggleRow(
|
||||
host, "stretch169", "Stretch 16:9 games to a narrower screen",
|
||||
"On a screen squarer than 16:9 (a foldable's inner panel), games render at 16:9 and are stretched to fill it: no bars, slightly wider-looking. Applies next session.",
|
||||
host, "stretch169", stringResource(R.string.mode_stretch169),
|
||||
stringResource(R.string.mode_stretch169_hint),
|
||||
s.stretch16x9, onChange = a.onStretch16x9,
|
||||
)
|
||||
}
|
||||
if (steam && s.directAudio != null && s.mic != null) SettingsGroup("Audio") {
|
||||
ToggleRow(host, "da", "DirectAudio for games", "Bypasses PulseAudio for lower latency in games.", s.directAudio, onChange = a.onDirectAudio)
|
||||
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
|
||||
ChoiceRow(
|
||||
host, "clientAudio", "Steam client audio", "Classic is the AAudio sink from 0.1.5. DirectAudio goes through the relay. Applies next session.",
|
||||
listOf("classic" to "Classic", "directaudio" to "DirectAudio"), if (s.clientDirectAudio) "directaudio" else "classic",
|
||||
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
|
||||
listOf("classic" to stringResource(R.string.mode_client_audio_classic), "directaudio" to stringResource(R.string.mode_client_audio_direct)), if (s.clientDirectAudio) "directaudio" else "classic",
|
||||
onPick = { id -> a.onClientDirectAudio(id == "directaudio") },
|
||||
)
|
||||
ToggleRow(host, "mic", "Microphone", "Uses the device microphone for voice chat.", s.mic, onChange = a.onMic)
|
||||
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
|
||||
}
|
||||
if (steam && s.gameStorage != null) SettingsGroup("Game storage") {
|
||||
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
|
||||
val options = buildList {
|
||||
add("" to ("Automatic - the SD card when one is in" + (if (s.storageOptions.isEmpty()) " (none right now)" else "")))
|
||||
add("off" to "Internal only")
|
||||
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
|
||||
add("off" to stringResource(R.string.mode_storage_internal))
|
||||
for ((label, path) in s.storageOptions) add(path to label)
|
||||
if (custom) add(s.gameStorage to "Folder: ${s.gameStorage}")
|
||||
if (custom) add(s.gameStorage to stringResource(R.string.mode_storage_folder, s.gameStorage))
|
||||
}
|
||||
val open = host.open == "storage"
|
||||
SettingsRow(
|
||||
"Second library",
|
||||
stringResource(R.string.mode_second_library),
|
||||
stringResource(R.string.second_library_import_hint),
|
||||
highlighted = open,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
ValueChip(options.firstOrNull { it.first == s.gameStorage }?.second?.substringBefore(" -") ?: "-", open) { host.open = if (open) null else "storage" }
|
||||
AnchoredMenu(
|
||||
open, onDismiss = { if (host.open == "storage") host.open = null }, title = "Second library",
|
||||
note = "Games that stream assets from SD or shared storage may stutter. Keep them internal.",
|
||||
open, onDismiss = { if (host.open == "storage") host.open = null }, title = stringResource(R.string.mode_second_library),
|
||||
note = stringResource(R.string.mode_storage_note),
|
||||
) { firstItemFocus ->
|
||||
options.forEachIndexed { index, (path, label) ->
|
||||
MenuItem(label, checked = path == s.gameStorage, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
@@ -404,30 +404,28 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
MenuItem("Choose a folder…", checked = false) { host.open = null; a.onPickGameStorageFolder() }
|
||||
MenuItem(stringResource(R.string.mode_choose_folder), checked = false) { host.open = null; a.onPickGameStorageFolder() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!steam && s.renderer != null) SettingsGroup("Renderer") {
|
||||
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
|
||||
ChoiceRow(
|
||||
host, "renderer", "Desktop renderer", "Composites the desktop.",
|
||||
listOf("vulkan" to "vulkan - GPU", "gles2" to "gles2 - GPU (experimental)", "pixman" to "pixman - software"), s.renderer,
|
||||
note = "On Vulkan, programs on the desktop draw with the GPU in their own windows; if it cannot start, " +
|
||||
"the desktop comes up on pixman. On pixman, games and emulators from the menu open full screen " +
|
||||
"on the GPU instead (right-click one for a desktop window).",
|
||||
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
|
||||
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
|
||||
note = stringResource(R.string.mode_renderer_note),
|
||||
onPick = a.onRenderer,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (confirmDeckyRemoval) AlertDialog(
|
||||
onDismissRequest = { confirmDeckyRemoval = false },
|
||||
title = { Text("Uninstall Decky Loader?") },
|
||||
text = { Text("Remove the loader and keep your plugins and settings.") },
|
||||
title = { Text(stringResource(R.string.mode_decky_remove_title)) },
|
||||
text = { Text(stringResource(R.string.mode_decky_remove_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text("Uninstall") }
|
||||
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text("Cancel") } },
|
||||
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -443,11 +441,11 @@ private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int,
|
||||
val parsed = SessionPrefs.parseResolution("${w}x$h")
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Custom resolution") },
|
||||
title = { Text(stringResource(R.string.mode_custom_res_title)) },
|
||||
text = {
|
||||
androidx.compose.foundation.layout.Column {
|
||||
Text(
|
||||
"The session's display size. It replaces the cap and the shape; a size that does not match the panel's shape gets bars.",
|
||||
stringResource(R.string.mode_custom_res_text),
|
||||
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.foundation.layout.Row(
|
||||
@@ -456,12 +454,12 @@ private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int,
|
||||
) {
|
||||
val numbers = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number)
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
w, { v -> w = v.filter(Char::isDigit).take(4) }, label = { Text("Width") },
|
||||
w, { v -> w = v.filter(Char::isDigit).take(4) }, label = { Text(stringResource(R.string.mode_width)) },
|
||||
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("×", fontSize = 18.sp, modifier = Modifier.padding(horizontal = 10.dp))
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
h, { v -> h = v.filter(Char::isDigit).take(4) }, label = { Text("Height") },
|
||||
h, { v -> h = v.filter(Char::isDigit).take(4) }, label = { Text(stringResource(R.string.mode_height)) },
|
||||
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -477,12 +475,12 @@ private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int,
|
||||
}
|
||||
}
|
||||
if (parsed == null && (w.isNotEmpty() || h.isNotEmpty())) Text(
|
||||
"Between 320×240 and 3840×2160.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
|
||||
stringResource(R.string.mode_custom_res_range), fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }) { Text("Use") } },
|
||||
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }) { Text(stringResource(R.string.common_use)) } },
|
||||
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
// A page opened from a control (the cog beside Play) grows out of it as a session grows out of
|
||||
// Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges
|
||||
// first, and the page rises into it once it is covered. Back draws it down into the control again.
|
||||
|
||||
/** Where a page opened from: the control's bounds and corner radius, in px. */
|
||||
internal class Origin(val bounds: Rect, val corner: Float) {
|
||||
fun translate(by: Offset) = Origin(bounds.translate(by), corner)
|
||||
}
|
||||
|
||||
internal object PageOrigin {
|
||||
private var origin: Origin? = null
|
||||
private var at = 0L
|
||||
|
||||
/** The control a page is about to open from, in root coordinates. */
|
||||
fun mark(o: Origin) {
|
||||
origin = o
|
||||
at = SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
/** The control just pressed, once: stale after a second, so a page opened another way never floods. */
|
||||
fun take(): Origin? = origin.takeIf { SystemClock.uptimeMillis() - at < 1_000 }.also { origin = null }
|
||||
}
|
||||
|
||||
/** How long a leaving page is kept for its flood to draw back into the control. */
|
||||
internal const val PAGE_RETURN_MS = 900
|
||||
|
||||
/**
|
||||
* [content], a page that opened from the control at [from] (in this element's coordinates): the
|
||||
* control's tile stretching over it until covered, then the page rising in as the tile fades.
|
||||
* Once [leaving], the reverse: the tile comes back over the page and draws down into the control.
|
||||
* The focus ring stays out of it until it is done.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PageFlood(from: Origin, leaving: Boolean, content: @Composable () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val glide = LocalFocusGlide.current
|
||||
// Left, top, right, bottom: 0 on the control, 1 on the pane's edge.
|
||||
val edges = remember { List(4) { Animatable(0f) } }
|
||||
val shown = remember { Animatable(0f) }
|
||||
val tile = remember { Animatable(1f) }
|
||||
var size by remember { mutableStateOf(Size.Zero) }
|
||||
LaunchedEffect(size != Size.Zero, leaving) {
|
||||
if (size == Size.Zero) return@LaunchedEffect
|
||||
glide?.hidden = true
|
||||
val b = from.bounds
|
||||
val travel = listOf(b.left, b.top, size.width - b.right, size.height - b.bottom).map { it.coerceAtLeast(0f) }
|
||||
val far = travel.max().coerceAtLeast(1f)
|
||||
coroutineScope {
|
||||
if (!leaving) {
|
||||
val runs = edges.mapIndexed { i, edge ->
|
||||
val lead = travel[i] / far
|
||||
launch {
|
||||
delay(Motion.ms((150 * (1f - lead)).toInt()).toLong())
|
||||
edge.animateTo(1f, Motion.sp(0.5f, lerp(150f, 260f, lead)))
|
||||
}
|
||||
}
|
||||
snapshotFlow { edges.all { it.value >= 0.995f } }.first { it }
|
||||
launch { tile.animateTo(0f, Motion.tw(360, 60)) }
|
||||
shown.animateTo(1f, Motion.tw(320))
|
||||
runs.forEach { it.join() }
|
||||
} else {
|
||||
launch { shown.animateTo(0f, Motion.tw(140)) }
|
||||
tile.animateTo(1f, Motion.tw(140))
|
||||
edges.mapIndexed { i, edge ->
|
||||
val lead = travel[i] / far
|
||||
launch {
|
||||
delay(Motion.ms((120 * (1f - lead)).toInt()).toLong())
|
||||
edge.animateTo(0f, if (Motion.scale == 0f) Motion.sp() else spring(dampingRatio = 1f, stiffness = lerp(170f, 260f, lead)))
|
||||
}
|
||||
}.forEach { it.join() }
|
||||
tile.animateTo(0f, Motion.tw(120))
|
||||
}
|
||||
}
|
||||
glide?.hidden = false
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { glide?.hidden = false } }
|
||||
Box(Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }) {
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value; translationY = (1f - shown.value) * 14.dp.toPx() }) { content() }
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
if (tile.value > 0.01f) drawTile(from, edges.map { it.value }, pal.surface, pal.line2, tile.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The control's tile with its edges [v] of the way to the pane's (left, top, right, bottom):
|
||||
* sinking with the page behind, blobby in flight, square once it fills the pane.
|
||||
*/
|
||||
private fun DrawScope.drawTile(from: Origin, v: List<Float>, fill: Color, line: Color, alpha: Float) {
|
||||
val (l, t, r, b) = v
|
||||
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
|
||||
// The control sinks with the page behind it (scaled to 0.95 about the pane's centre).
|
||||
val k = 1f - 0.05f * mean
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val f = from.bounds
|
||||
val left = lerp(cx + (f.left - cx) * k, 0f, l)
|
||||
val top = lerp(cy + (f.top - cy) * k, 0f, t)
|
||||
val right = lerp(cx + (f.right - cx) * k, size.width, r)
|
||||
val bottom = lerp(cy + (f.bottom - cy) * k, size.height, b)
|
||||
val w = (right - left).coerceAtLeast(0f)
|
||||
val h = (bottom - top).coerceAtLeast(0f)
|
||||
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
|
||||
val corner = lerp(from.corner * (1f - mean), minOf(w, h) * 0.42f, blob)
|
||||
drawRoundRect(fill, Offset(left, top), Size(w, h), CornerRadius(corner), alpha = alpha)
|
||||
// The control's outline, kept while it is still the size of one.
|
||||
val edge = (1f - mean * 3f).coerceIn(0f, 1f)
|
||||
if (edge > 0f) drawRoundRect(line, Offset(left, top), Size(w, h), CornerRadius(corner), alpha = alpha * edge, style = Stroke(1.dp.toPx()))
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -157,11 +159,11 @@ fun PcKeyboard(
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(24.dp)) {
|
||||
Text("PC keyboard", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant)
|
||||
Text(stringResource(R.string.drawer_pc_keyboard), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant)
|
||||
Spacer(Modifier.weight(1f))
|
||||
HeaderButton("Android keyboard", onAndroidKeyboard)
|
||||
HeaderButton(stringResource(R.string.pckb_android), onAndroidKeyboard)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
HeaderButton("Hide ✕", onClose)
|
||||
HeaderButton(stringResource(R.string.pckb_hide), onClose)
|
||||
}
|
||||
ROWS.forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -40,6 +42,7 @@ fun PerformancePage(
|
||||
gamescopeRealtime: Boolean,
|
||||
gpuClockPin: Boolean,
|
||||
prootNoSeccomp: Boolean,
|
||||
prootFastPath: Boolean,
|
||||
guestHostname: String,
|
||||
phantomWarning: String?,
|
||||
onClientOverride: (Boolean) -> Unit,
|
||||
@@ -51,6 +54,7 @@ fun PerformancePage(
|
||||
onGamescopeRealtime: (Boolean) -> Unit,
|
||||
onGpuClockPin: (Boolean) -> Unit,
|
||||
onProotNoSeccomp: (Boolean) -> Unit,
|
||||
onProotFastPath: (Boolean) -> Unit,
|
||||
onGuestHostname: (String) -> Unit,
|
||||
onClientCore: (Int, Boolean) -> Unit,
|
||||
onGameCore: (Int, Boolean) -> Unit,
|
||||
@@ -60,81 +64,87 @@ fun PerformancePage(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val coreItems = cores.map { it.core to it.label }
|
||||
SettingsPage(
|
||||
host, title = "Performance",
|
||||
lede = "CPU cores, session fixes and the host name. Applies next session.",
|
||||
host, title = stringResource(R.string.perf_title),
|
||||
lede = stringResource(R.string.perf_lede),
|
||||
onBack = onDismiss,
|
||||
) {
|
||||
SettingsGroup("Steam client cores") {
|
||||
SettingsGroup(stringResource(R.string.perf_client_cores_group)) {
|
||||
ToggleRow(
|
||||
host, "override", "Override Steam's own core choice",
|
||||
"Pins Steam, its UI helper, and gamescope to the cores below. Reapplied every few seconds.",
|
||||
host, "override", stringResource(R.string.perf_override),
|
||||
stringResource(R.string.perf_override_hint),
|
||||
clientOverride, onChange = onClientOverride,
|
||||
)
|
||||
MultiRow(
|
||||
host, "clientCores", "Client cores", if (clientOverride) "Choose cores for Steam." else "Enable the override to choose.",
|
||||
host, "clientCores", stringResource(R.string.perf_client_cores), if (clientOverride) stringResource(R.string.perf_client_cores_on) else stringResource(R.string.perf_client_cores_off),
|
||||
coreItems, clientCores, enabled = clientOverride, onToggle = onClientCore,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Game cores") {
|
||||
SettingsGroup(stringResource(R.string.perf_game_cores_group)) {
|
||||
MultiRow(
|
||||
host, "gameCores", "Game cores",
|
||||
"Choose cores for games. All selected lets Android schedule across every core.",
|
||||
host, "gameCores", stringResource(R.string.perf_game_cores),
|
||||
stringResource(R.string.perf_game_cores_hint),
|
||||
coreItems, gameCores, onToggle = onGameCore,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Client interface") {
|
||||
SettingsGroup(stringResource(R.string.perf_client_ui)) {
|
||||
ToggleRow(
|
||||
host, "glthread", "Threaded GL",
|
||||
"May improve Steam menu responsiveness. Device impact is unverified.",
|
||||
host, "glthread", stringResource(R.string.perf_glthread),
|
||||
stringResource(R.string.perf_glthread_hint),
|
||||
glThread, onChange = onGlThread,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "zink", "Zink: lazy descriptors",
|
||||
"Lazy, compact descriptors. Recommended for drivers without descriptor buffers.",
|
||||
host, "zink", stringResource(R.string.perf_zink),
|
||||
stringResource(R.string.perf_zink_hint),
|
||||
zinkLazy, onChange = onZinkLazy,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "noglerror", "Skip GL error checks",
|
||||
"Disables per-call GL validation.",
|
||||
host, "noglerror", stringResource(R.string.perf_noglerror),
|
||||
stringResource(R.string.perf_noglerror_hint),
|
||||
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.",
|
||||
host, "gsrealtime", stringResource(R.string.perf_gsrealtime),
|
||||
stringResource(R.string.perf_gsrealtime_hint),
|
||||
gamescopeRealtime, onChange = onGamescopeRealtime,
|
||||
)
|
||||
}
|
||||
SettingsGroup("GPU") {
|
||||
SettingsGroup(stringResource(R.string.perf_gpu)) {
|
||||
ToggleRow(
|
||||
host, "gpuclock", "Hold the GPU at its top clock",
|
||||
"Adreno only. Fewer hitches from the clock ramping up, at the cost of battery and heat. Released when the session ends.",
|
||||
host, "gpuclock", stringResource(R.string.perf_gpuclock),
|
||||
stringResource(R.string.perf_gpuclock_hint),
|
||||
gpuClockPin, onChange = onGpuClockPin,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Session fixes") {
|
||||
SettingsGroup(stringResource(R.string.perf_fixes)) {
|
||||
ToggleRow(
|
||||
host, "sysmem", "Turnip: sysmem rendering",
|
||||
"On by default: faster for the Steam interface and most games. Required on Adreno 710/720/722.",
|
||||
host, "sysmem", stringResource(R.string.perf_sysmem),
|
||||
stringResource(R.string.perf_sysmem_hint),
|
||||
tuSysmem, onChange = onTuSysmem,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "xalia", "Skip Steam's xalia helper",
|
||||
"Disables Proton's gamepad navigation helper, which costs every game CPU time. Turn off only if a game needs it.",
|
||||
host, "xalia", stringResource(R.string.perf_xalia),
|
||||
stringResource(R.string.perf_xalia_hint),
|
||||
noXalia, onChange = onNoXalia,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "seccomp", "Run proot without seccomp",
|
||||
"May fix missing syscall errors on some kernels, but can reduce performance.",
|
||||
host, "seccomp", stringResource(R.string.perf_seccomp),
|
||||
stringResource(R.string.perf_seccomp_hint),
|
||||
prootNoSeccomp, onChange = onProotNoSeccomp,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "fastpath", stringResource(R.string.perf_fastpath),
|
||||
if (prootNoSeccomp) stringResource(R.string.perf_fastpath_needs_seccomp)
|
||||
else stringResource(R.string.perf_fastpath_hint),
|
||||
prootFastPath && !prootNoSeccomp, enabled = !prootNoSeccomp, onChange = onProotFastPath,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Session identity") {
|
||||
SettingsGroup(stringResource(R.string.perf_identity)) {
|
||||
var draft by remember(guestHostname) { mutableStateOf(guestHostname) }
|
||||
val valid = SessionPrefs.validGuestHostname(draft) != null
|
||||
SettingsRow(
|
||||
"Host name",
|
||||
if (valid || draft.isBlank()) "What the session and Steam report as this machine's name. Blank restores ${SessionPrefs.DEFAULT_GUEST_HOSTNAME}."
|
||||
else "Letters, digits and inner hyphens only, up to 63 characters. Not saved until it is valid.",
|
||||
stringResource(R.string.perf_hostname),
|
||||
if (valid || draft.isBlank()) stringResource(R.string.perf_hostname_hint, SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
else stringResource(R.string.perf_hostname_invalid),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
draft, { v ->
|
||||
@@ -153,7 +163,7 @@ fun PerformancePage(
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(colors.error.copy(alpha = 0.08f))
|
||||
.border(1.dp, colors.error.copy(alpha = 0.4f), RoundedCornerShape(12.dp)).padding(12.dp),
|
||||
) {
|
||||
Text("Android is set to kill this session", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
Text(stringResource(R.string.perf_phantom_warning), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
Text(phantomWarning, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -64,10 +66,9 @@ fun PhantomProcessGatePage(
|
||||
val compact = maxWidth < 620.dp && maxHeight < 500.dp
|
||||
SettingsPage(
|
||||
host = rememberMenuHost(),
|
||||
title = "One Android setting to change",
|
||||
eyebrow = "Steam",
|
||||
lede = if (compact) null else "Android stops apps that start lots of background processes, and Steam starts dozens. " +
|
||||
"Until this is changed, games close with no error.",
|
||||
title = stringResource(R.string.gate_title),
|
||||
eyebrow = stringResource(R.string.gate_eyebrow),
|
||||
lede = if (compact) null else stringResource(R.string.gate_lede),
|
||||
onBack = onDismiss,
|
||||
scrollContent = true,
|
||||
compactLayout = compact,
|
||||
@@ -123,7 +124,7 @@ private fun FixGroup(
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val pairing = stage is Stage.Waiting || stage is Stage.CodeNeeded || stage is Stage.Working
|
||||
SettingsGroup(if (hasToggle) "Developer options" else "Fix it for me", compact = compact) {
|
||||
SettingsGroup(if (hasToggle) stringResource(R.string.setup_dev_options) else stringResource(R.string.gate_fix_for_me), compact = compact) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -131,14 +132,14 @@ private fun FixGroup(
|
||||
when {
|
||||
pairing -> PairingProgress(stage, onCancel)
|
||||
hasToggle -> {
|
||||
Body("Turn on “Disable child process restrictions”, then come back. Keep Developer options on afterwards: turning them off turns the switch back off.")
|
||||
PrimaryButton("Open Developer options", compact = compact, onClick = onOpenDeveloperOptions)
|
||||
Body(stringResource(R.string.gate_toggle_steps))
|
||||
PrimaryButton(stringResource(R.string.gate_open_dev), compact = compact, onClick = onOpenDeveloperOptions)
|
||||
}
|
||||
else -> {
|
||||
Body("This Android version has no switch for it, so DroidDeck changes it over Wireless debugging on this device. About a minute, no computer needed.")
|
||||
Body(stringResource(R.string.gate_no_toggle))
|
||||
(stage as? Stage.Failed)?.let { Body(it.error, error = true) }
|
||||
PrimaryButton(
|
||||
if (stage is Stage.Failed) "Try again" else "Fix it for me",
|
||||
if (stage is Stage.Failed) stringResource(R.string.store_try_again) else stringResource(R.string.gate_fix_for_me),
|
||||
compact = compact,
|
||||
enabled = environment.onWifi && environment.notifications,
|
||||
onClick = onFixWithWirelessDebugging,
|
||||
@@ -147,11 +148,11 @@ private fun FixGroup(
|
||||
}
|
||||
if (!hasToggle && !pairing) {
|
||||
if (!environment.onWifi) {
|
||||
Body("Wireless debugging needs Wi-Fi. Connect to any network first; it doesn't need internet.", warn = true)
|
||||
Body(stringResource(R.string.gate_needs_wifi), warn = true)
|
||||
}
|
||||
if (!environment.notifications) {
|
||||
Body("The pairing code is entered in a notification. Allow notifications for DroidDeck, or use Other ways.", warn = true)
|
||||
SecondaryButton("Allow notifications", compact = compact, onClick = onOpenNotificationSettings)
|
||||
Body(stringResource(R.string.gate_needs_notifications), warn = true)
|
||||
SecondaryButton(stringResource(R.string.gate_allow_notifications), compact = compact, onClick = onOpenNotificationSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,9 +162,9 @@ private fun FixGroup(
|
||||
@Composable
|
||||
private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
|
||||
val steps = listOf(
|
||||
"Open Settings → Developer options → Wireless debugging and turn it on",
|
||||
"Tap “Pair device with pairing code”",
|
||||
"Type the code into the DroidDeck notification",
|
||||
stringResource(R.string.gate_step1),
|
||||
stringResource(R.string.gate_step2),
|
||||
stringResource(R.string.gate_step3),
|
||||
)
|
||||
val active = when (stage) {
|
||||
Stage.Waiting -> 1
|
||||
@@ -184,8 +185,8 @@ private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
when (stage) {
|
||||
Stage.Waiting -> "Waiting for the pairing pop-up…"
|
||||
is Stage.CodeNeeded -> stage.error ?: "Pop-up found. Enter its code in the notification."
|
||||
Stage.Waiting -> stringResource(R.string.gate_waiting)
|
||||
is Stage.CodeNeeded -> stage.error ?: stringResource(R.string.gate_code_needed)
|
||||
is Stage.Working -> stage.step
|
||||
else -> ""
|
||||
},
|
||||
@@ -193,16 +194,16 @@ private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
|
||||
color = if (stage is Stage.CodeNeeded && stage.error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton("Cancel", compact = true, onClick = onCancel)
|
||||
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onCancel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
|
||||
SettingsGroup("Status", compact = compact) {
|
||||
SettingsGroup(stringResource(R.string.gate_status), compact = compact) {
|
||||
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
|
||||
Body("Checking every 2 seconds. This page closes when it's off.")
|
||||
Body(stringResource(R.string.gate_polling))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,12 +218,12 @@ private fun OtherWays(
|
||||
onCopyCommand: () -> Unit,
|
||||
) {
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
SettingsGroup("Other ways", compact = compact) {
|
||||
SettingsGroup(stringResource(R.string.gate_other_ways), compact = compact) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Body(if (hasToggle) "Wireless debugging, manual address, computer ADB" else "Manual address, computer ADB, Developer options", modifier = Modifier.weight(1f))
|
||||
Body(if (hasToggle) stringResource(R.string.gate_other_toggle) else stringResource(R.string.gate_other_no_toggle), modifier = Modifier.weight(1f))
|
||||
Text(if (open) "▴" else "▾", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (open) Column(
|
||||
@@ -230,20 +231,20 @@ private fun OtherWays(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (hasToggle) {
|
||||
SecondaryButton("Use Wireless debugging instead", compact = true, onClick = onFixWithWirelessDebugging)
|
||||
SecondaryButton(stringResource(R.string.gate_use_wireless_instead), compact = true, onClick = onFixWithWirelessDebugging)
|
||||
} else {
|
||||
Body("Some ROMs add the switch anyway. Look for “child process” in Developer options.")
|
||||
SecondaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
|
||||
Body(stringResource(R.string.gate_some_roms))
|
||||
SecondaryButton(stringResource(R.string.gate_open_dev), compact = true, onClick = onOpenDeveloperOptions)
|
||||
}
|
||||
Body("If the notification doesn't work, enter the pop-up's address and code yourself.")
|
||||
SecondaryButton("Enter address manually", compact = true, onClick = onEnterAddressManually)
|
||||
Body("From a computer with ADB:")
|
||||
Body(stringResource(R.string.gate_manual_hint))
|
||||
SecondaryButton(stringResource(R.string.gate_enter_manually), compact = true, onClick = onEnterAddressManually)
|
||||
Body(stringResource(R.string.gate_from_computer))
|
||||
Text(
|
||||
PhantomProcessLimit.adbCommand(),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SecondaryButton("Copy command", compact = true, onClick = onCopyCommand)
|
||||
SecondaryButton(stringResource(R.string.gate_copy_command), compact = true, onClick = onCopyCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,16 +272,16 @@ fun DeveloperDisplayChoiceDialog(
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Open Developer options") },
|
||||
title = { Text(stringResource(R.string.gate_open_dev)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Choose which display to open Android Settings on.")
|
||||
TextButton(modifier = Modifier.fillMaxWidth(), onClick = onMainScreen) { Text("Main screen") }
|
||||
Text(stringResource(R.string.gate_choose_display))
|
||||
TextButton(modifier = Modifier.fillMaxWidth(), onClick = onMainScreen) { Text(stringResource(R.string.gate_main_screen)) }
|
||||
displays.forEach { (id, label) ->
|
||||
TextButton(modifier = Modifier.fillMaxWidth(), onClick = { onSecondaryScreen(id) }) { Text(label) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -33,43 +35,43 @@ fun ProtonPage(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
SettingsPage(
|
||||
host,
|
||||
title = "Proton versions",
|
||||
eyebrow = "Setup",
|
||||
lede = "Download and install an ARM64 Proton build now, then select it per game in Steam > Properties > Compatibility.",
|
||||
title = stringResource(R.string.setup_tool_protons),
|
||||
eyebrow = stringResource(R.string.setup_title),
|
||||
lede = stringResource(R.string.proton_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
if (!runtimeReady) Text(
|
||||
"Install the Linux runtime from Setup before managing compatibility tools.",
|
||||
stringResource(R.string.content_needs_runtime),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
if (sessionRunning) Text(
|
||||
"Stop the active session before installing or removing a compatibility tool.",
|
||||
stringResource(R.string.proton_stop_first),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
SettingsGroup("Available builds") {
|
||||
SettingsGroup(stringResource(R.string.proton_available)) {
|
||||
for (row in rows) {
|
||||
SettingsRow(
|
||||
row.name,
|
||||
when {
|
||||
row.installed != null -> "Installed ${row.installed}"
|
||||
row.queued -> "Pending from an earlier request"
|
||||
else -> "Not installed"
|
||||
row.installed != null -> stringResource(R.string.proton_installed, row.installed)
|
||||
row.queued -> stringResource(R.string.proton_queued)
|
||||
else -> stringResource(R.string.proton_not_installed)
|
||||
},
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
when {
|
||||
row.installed != null -> SecondaryButton("Remove", enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
|
||||
busyId != null -> SecondaryButton(if (busyId == row.id) "Installing…" else "Install", enabled = false) {}
|
||||
else -> SecondaryButton("Install now", enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
|
||||
row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
|
||||
busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {}
|
||||
else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
|
||||
}
|
||||
if (row.queued && row.installed == null && busyId == null) {
|
||||
SecondaryButton("Cancel queued", enabled = !sessionRunning) { onCancel(row.id) }
|
||||
SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
if (stage != null && percent >= 0) "$stage · $percent%" else stage ?: "Starting…",
|
||||
if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
|
||||
)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -15,31 +17,31 @@ import androidx.compose.ui.unit.dp
|
||||
fun RomsDialog(path: String?, onChoose: () -> Unit, onClear: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("ROMs folder") },
|
||||
title = { Text(stringResource(R.string.setup_tool_roms)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"This folder appears as /root/ROMs in each session. Internal storage is available at /root/Storage.",
|
||||
stringResource(R.string.roms_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
path ?: "No folder chosen",
|
||||
path ?: stringResource(R.string.roms_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (path != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Applies next session. SD cards are supported.",
|
||||
stringResource(R.string.roms_applies),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onChoose) { Text(if (path == null) "Choose folder" else "Change folder") } },
|
||||
confirmButton = { TextButton(onClick = onChoose) { Text(if (path == null) stringResource(R.string.roms_choose) else stringResource(R.string.roms_change)) } },
|
||||
dismissButton = {
|
||||
if (path != null) TextButton(onClick = onClear) { Text("Forget") }
|
||||
else TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
if (path != null) TextButton(onClick = onClear) { Text(stringResource(R.string.common_forget)) }
|
||||
else TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import android.app.Presentation
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -128,37 +129,37 @@ class SecondScreenPresentation(
|
||||
}
|
||||
controls.addView(Button(context).apply {
|
||||
text = "×"
|
||||
contentDescription = "Close second-screen controls"
|
||||
contentDescription = context.getString(R.string.deck_close)
|
||||
minWidth = dp(48)
|
||||
setOnClickListener { onClose() }
|
||||
})
|
||||
controls.addView(TextView(context).apply {
|
||||
text = "Trackpad"
|
||||
text = context.getString(R.string.second_trackpad)
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
}, LinearLayout.LayoutParams(0, -2, 1f))
|
||||
val keyboardButton = Button(context).apply {
|
||||
text = "Show keyboard"
|
||||
text = context.getString(R.string.second_show_keyboard)
|
||||
setOnClickListener {
|
||||
val host = keyboardHost ?: return@setOnClickListener
|
||||
if (host.shown) {
|
||||
host.hide()
|
||||
text = "Show keyboard"
|
||||
text = context.getString(R.string.second_show_keyboard)
|
||||
} else {
|
||||
host.show()
|
||||
text = "Hide keyboard"
|
||||
text = context.getString(R.string.second_hide_keyboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
controls.addView(keyboardButton)
|
||||
controls.addView(Button(context).apply {
|
||||
text = "STEAM"
|
||||
contentDescription = "Open Steam menu"
|
||||
contentDescription = context.getString(R.string.deck_open_steam_menu)
|
||||
setOnClickListener { onSteamMenu() }
|
||||
})
|
||||
controls.addView(Button(context).apply {
|
||||
text = "…"
|
||||
contentDescription = "Open Quick Access Menu"
|
||||
contentDescription = context.getString(R.string.drawer_open_qam)
|
||||
setOnClickListener { onQam() }
|
||||
})
|
||||
panel.addView(controls, FrameLayout.LayoutParams(-1, -2, Gravity.TOP))
|
||||
@@ -179,17 +180,17 @@ class SecondScreenPresentation(
|
||||
}
|
||||
bar.addView(Button(context).apply {
|
||||
text = "×"
|
||||
contentDescription = "Close second-screen controls"
|
||||
contentDescription = context.getString(R.string.deck_close)
|
||||
minWidth = dp(48)
|
||||
setOnClickListener { onClose() }
|
||||
})
|
||||
bar.addView(TextView(context).apply {
|
||||
text = "Linux terminal"
|
||||
text = context.getString(R.string.second_terminal)
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
}, LinearLayout.LayoutParams(0, -2, 1f))
|
||||
bar.addView(Button(context).apply {
|
||||
text = "Keyboard"
|
||||
text = context.getString(R.string.drawer_keyboard)
|
||||
setOnClickListener { showTerminalKeyboard() }
|
||||
})
|
||||
root.addView(bar, LinearLayout.LayoutParams(-1, -2))
|
||||
@@ -205,7 +206,7 @@ class SecondScreenPresentation(
|
||||
val session = SessionTerminal.create(terminalClient)
|
||||
if (session == null) {
|
||||
root.addView(TextView(context).apply {
|
||||
text = "The Linux session is not ready for a terminal yet."
|
||||
text = context.getString(R.string.second_terminal_not_ready)
|
||||
setTextColor(Color.WHITE)
|
||||
gravity = Gravity.CENTER
|
||||
}, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
@@ -89,17 +91,17 @@ fun HudText(text: String) {
|
||||
}
|
||||
|
||||
/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */
|
||||
private class LoadStage(val label: String, val detail: String)
|
||||
private class LoadStage(@androidx.annotation.StringRes val label: Int, @androidx.annotation.StringRes val detail: Int)
|
||||
|
||||
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
|
||||
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
|
||||
LoadStage("Session", "Starting the display and audio"),
|
||||
LoadStage("Steam client", "Checking the Steam client and its compatibility tools"),
|
||||
LoadStage("Opening Steam", "Waiting for Steam's first frame"),
|
||||
LoadStage(R.string.load_runtime, R.string.load_runtime_detail),
|
||||
LoadStage(R.string.load_session, R.string.load_session_detail),
|
||||
LoadStage(R.string.load_steam, R.string.load_steam_detail),
|
||||
LoadStage(R.string.load_opening_steam, R.string.load_opening_steam_detail),
|
||||
) else listOf(
|
||||
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
|
||||
LoadStage("Desktop", "Getting the desktop ready"),
|
||||
LoadStage("Opening", "Waiting for the first frame"),
|
||||
LoadStage(R.string.load_runtime, R.string.load_runtime_detail),
|
||||
LoadStage(R.string.load_desktop, R.string.load_desktop_detail),
|
||||
LoadStage(R.string.load_opening, R.string.load_opening_detail),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -128,7 +130,7 @@ private fun readableStep(step: String): Boolean {
|
||||
@Composable
|
||||
fun LoadingOverlay(
|
||||
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
|
||||
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
|
||||
title: String = stringResource(R.string.load_starting_steam), steam: Boolean = true, onCancel: (() -> Unit)? = null,
|
||||
/** An ended session's exit status and log path, shown small under the advice. */
|
||||
endedDetail: String? = null,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
@@ -147,7 +149,7 @@ fun LoadingOverlay(
|
||||
if (at > reached) reached = at
|
||||
}
|
||||
val active = reached.coerceAtMost(stages.lastIndex)
|
||||
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail
|
||||
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
|
||||
val context = LocalContext.current
|
||||
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
|
||||
val games by produceState(emptyList<Library.SteamGame>()) {
|
||||
@@ -180,13 +182,13 @@ fun LoadingOverlay(
|
||||
// Held on the plain D while the flood gathers, and started fresh when it lands.
|
||||
key(flood == null) {
|
||||
LogoThrobber(
|
||||
Modifier.width(if (ended) 88.dp else 110.dp).onGloballyPositioned { ball = throbberBall(it.boundsInRoot()) },
|
||||
Modifier.width(if (ended) 88.dp else 110.dp).onGloballyPositioned { ball = throbberBall(it.boundsInRoot()).also { b -> ThrobberSpot.ball = b } },
|
||||
running = !ended && flood == null,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(30.dp))
|
||||
Text(
|
||||
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
|
||||
if (ended) stringResource(R.string.load_ended) else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
@@ -223,9 +225,9 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 22.dp)) {
|
||||
var firstUsed = false
|
||||
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
|
||||
if (onRetry != null) PrimaryButton("Try again", modifier = claim(), onClick = onRetry)
|
||||
if (onShareLogs != null) SecondaryButton("Share logs", modifier = claim(), onClick = onShareLogs)
|
||||
if (onClose != null) SecondaryButton("Back", modifier = claim(), onClick = onClose)
|
||||
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
|
||||
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), onClick = onShareLogs)
|
||||
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,11 +237,12 @@ private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifi
|
||||
val pal = LocalPalette.current
|
||||
val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f)
|
||||
val run = rememberInfiniteTransition(label = "segment")
|
||||
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(Motion.ms(1300).coerceAtLeast(1), easing = FastOutSlowInEasing)), label = "run")
|
||||
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
|
||||
val still = Motion.scale == 0f
|
||||
val stepDescription = stringResource(R.string.load_step, active + 1, count)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
|
||||
modifier = modifier.semantics { contentDescription = stepDescription },
|
||||
) {
|
||||
repeat(count) { i ->
|
||||
Box(
|
||||
@@ -272,13 +275,13 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
|
||||
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
|
||||
}
|
||||
Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** A session paused in the background, ready to pick up where it left off. */
|
||||
@Composable
|
||||
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
|
||||
fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paused), onResume: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
@@ -304,7 +307,7 @@ fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit
|
||||
}
|
||||
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
|
||||
PrimaryButton(
|
||||
"Resume",
|
||||
stringResource(R.string.resume_session),
|
||||
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
|
||||
onClick = onResume,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -93,6 +92,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -124,7 +124,7 @@ import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings")
|
||||
private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings)
|
||||
|
||||
/** One icon per drawer page, in page order (QAM-style tabs). */
|
||||
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
|
||||
@@ -146,7 +146,8 @@ private class DrawerFocus {
|
||||
} else if (focused == key) focused = null
|
||||
}
|
||||
|
||||
fun target(page: Int) = last[page] ?: drawerPageEntries[page]
|
||||
/** Where focus was last on [page], if anywhere. */
|
||||
fun target(page: Int) = last[page]
|
||||
fun request(key: String) = runCatching { requester(key).requestFocus() }
|
||||
fun forget(page: Int) { last[page] = null }
|
||||
}
|
||||
@@ -233,6 +234,9 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
LaunchedEffect(open, controllerActive) {
|
||||
if (open && !controllerActive) focusManager.clearFocus(force = true)
|
||||
}
|
||||
// The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first
|
||||
// option; anything else (a menu closing, the drawer opening again) goes back where it was.
|
||||
var focusedPage by remember { mutableIntStateOf(page) }
|
||||
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
|
||||
if (!open) {
|
||||
host.open = null
|
||||
@@ -240,14 +244,19 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
} else if (controllerActive) {
|
||||
inputModeManager.requestInputMode(InputMode.Keyboard)
|
||||
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
|
||||
val target = focus.target(page)
|
||||
val turned = page != focusedPage
|
||||
focusedPage = page
|
||||
// The page's top control: on Settings that is Android apps, when it is shown.
|
||||
val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page]
|
||||
if (turned) focus.forget(page)
|
||||
val target = focus.target(page) ?: first
|
||||
repeat(24) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
focus.request(target)
|
||||
if (focus.focused == target) return@LaunchedEffect
|
||||
}
|
||||
focus.forget(page)
|
||||
focus.request(drawerPageEntries[page])
|
||||
focus.request(first)
|
||||
}
|
||||
}
|
||||
if (open || veil > 0.01f) Box(
|
||||
@@ -277,7 +286,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
.padding(if (short) 12.dp else 16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) {
|
||||
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold,
|
||||
Text(a.title ?: if (a.steam) stringResource(R.string.drawer_steam) else stringResource(R.string.drawer_desktop), fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold,
|
||||
color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true }
|
||||
}
|
||||
@@ -297,13 +306,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = if (short) 8.dp else 12.dp),
|
||||
) {
|
||||
QuickAction("Steam menu", Icons.Outlined.Menu, Modifier.weight(1f).then(focus.track(page, "steam")), compact = short) {
|
||||
QuickAction(stringResource(R.string.drawer_steam_menu), Icons.Outlined.Menu, Modifier.weight(1f).then(focus.track(page, "steam")), compact = short) {
|
||||
host.open = null
|
||||
a.onSteamMenu.invoke()
|
||||
}
|
||||
QuickAction(
|
||||
"Quick access", Icons.Outlined.MoreHoriz,
|
||||
Modifier.weight(1f).then(focus.track(page, "qam")).semantics { contentDescription = "Open Quick Access Menu" },
|
||||
stringResource(R.string.drawer_quick_access), Icons.Outlined.MoreHoriz,
|
||||
stringResource(R.string.drawer_open_qam).let { d -> Modifier.weight(1f).then(focus.track(page, "qam")).semantics { contentDescription = d } },
|
||||
compact = short,
|
||||
interactionSource = qamInteraction,
|
||||
onConfirm = { host.open = null; a.onQam.invoke() },
|
||||
@@ -315,7 +324,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
// Big Picture's own "Switch to Desktop" waits on SteamOS Manager for ever
|
||||
// here; this does the switch without the client.
|
||||
if (a.onSwitchToDesktop != null) {
|
||||
QuickAction("Desktop", Icons.Outlined.DesktopWindows, Modifier.weight(1f).then(focus.track(page, "desktop")), compact = short) {
|
||||
QuickAction(stringResource(R.string.drawer_desktop), Icons.Outlined.DesktopWindows, Modifier.weight(1f).then(focus.track(page, "desktop")), compact = short) {
|
||||
host.open = null
|
||||
a.onSwitchToDesktop.invoke()
|
||||
}
|
||||
@@ -327,11 +336,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
|
||||
) {
|
||||
DrawerBumper("LB", "Previous page", modifier = focus.track(page, "prev")) {
|
||||
DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) {
|
||||
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
|
||||
}
|
||||
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
|
||||
DrawerBumper("RB", "Next page", modifier = focus.track(page, "next")) {
|
||||
DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) {
|
||||
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
|
||||
}
|
||||
}
|
||||
@@ -339,11 +348,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
|
||||
) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) {
|
||||
when (page) {
|
||||
0 -> SettingsGroup("Display") {
|
||||
ToggleRow(host, "hud", "Performance HUD", null, a.hudOn,
|
||||
0 -> SettingsGroup(stringResource(R.string.drawer_page_display)) {
|
||||
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
|
||||
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
|
||||
if (a.fillScreen != null) ToggleRow(
|
||||
host, "fill", "Stretch games to fill", null, a.fillScreen,
|
||||
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
|
||||
)
|
||||
val fgOpen = host.open == "fg"
|
||||
@@ -357,49 +366,49 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
|
||||
1 -> {
|
||||
SettingsGroup("Controls") {
|
||||
ChoiceRow(host, "touch", "Touch", null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"),
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controls)) {
|
||||
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
ChoiceRow(host, "osc", "On-screen controls", null,
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never")
|
||||
else listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"),
|
||||
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
|
||||
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
|
||||
if (a.steam) ChoiceRow(host, "back-actions", "Back", null,
|
||||
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
|
||||
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
|
||||
}
|
||||
SettingsGroup("Keyboard") {
|
||||
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
DrawerOutlineButton("PC keyboard", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_pc_keyboard), modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) {
|
||||
host.open = null; a.onHardwareKeyboard()
|
||||
}
|
||||
DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "android"))) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_android_keyboard), modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "android"))) {
|
||||
host.open = null; a.onKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
|
||||
ChoiceRow(host, "second-screen-mode", "Shows", null,
|
||||
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) {
|
||||
ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null,
|
||||
(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,
|
||||
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
|
||||
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
|
||||
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (a.isHomeApp) SettingsGroup("Android apps") {
|
||||
SettingsRow("Launch an app", null) {
|
||||
DrawerOutlineButton(if (androidAppsExpanded) "Hide" else "Show", modifier = focus.track(page, "apps")) {
|
||||
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
|
||||
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
|
||||
DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) {
|
||||
androidAppsExpanded = !androidAppsExpanded
|
||||
}
|
||||
}
|
||||
if (androidAppsExpanded) {
|
||||
if (a.androidApps.isEmpty()) {
|
||||
Text("No launchable apps", fontSize = 12.sp, color = colors.onSurfaceVariant,
|
||||
Text(stringResource(R.string.drawer_no_apps), fontSize = 12.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else for (app in a.androidApps) {
|
||||
MenuItem(
|
||||
@@ -419,23 +428,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup("Session behavior") {
|
||||
SettingsGroup(stringResource(R.string.drawer_session_behavior)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", "Background behavior",
|
||||
"Applies now and to future sessions in this mode.",
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.drawer_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to "Auto",
|
||||
SessionPrefs.SUSPEND_MANUAL to "Manual",
|
||||
SessionPrefs.SUSPEND_NEVER to "Never",
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
),
|
||||
a.suspendPolicy,
|
||||
note = "Auto pauses in the background and resumes when visible. Manual pauses there and waits for Resume. Never keeps the session running.",
|
||||
note = stringResource(R.string.mode_suspend_note),
|
||||
chipModifier = focus.track(page, "suspend"),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Next session") {
|
||||
ChoiceRow(host, "shape", "Screen ratio", null,
|
||||
SettingsGroup(stringResource(R.string.drawer_next_session)) {
|
||||
ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null,
|
||||
SessionPrefs.shapeChoices, a.shapeMode,
|
||||
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
|
||||
}
|
||||
@@ -445,16 +454,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
|
||||
}
|
||||
SettingsGroup("Support") {
|
||||
SettingsRow("Session logs", "Send this session's logs with a bug report") {
|
||||
DrawerOutlineButton("Share logs", modifier = focus.track(page, "share-logs")) {
|
||||
SettingsGroup(stringResource(R.string.drawer_support)) {
|
||||
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
|
||||
host.open = null
|
||||
a.onShareLogs()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
if (!a.isHomeApp) DrawerOutlineButton("Background", modifier = focus.track(page, "background")) {
|
||||
if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) {
|
||||
host.open = null; a.onBackground()
|
||||
}
|
||||
}
|
||||
@@ -519,16 +528,17 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val fill by animateColorAsState(if (hot) colors.error.copy(alpha = 0.18f) else Color.Transparent, Motion.tw(220), label = "dangerFill")
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val stopDescription = stringResource(R.string.stop_session)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" }
|
||||
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }
|
||||
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(start = 12.dp, end = 14.dp),
|
||||
) {
|
||||
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp))
|
||||
Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
Text(stringResource(R.string.drawer_stop), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +613,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
val pal = LocalPalette.current
|
||||
val count = drawerPageIcons.size
|
||||
val middle = (count - 1) / 2f
|
||||
val motion = tween<Float>(durationMillis = 340, easing = FastOutSlowInEasing)
|
||||
val motion = Motion.tw<Float>(340, easing = FastOutSlowInEasing)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
|
||||
@@ -623,7 +633,8 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
|
||||
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
|
||||
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, tween(340), label = "tabTint")
|
||||
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index]))
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
|
||||
val select = { onSelect(index) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -638,7 +649,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
),
|
||||
)
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = "${drawerPageTitles[index]} page" }
|
||||
.semantics { contentDescription = pageDescription }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
@@ -664,8 +675,8 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val snap = a.components
|
||||
if (snap == null) {
|
||||
SettingsGroup("Components") {
|
||||
Text("Reading the Protons…", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
|
||||
SettingsGroup(stringResource(R.string.drawer_page_components)) {
|
||||
Text(stringResource(R.string.comp_reading), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -673,7 +684,7 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
var pick by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
|
||||
// Focusable, so the pad can climb from the Proton box up to it and the page scrolls it into view.
|
||||
SettingsGroup("Running now") {
|
||||
SettingsGroup(stringResource(R.string.drawer_running_now)) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value
|
||||
val pal = LocalPalette.current
|
||||
@@ -684,8 +695,8 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||
) {
|
||||
if (running.isEmpty()) {
|
||||
Text("No game is running on a Proton", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text("Changes apply the next time a game starts.", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
Text(stringResource(R.string.drawer_no_game), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(stringResource(R.string.comp_applies_long), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
} else for (r in running) {
|
||||
Text(r.proton.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
@@ -698,28 +709,29 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
}
|
||||
}
|
||||
if (view == null) {
|
||||
SettingsGroup("Components") {
|
||||
Text("No Proton is installed yet.", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
|
||||
SettingsGroup(stringResource(R.string.drawer_page_components)) {
|
||||
Text(stringResource(R.string.drawer_no_proton), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
|
||||
}
|
||||
return
|
||||
}
|
||||
val p = view.proton
|
||||
SettingsGroup("Components") {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_components)) {
|
||||
DrawerStackedChoice(
|
||||
host, "cmp-proton", "Proton",
|
||||
host, "cmp-proton", stringResource(R.string.comp_proton),
|
||||
listOfNotNull(
|
||||
p.version,
|
||||
"game running".takeIf { view.inUseByGame },
|
||||
view.reappliedAt.takeIf { it > 0 }?.let { "re-applied at launch " + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(it * 1000)) },
|
||||
stringResource(R.string.drawer_game_running).takeIf { view.inUseByGame },
|
||||
view.reappliedAt.takeIf { it > 0 }?.let { stringResource(R.string.drawer_reapplied, java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(it * 1000))) },
|
||||
).joinToString(" · "),
|
||||
snap.protons.map { it.proton.id to it.proton.name }, p.id,
|
||||
chipModifier = track("cmp-proton"), onPick = { pick = it },
|
||||
)
|
||||
val originalLabel = stringResource(R.string.drawer_original)
|
||||
val build = ComponentsManager.safeName(p.version)
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
val st = view.components.getValue(comp)
|
||||
val originals = view.originals.filter { it.comp == comp }
|
||||
val options = originals.map { "orig:${it.protonVersion}" to (if (it.protonVersion == build) "Original" else "Original · ${it.protonVersion}") } +
|
||||
val options = originals.map { "orig:${it.protonVersion}" to (if (it.protonVersion == build) originalLabel else stringResource(R.string.comp_original, it.protonVersion)) } +
|
||||
snap.packages.filter { it.comp == comp }.map { it.file to it.version }
|
||||
if (options.isEmpty()) continue
|
||||
val current = st.activeFile ?: "orig:$build"
|
||||
@@ -727,16 +739,16 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
DrawerStackedChoice(
|
||||
host, "cmp-$comp", ComponentsManager.LABEL.getValue(comp),
|
||||
// The box already shows the choice: only say more when there is more to say.
|
||||
st.queued?.let { "Next: $it · after the game closes" } ?: st.inUse.takeIf { it != shown },
|
||||
st.queued?.let { stringResource(R.string.comp_next_queued, it) } ?: st.inUse.takeIf { it != shown },
|
||||
options, current,
|
||||
note = if (view.inUseByGame) "A game is running on this Proton: the change waits until it closes." else "Applies the next time a game starts.",
|
||||
note = if (view.inUseByGame) stringResource(R.string.comp_waits_long) else stringResource(R.string.comp_applies_long),
|
||||
chipModifier = track("cmp-$comp"),
|
||||
onPick = { v -> if (v != current || st.queued != null) a.onComponentSwap(p.id, comp, v) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Downloads, importing and deleting are on the Components page in the app.",
|
||||
stringResource(R.string.drawer_components_note),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -932,7 +944,7 @@ private fun StopConfirm(
|
||||
.padding(start = 12.dp, end = 14.dp),
|
||||
) {
|
||||
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp))
|
||||
Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
Text(stringResource(R.string.drawer_stop), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
}
|
||||
val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right)
|
||||
Column(
|
||||
@@ -954,7 +966,7 @@ private fun StopConfirm(
|
||||
.padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
stringResource(R.string.drawer_stop_title), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() },
|
||||
)
|
||||
Row(
|
||||
@@ -962,13 +974,13 @@ private fun StopConfirm(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||
) {
|
||||
ConfirmChoice(
|
||||
"Cancel", danger = false, enabled = open,
|
||||
stringResource(R.string.common_cancel), danger = false, enabled = open,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
|
||||
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
|
||||
onClick = onCancel,
|
||||
)
|
||||
ConfirmChoice(
|
||||
"Stop", danger = true, enabled = open,
|
||||
stringResource(R.string.drawer_stop), danger = true, enabled = open,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
|
||||
.onFocusChanged { stopFocused = it.isFocused }
|
||||
.onGloballyPositioned { stopCoords = it },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -130,7 +132,7 @@ internal fun TabStrip(
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier) {
|
||||
BumperKey("LB", "Previous tab") { onSelect((selected + tabs.size - 1) % tabs.size) }
|
||||
BumperKey("LB", stringResource(R.string.widgets_prev_tab)) { onSelect((selected + tabs.size - 1) % tabs.size) }
|
||||
Row(
|
||||
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
|
||||
.horizontalScroll(rememberScrollState()).padding(3.dp),
|
||||
@@ -155,7 +157,7 @@ internal fun TabStrip(
|
||||
)
|
||||
}
|
||||
}
|
||||
BumperKey("RB", "Next tab") { onSelect((selected + 1) % tabs.size) }
|
||||
BumperKey("RB", stringResource(R.string.widgets_next_tab)) { onSelect((selected + 1) % tabs.size) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +403,8 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
|
||||
val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack")
|
||||
val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob")
|
||||
val flip = { onChange(!checked) }
|
||||
val onLabel = stringResource(R.string.widgets_on)
|
||||
val offLabel = stringResource(R.string.widgets_off)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
// 48dp tall to touch; the visible track sits inside it.
|
||||
Box(
|
||||
@@ -411,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.Switch, onClick = flip)
|
||||
.controllerConfirm(enabled = enabled, onClick = flip)
|
||||
.semantics { stateDescription = if (checked) "On" else "Off"; if (label != null) contentDescription = label },
|
||||
.semantics { stateDescription = if (checked) onLabel else offLabel; if (label != null) contentDescription = label },
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track)
|
||||
@@ -435,9 +439,9 @@ fun MultiRow(
|
||||
) {
|
||||
val open = host.open == key
|
||||
val summary = when {
|
||||
selected.size >= items.size -> "All ${items.size}"
|
||||
selected.isEmpty() -> "None"
|
||||
else -> "${selected.size} of ${items.size}"
|
||||
selected.size >= items.size -> stringResource(R.string.widgets_all_n, items.size)
|
||||
selected.isEmpty() -> stringResource(R.string.widgets_none)
|
||||
else -> stringResource(R.string.widgets_n_of_m, selected.size, items.size)
|
||||
}
|
||||
SettingsRow(label, hint, highlighted = open) {
|
||||
Box {
|
||||
@@ -490,7 +494,7 @@ fun SettingsPage(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
) {
|
||||
BackLink("Back", compact = true, onClick = onBack)
|
||||
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
|
||||
Text(
|
||||
title, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
|
||||
@@ -502,7 +506,7 @@ fun SettingsPage(
|
||||
} else {
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BackLink("Back", compact = compactLayout, onClick = onBack)
|
||||
BackLink(stringResource(R.string.mode_back), compact = compactLayout, onClick = onBack)
|
||||
if (eyebrow != null) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Eyebrow(eyebrow)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -57,7 +59,9 @@ private const val WALL_MIN_GAMES = 4
|
||||
*/
|
||||
@Composable
|
||||
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
|
||||
// A fixed order, ties included: the wall places each game by its position, so an order that
|
||||
// came out differently from one read of the library to the next moved every capsule.
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
|
||||
val narrow = LocalNarrowPane.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -94,7 +98,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Steam", fontSize = if (narrow) 34.sp else 44.sp, lineHeight = if (narrow) 40.sp else 50.sp,
|
||||
stringResource(R.string.rail_steam), fontSize = if (narrow) 34.sp else 44.sp, lineHeight = if (narrow) 40.sp else 50.sp,
|
||||
fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1,
|
||||
)
|
||||
// Said only when something stands between Play and Steam; a ready runtime needs no words.
|
||||
@@ -103,7 +107,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// Enabled without a runtime: the session's loading screen installs it first.
|
||||
PrimaryButton("Play Steam", enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay)
|
||||
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay)
|
||||
Cog(size = 54.dp, onClick = a.onSteamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -64,7 +66,7 @@ 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")
|
||||
private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed)
|
||||
|
||||
@Composable
|
||||
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
@@ -112,19 +114,19 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
return@Column
|
||||
}
|
||||
Rise(0) {
|
||||
PageHeader("Store") {
|
||||
Chip("Flathub · ARM64", ok = false)
|
||||
if (StoreState.ready) Chip("● Flatpak ready", ok = true)
|
||||
PageHeader(stringResource(R.string.store_title)) {
|
||||
Chip(stringResource(R.string.store_chip_flathub), ok = false)
|
||||
if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true)
|
||||
}
|
||||
}
|
||||
if (!s.ready) {
|
||||
Rise(1) { Note("Install the Linux runtime from Setup first; the store installs apps into it.") }
|
||||
Rise(1) { Note(stringResource(R.string.store_needs_runtime)) }
|
||||
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 },
|
||||
TABS.mapIndexed { i, id -> stringResource(id).let { t -> if (i == 2 && StoreState.installed.isNotEmpty()) stringResource(R.string.store_tab_count, t, StoreState.installed.size) else t } },
|
||||
tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus,
|
||||
)
|
||||
}
|
||||
@@ -148,14 +150,13 @@ private fun SetupCard() {
|
||||
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(stringResource(R.string.store_setup_title), 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.",
|
||||
stringResource(R.string.store_setup_text),
|
||||
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) }
|
||||
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,11 +167,11 @@ 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('.') }
|
||||
?: when (busy) { "setup" -> stringResource(R.string.store_flatpak); "update-all" -> stringResource(R.string.store_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…"),
|
||||
if (stage != null && StoreState.percent >= 0) stringResource(R.string.store_busy_percent, name, stage, StoreState.percent) else stringResource(R.string.store_busy, name, stage ?: stringResource(R.string.store_starting)),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
@@ -181,15 +182,15 @@ private fun BusyBar() {
|
||||
|
||||
@Composable
|
||||
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
|
||||
Rise(3) { SectionTitle("Categories", null) }
|
||||
Rise(3) { SectionTitle(stringResource(R.string.store_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) } }
|
||||
Rise(4) { Note(stringResource(R.string.store_unreachable)) }
|
||||
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
|
||||
return
|
||||
}
|
||||
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
|
||||
@@ -211,15 +212,15 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
|
||||
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,
|
||||
value = query, onValueChange = onQuery, label = stringResource(R.string.store_search_label), keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done, placeholder = stringResource(R.string.store_search_placeholder), modifier = Modifier.weight(1f), onDone = run,
|
||||
)
|
||||
PrimaryButton("Search", main = true, onClick = run)
|
||||
PrimaryButton(stringResource(R.string.store_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) }
|
||||
PillButton(stringResource(R.string.store_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) }
|
||||
}
|
||||
@@ -227,11 +228,11 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
|
||||
}
|
||||
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.") }
|
||||
StoreState.searching -> Rise(5) { SectionTitle(stringResource(R.string.store_searching), null) }
|
||||
results == null -> Rise(5) { Note(stringResource(R.string.store_search_hint)) }
|
||||
results.isEmpty() -> Rise(5) { Note(stringResource(R.string.store_no_results)) }
|
||||
else -> {
|
||||
Rise(5) { SectionTitle("Results", results.size.toString()) }
|
||||
Rise(5) { SectionTitle(stringResource(R.string.store_results), results.size.toString()) }
|
||||
Rise(6) { AppGrid(results, first = true, onOpen = onOpen) }
|
||||
}
|
||||
}
|
||||
@@ -245,19 +246,19 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
|
||||
val apps = StoreState.installed
|
||||
Rise(3) {
|
||||
Actions {
|
||||
SecondaryButton(if (StoreState.checkingUpdates) "Checking…" else "Check for updates", enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
|
||||
SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_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 (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) {
|
||||
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
|
||||
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), 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.") } }
|
||||
Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) stringResource(R.string.store_no_apps) else stringResource(R.string.store_setup_first_note)) } }
|
||||
return
|
||||
}
|
||||
Rise(4) { SectionTitle("Installed", apps.size.toString()) }
|
||||
Rise(4) { SectionTitle(stringResource(R.string.store_tab_installed), apps.size.toString()) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
apps.forEachIndexed { i, app ->
|
||||
key(app.id) {
|
||||
@@ -270,13 +271,13 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
|
||||
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),
|
||||
(app.summary ?: app.id).let { if (app.id in StoreState.updates) stringResource(R.string.store_update_available_prefix, it) else it },
|
||||
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) }
|
||||
PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
|
||||
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
|
||||
SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,7 +296,7 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
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(0) { BackLink(stringResource(R.string.store_title), 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)
|
||||
@@ -315,35 +316,35 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
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) {
|
||||
PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
|
||||
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
|
||||
SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_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,
|
||||
if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_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)
|
||||
if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_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 (d != null && d.downloadSize > 0) ActionChip(stringResource(R.string.store_download_size, FileUtils.sizeToString(d.downloadSize)), ok = false)
|
||||
if (installed) ActionChip(stringResource(R.string.store_installed_chip), 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…")
|
||||
Note(if (StoreState.detailsFailed[id] == true) stringResource(R.string.store_details_failed) else stringResource(R.string.store_loading))
|
||||
}
|
||||
}
|
||||
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.arches.isNotEmpty() && "aarch64" !in d.arches) Rise(3) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_arm)) } }
|
||||
if (d.screenshots.isNotEmpty()) {
|
||||
Rise(3) { SectionTitle("Screenshots", null) }
|
||||
Rise(3) { SectionTitle(stringResource(R.string.store_screenshots), null) }
|
||||
Rise(3) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
|
||||
d.screenshots.take(6).forEach { shot ->
|
||||
@@ -356,18 +357,18 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
}
|
||||
}
|
||||
if (d.description.isNotEmpty()) {
|
||||
Rise(4) { SectionTitle("About", null) }
|
||||
Rise(4) { SectionTitle(stringResource(R.string.store_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) { SectionTitle(stringResource(R.string.store_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 },
|
||||
stringResource(R.string.store_app_id) to d.id,
|
||||
d.license?.let { stringResource(R.string.store_license) to it },
|
||||
d.runtime?.let { stringResource(R.string.store_runtime) to it.replace("/x86_64/", "/aarch64/") },
|
||||
if (d.installedSize > 0) stringResource(R.string.store_installed_size) to stringResource(R.string.store_installed_size_value, FileUtils.sizeToString(d.installedSize)) else null,
|
||||
d.homepage?.let { stringResource(R.string.store_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))
|
||||
@@ -419,16 +420,16 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo
|
||||
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))
|
||||
if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = stringResource(R.string.store_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)
|
||||
Text(if (installed) stringResource(R.string.store_tab_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) {
|
||||
internal 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))
|
||||
@@ -455,102 +456,3 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
.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)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.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)
|
||||
.glideBorder(hot, Shape14, pal.signal)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -94,18 +97,18 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
val me = remember { AppUpdates.installed() }
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier) {
|
||||
PageHeader("Updates") {
|
||||
PageHeader(stringResource(R.string.upd_title)) {
|
||||
// The build that is running, beside the title where it is always in view.
|
||||
Text(
|
||||
"DroidDeck ${me.version} · ${s.buildLabel}", fontSize = 12.5.sp, color = colors.onSurfaceVariant,
|
||||
stringResource(R.string.upd_build, me.version, s.buildLabel), fontSize = 12.5.sp, color = colors.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp),
|
||||
)
|
||||
val checked = u.catalog?.checkedAt?.takeIf { it > 0 }
|
||||
if (!LocalNarrowPane.current) Text(
|
||||
if (u.checking) "Checking…" else if (checked != null) "Checked ${ago(checked)}" else "Not checked yet",
|
||||
if (u.checking) stringResource(R.string.common_checking) else if (checked != null) stringResource(R.string.upd_checked, ago(checked)) else stringResource(R.string.upd_not_checked),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
ToolIcon(Icons.Outlined.Refresh, "Check for updates", busy = u.checking, enabled = !u.checking && u.stage == null, onClick = ua.onCheck)
|
||||
ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.store_check_updates), busy = u.checking, enabled = !u.checking && u.stage == null, onClick = ua.onCheck)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
|
||||
// Landscape has the width for channels and status side by side; a narrow pane stacks them.
|
||||
@@ -126,15 +129,14 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
}
|
||||
if (u.askPermission) AlertDialog(
|
||||
onDismissRequest = ua.onDismissPermission,
|
||||
title = { Text("Let DroidDeck update itself") },
|
||||
title = { Text(stringResource(R.string.upd_perm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Android asks once before an app may install updates. On the next screen, turn on " +
|
||||
"\"Allow from this source\", then come back - the update carries on by itself.",
|
||||
stringResource(R.string.upd_perm_text),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = ua.onAllowInstalls) { Text("Open settings") } },
|
||||
dismissButton = { TextButton(onClick = ua.onDismissPermission) { Text("Not now") } },
|
||||
confirmButton = { TextButton(onClick = ua.onAllowInstalls) { Text(stringResource(R.string.upd_open_settings)) } },
|
||||
dismissButton = { TextButton(onClick = ua.onDismissPermission) { Text(stringResource(R.string.upd_not_now)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,16 +153,17 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
val installBlock = release?.let { AppUpdates.installBlock(it, me) }
|
||||
class Look(val tint: Color, val status: String, val headline: String, val detail: String?)
|
||||
val look = when {
|
||||
!me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place",
|
||||
"This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.")
|
||||
catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null)
|
||||
offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null)
|
||||
offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null)
|
||||
offer == Offer.AHEAD -> Look(pal.signal, "Ahead of Stable", "You're ahead of Stable",
|
||||
"This build is newer than the last Stable release. You'll move onto Stable with its next one.")
|
||||
offer == Offer.GONE -> Look(AttentionAmber, "Test ended", "This test has ended",
|
||||
"Its fix was merged or dropped. Follow Preview to keep getting the newest fixes.")
|
||||
else -> Look(pal.good, "Up to date", "You have the latest $name", null)
|
||||
!me.updatable -> Look(colors.onSurfaceVariant, stringResource(R.string.upd_signed_status), stringResource(R.string.upd_signed_headline),
|
||||
stringResource(R.string.upd_signed_detail))
|
||||
catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) stringResource(R.string.common_checking) else stringResource(R.string.upd_not_checked), stringResource(R.string.upd_title), null)
|
||||
offered && installBlock != null -> Look(colors.onSurfaceVariant, stringResource(R.string.upd_blocked_status), newBuild(u.follow, release!!), null)
|
||||
offer == Offer.UPDATE -> Look(AttentionAmber, stringResource(R.string.upd_available), newBuild(u.follow, release!!), null)
|
||||
offer == Offer.SWITCH -> Look(pal.signal, stringResource(R.string.upd_switch), newBuild(u.follow, release!!), null)
|
||||
offer == Offer.AHEAD -> Look(pal.signal, stringResource(R.string.upd_ahead_status), stringResource(R.string.upd_ahead_headline),
|
||||
stringResource(R.string.upd_ahead_detail))
|
||||
offer == Offer.GONE -> Look(AttentionAmber, stringResource(R.string.upd_gone_status), stringResource(R.string.upd_gone_headline),
|
||||
stringResource(R.string.upd_gone_detail))
|
||||
else -> Look(pal.good, stringResource(R.string.upd_current_status), stringResource(R.string.upd_current_headline, name), null)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(18.dp),
|
||||
@@ -168,14 +171,14 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(look.tint))
|
||||
Text(look.status, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = look.tint)
|
||||
if (offered) Text("· ${ago(release!!.publishedAt)}", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
if (offered) Text(stringResource(R.string.upd_dot_ago, ago(release!!.publishedAt)), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
Text(look.headline, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
|
||||
if (offered) {
|
||||
ReleaseNotes(changeTitle(release!!.title.ifBlank { release.tag }), release.summary, release.tag)
|
||||
}
|
||||
if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
|
||||
if (installBlock != null && (offered || offer == Offer.AHEAD)) {
|
||||
if (installBlock != null && offered) {
|
||||
Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
|
||||
@@ -184,19 +187,20 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
Box(Modifier.padding(top = 14.dp)) {
|
||||
when {
|
||||
u.stage != null -> Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (u.percent >= 0) "${u.stage}… ${u.percent}%" else "${u.stage}…", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(if (u.percent >= 0) stringResource(R.string.upd_stage_percent, u.stage, u.percent) else stringResource(R.string.upd_stage, u.stage), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
if (u.percent >= 0) LinearProgressIndicator(progress = { u.percent / 100f }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
|
||||
}
|
||||
offer == Offer.UPDATE -> PrimaryButton("Update$button", enabled = installable, main = true) { ua.onInstall(release!!) }
|
||||
offer == Offer.SWITCH -> PrimaryButton("Install$button", enabled = installable, main = true) { ua.onInstall(release!!) }
|
||||
offered && installBlock != null -> Unit
|
||||
offer == Offer.UPDATE -> PrimaryButton(stringResource(R.string.upd_update_button, button), enabled = installable, main = true) { ua.onInstall(release!!) }
|
||||
offer == Offer.SWITCH -> PrimaryButton(stringResource(R.string.upd_install_button, button), enabled = installable, main = true) { ua.onInstall(release!!) }
|
||||
offer == Offer.AHEAD && installBlock == null ->
|
||||
SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) }
|
||||
offer == Offer.GONE -> PrimaryButton("Follow Preview", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
|
||||
SecondaryButton(stringResource(R.string.upd_install_anyway, release?.version.orEmpty()), enabled = installable) { ua.onInstall(release!!) }
|
||||
offer == Offer.GONE -> PrimaryButton(stringResource(R.string.upd_follow_preview), main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
|
||||
}
|
||||
}
|
||||
if (s.sessionRunning && (offered || offer == Offer.AHEAD)) {
|
||||
Text("Stop the running session to update.", fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
Text(stringResource(R.string.upd_stop_session), fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +225,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
|
||||
val hot = rememberHot(src)
|
||||
val toggle = { open = !open }
|
||||
Text(
|
||||
if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
if (open) stringResource(R.string.upd_less) else stringResource(R.string.upd_more), fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more")
|
||||
.clip(Shape12).glideBorder(hot, Shape12, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
|
||||
@@ -232,23 +236,25 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
|
||||
}
|
||||
|
||||
/** The headline for a build on offer: "DroidDeck 0.3.0", "New Preview build", "PR #93 test build". */
|
||||
@Composable
|
||||
private fun newBuild(f: Follow, r: Release) = when (f.channel) {
|
||||
Channel.STABLE -> "DroidDeck ${r.version ?: r.tag}"
|
||||
Channel.NIGHTLY -> "New Preview build"
|
||||
Channel.TEST -> "PR #${r.pr} test build"
|
||||
Channel.STABLE -> stringResource(R.string.upd_new_stable, r.version ?: r.tag)
|
||||
Channel.NIGHTLY -> stringResource(R.string.upd_new_preview)
|
||||
Channel.TEST -> stringResource(R.string.upd_new_test, r.pr)
|
||||
}
|
||||
|
||||
/** A PR title as a sentence: "fix(steam): keep the client alive" -> "Keep the client alive". */
|
||||
private fun changeTitle(t: String): String =
|
||||
t.replace(Regex("""^[a-z]+(\([^)]*\))?!?:\s*"""), "").replaceFirstChar { it.uppercase() }
|
||||
|
||||
private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB"
|
||||
@Composable
|
||||
private fun megabytes(bytes: Long) = stringResource(R.string.upd_mb, ((bytes + 524_288) / 1_048_576).toInt())
|
||||
|
||||
/** The three channels as cards to pick from; Test builds opens its list of PRs under it. */
|
||||
@Composable
|
||||
private fun ChannelLabel() {
|
||||
Text(
|
||||
"UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
stringResource(R.string.upd_channel), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -260,19 +266,19 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
|
||||
val tests = catalog?.tests.orEmpty()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ChannelCard(
|
||||
Icons.Outlined.Verified, "Stable", "Tested releases, for most people",
|
||||
catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE,
|
||||
Icons.Outlined.Verified, stringResource(R.string.upd_stable), stringResource(R.string.upd_stable_hint),
|
||||
catalog?.stable?.let { stringResource(R.string.upd_version_ago, it.version ?: it.tag, ago(it.publishedAt)) }, u.follow.channel == Channel.STABLE,
|
||||
) { ua.onFollow(Follow(Channel.STABLE)) }
|
||||
ChannelCard(
|
||||
Icons.Outlined.Bolt, "Preview", "Newest main-branch fixes, before Stable",
|
||||
Icons.Outlined.Bolt, stringResource(R.string.upd_preview), stringResource(R.string.upd_preview_hint),
|
||||
catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
|
||||
) { ua.onFollow(Follow(Channel.NIGHTLY)) }
|
||||
ChannelCard(
|
||||
Icons.Outlined.Science, "Test builds", "Try a fix before it's released",
|
||||
when (tests.size) { 0 -> "None right now"; 1 -> "1 to try"; else -> "${tests.size} to try" },
|
||||
Icons.Outlined.Science, stringResource(R.string.upd_tests), stringResource(R.string.upd_tests_hint),
|
||||
if (tests.isEmpty()) stringResource(R.string.upd_tests_none) else pluralStringResource(R.plurals.upd_tests_count, tests.size, tests.size),
|
||||
u.follow.channel == Channel.TEST, enabled = tests.isNotEmpty() || u.follow.channel == Channel.TEST,
|
||||
) { tests.firstOrNull()?.let { ua.onFollow(Follow(Channel.TEST, it.pr)) } }
|
||||
AnimatedVisibility(u.follow.channel == Channel.TEST && tests.isNotEmpty(), enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
|
||||
AnimatedVisibility(u.follow.channel == Channel.TEST && tests.isNotEmpty(), enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
|
||||
Column(Modifier.padding(start = 18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
|
||||
}
|
||||
@@ -289,13 +295,14 @@ private fun ChannelCard(
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
// Focus shows at once, as on the Components page: no ripple, no fade.
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
// Focus shows at once, as on the Components page: no ripple, no fade. Selection is the radio
|
||||
// dot, never a ring of its own: the ring is only ever focus.
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("channel:$title")
|
||||
.clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line)
|
||||
.clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.alpha(if (enabled) 1f else 0.55f)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick)
|
||||
@@ -312,9 +319,16 @@ private fun ChannelCard(
|
||||
}
|
||||
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(11.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
RadioDot(selected)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one mark of a chosen option: a filled dot in a ring, beside it. */
|
||||
@Composable
|
||||
private fun RadioDot(selected: Boolean, size: androidx.compose.ui.unit.Dp = 22.dp) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(size / 2).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,8 +345,8 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
|
||||
modifier = Modifier.fillMaxWidth().paneItem("test:${t.pr}")
|
||||
.heightIn(min = 52.dp)
|
||||
.clip(Shape12)
|
||||
.background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line)
|
||||
.background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent)
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
@@ -340,21 +354,24 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
|
||||
) {
|
||||
Text("#${t.pr}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (selected) pal.signal else colors.onSurfaceVariant)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(t.title.ifBlank { "PR #${t.pr}" }, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (t.apk == null) "Not built for this copy of DroidDeck" else "Updated ${ago(t.publishedAt)}", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
Text(t.title.ifBlank { stringResource(R.string.upd_pr, t.pr) }, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(if (t.apk == null) stringResource(R.string.upd_not_built) else stringResource(R.string.upd_updated, ago(t.publishedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
RadioDot(selected, 20.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun channelName(f: Follow) = when (f.channel) {
|
||||
Channel.STABLE -> "Stable"
|
||||
Channel.NIGHTLY -> "Preview"
|
||||
Channel.TEST -> "the PR #${f.pr} test"
|
||||
Channel.STABLE -> stringResource(R.string.upd_stable)
|
||||
Channel.NIGHTLY -> stringResource(R.string.upd_preview)
|
||||
Channel.TEST -> stringResource(R.string.upd_test_name, f.pr)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ago(millis: Long): String {
|
||||
if (millis <= 0) return "a while ago"
|
||||
if (System.currentTimeMillis() - millis in 0 until 60_000) return "just now"
|
||||
if (millis <= 0) return stringResource(R.string.upd_while_ago)
|
||||
if (System.currentTimeMillis() - millis in 0 until 60_000) return stringResource(R.string.upd_just_now)
|
||||
return android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
millis, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.text.Editable
|
||||
import android.text.InputType
|
||||
import android.text.TextWatcher
|
||||
@@ -89,22 +91,22 @@ fun WirelessAdbFixPage(
|
||||
} else {
|
||||
step = 1
|
||||
busy = true
|
||||
message = "Paired. Finding the Wireless debugging port…"
|
||||
message = context.getString(R.string.wadb_finding_port)
|
||||
onFindConnectPort(endpoint.host) { discoveredPort ->
|
||||
if (discoveredPort == null) {
|
||||
connectionAddress = formatAddressHost(endpoint.host) + ":"
|
||||
busy = false
|
||||
message = "Paired. Enter the port from Settings."
|
||||
message = context.getString(R.string.wadb_enter_port)
|
||||
} else {
|
||||
connectionAddress = formatAdbAddress(endpoint.host, discoveredPort)
|
||||
message = "Device found. Applying the setting…"
|
||||
message = context.getString(R.string.wadb_applying)
|
||||
onApply(endpoint.host, discoveredPort, desiredEnabled) { applyError ->
|
||||
busy = false
|
||||
if (applyError == null) {
|
||||
step = 2
|
||||
message = null
|
||||
} else {
|
||||
message = "Could not apply automatically. Check the address below and retry. $applyError"
|
||||
message = context.getString(R.string.wadb_apply_failed, applyError)
|
||||
messageIsError = true
|
||||
}
|
||||
}
|
||||
@@ -172,22 +174,22 @@ fun WirelessAdbFixPage(
|
||||
SettingsPage(
|
||||
host = rememberMenuHost(),
|
||||
title = when {
|
||||
compactSplit && step < 2 -> "Wireless debugging"
|
||||
step == 2 -> "Child-process limit updated"
|
||||
else -> "Change the child-process limit"
|
||||
compactSplit && step < 2 -> stringResource(R.string.wadb_title_wireless)
|
||||
step == 2 -> stringResource(R.string.wadb_title_done)
|
||||
else -> stringResource(R.string.wadb_title)
|
||||
},
|
||||
eyebrow = "Setup",
|
||||
eyebrow = stringResource(R.string.setup_title),
|
||||
lede = when {
|
||||
compactSplit && step == 0 -> "If the option is missing, pair over Wireless debugging."
|
||||
compactSplit && step == 1 -> "Paired. Check IP address & Port in Wireless debugging."
|
||||
compactSplit -> "The child-process limit was updated."
|
||||
step == 0 -> "Use step 2 if Developer options has no child-process setting. In Android Settings, open Developer options → Wireless debugging → Pair device with pairing code."
|
||||
step == 1 -> "Paired. Use the address below; update it from Wireless debugging → IP address & Port if needed."
|
||||
else -> "The child-process limit was updated over Wireless debugging."
|
||||
compactSplit && step == 0 -> stringResource(R.string.wadb_lede_compact0)
|
||||
compactSplit && step == 1 -> stringResource(R.string.wadb_lede_compact1)
|
||||
compactSplit -> stringResource(R.string.wadb_lede_done)
|
||||
step == 0 -> stringResource(R.string.wadb_lede0)
|
||||
step == 1 -> stringResource(R.string.wadb_lede1)
|
||||
else -> stringResource(R.string.wadb_lede_done_long)
|
||||
},
|
||||
onBack = { if (!busy) onBack() },
|
||||
action = if (compactSplit && step < 2) {
|
||||
{ SecondaryButton("Developer options", compact = true, enabled = !busy, onClick = onOpenDeveloperOptions) }
|
||||
{ SecondaryButton(stringResource(R.string.setup_dev_options), compact = true, enabled = !busy, onClick = onOpenDeveloperOptions) }
|
||||
} else null,
|
||||
scrollContent = !compactSplit,
|
||||
compactLayout = compactSplit,
|
||||
@@ -199,14 +201,14 @@ fun WirelessAdbFixPage(
|
||||
modifier = Modifier.widthIn(max = 680.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SettingsGroup("Child-process limit", compact = compactSplit) {
|
||||
SettingsGroup(stringResource(R.string.setup_limit), compact = compactSplit) {
|
||||
Text(
|
||||
"Android confirmed the limit is ${if (desiredEnabled) "on" else "off"}.",
|
||||
if (desiredEnabled) stringResource(R.string.wadb_confirmed_on) else stringResource(R.string.wadb_confirmed_off),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
}
|
||||
PrimaryButton("Done", enabled = !busy, onClick = onBack)
|
||||
PrimaryButton(stringResource(R.string.setup_check_done), enabled = !busy, onClick = onBack)
|
||||
}
|
||||
} else if (compactSplit) {
|
||||
Column(
|
||||
@@ -267,7 +269,7 @@ private fun WirelessStepForm(
|
||||
onPairAgain: () -> Unit,
|
||||
) {
|
||||
if (step == 0) {
|
||||
SettingsGroup("2 · Pair Wireless debugging", compact = compact) {
|
||||
SettingsGroup(stringResource(R.string.wadb_pair_group), compact = compact) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = if (compact) 8.dp else 12.dp, vertical = if (compact) 6.dp else 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 6.dp),
|
||||
@@ -275,7 +277,7 @@ private fun WirelessStepForm(
|
||||
AdbTextField(
|
||||
value = pairingAddress,
|
||||
onValueChange = onPairingAddressChange,
|
||||
label = "Pairing pop-up IP address & Port",
|
||||
label = stringResource(R.string.wadb_pair_address),
|
||||
placeholder = "192.168.1.42:37123",
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next,
|
||||
@@ -291,8 +293,8 @@ private fun WirelessStepForm(
|
||||
AdbTextField(
|
||||
value = pairingCode,
|
||||
onValueChange = onPairingCodeChange,
|
||||
label = "Pairing code (PIN)",
|
||||
placeholder = "6 digits",
|
||||
label = stringResource(R.string.wadb_pin),
|
||||
placeholder = stringResource(R.string.wadb_pin_placeholder),
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
compact = compact,
|
||||
@@ -302,13 +304,13 @@ private fun WirelessStepForm(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
PrimaryButton("Pair", compact = compact, enabled = !busy && canPair, onClick = onPair)
|
||||
PrimaryButton(stringResource(R.string.wadb_pair), compact = compact, enabled = !busy && canPair, onClick = onPair)
|
||||
}
|
||||
StatusMessage(busy, message, isError = messageIsError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SettingsGroup("2 · Apply over Wireless debugging", compact = compact) {
|
||||
SettingsGroup(stringResource(R.string.wadb_apply_group), compact = compact) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = if (compact) 8.dp else 12.dp, vertical = if (compact) 6.dp else 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 6.dp),
|
||||
@@ -316,7 +318,7 @@ private fun WirelessStepForm(
|
||||
AdbTextField(
|
||||
value = connectionAddress,
|
||||
onValueChange = onConnectionAddressChange,
|
||||
label = "Connection IP address & Port",
|
||||
label = stringResource(R.string.wadb_connect_address),
|
||||
placeholder = "192.168.1.42:45678",
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
@@ -330,9 +332,9 @@ private fun WirelessStepForm(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
StatusMessage(busy, message, Modifier.weight(1f), isError = messageIsError)
|
||||
SecondaryButton("Pair again", compact = compact, enabled = !busy, onClick = onPairAgain)
|
||||
SecondaryButton(stringResource(R.string.wadb_pair_again), compact = compact, enabled = !busy, onClick = onPairAgain)
|
||||
PrimaryButton(
|
||||
if (desiredEnabled) "Turn limit on" else "Turn limit off",
|
||||
if (desiredEnabled) stringResource(R.string.setup_limit_turn_on) else stringResource(R.string.wadb_turn_off),
|
||||
compact = compact,
|
||||
enabled = !busy && parseAdbAddress(connectionAddress) != null,
|
||||
onClick = onApply,
|
||||
@@ -453,16 +455,16 @@ private fun FallbackOrder(
|
||||
busy: Boolean,
|
||||
onOpenDeveloperOptions: () -> Unit,
|
||||
) {
|
||||
SettingsGroup("Other ways to change it") {
|
||||
SettingsGroup(stringResource(R.string.wadb_other_ways)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("1 · Developer options (try first)", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text("Set “Restrict child processes” ${if (desiredEnabled) "on" else "off"}.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
SecondaryButton("Open Developer options", enabled = !busy, onClick = onOpenDeveloperOptions)
|
||||
Text("3 · Computer ADB (last resort)", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text("Only if Wireless debugging is unavailable.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.wadb_dev_try_first), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(if (desiredEnabled) stringResource(R.string.wadb_restrict_on) else stringResource(R.string.wadb_restrict_off), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
SecondaryButton(stringResource(R.string.gate_open_dev), enabled = !busy, onClick = onOpenDeveloperOptions)
|
||||
Text(stringResource(R.string.wadb_computer_last), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(stringResource(R.string.wadb_only_if), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
PhantomProcessLimit.adbCommand(desiredEnabled),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
@@ -474,7 +476,7 @@ private fun FallbackOrder(
|
||||
|
||||
@Composable
|
||||
private fun CompactComputerFallback(desiredEnabled: Boolean) {
|
||||
SettingsGroup("3 · Computer ADB last resort", compact = true) {
|
||||
SettingsGroup(stringResource(R.string.wadb_computer_group), compact = true) {
|
||||
Text(
|
||||
PhantomProcessLimit.adbCommand(desiredEnabled),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||
@@ -489,7 +491,7 @@ private fun StatusMessage(busy: Boolean, message: String?, modifier: Modifier =
|
||||
if (busy) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(message ?: "Working…", style = MaterialTheme.typography.bodySmall)
|
||||
Text(message ?: stringResource(R.string.common_working), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
} else {
|
||||
message?.let {
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="loading_hints">
|
||||
<item>La primera vez se descarga Steam. Puede tardar unos minutos.</item>
|
||||
<item>De forma predeterminada, al salir de la app Steam se pausa y continúa donde lo dejaste cuando vuelves.</item>
|
||||
<item>Tu mando aparece como un mando Xbox 360. Si no, usa los controles en pantalla.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>La primera vez se prepara el escritorio. Puede tardar unos minutos.</item>
|
||||
<item>De forma predeterminada, al salir de la app la sesión se pausa y continúa donde la dejaste cuando vuelves.</item>
|
||||
<item>El tacto funciona como un touchpad: arrastra para mover el puntero y toca para hacer clic.</item>
|
||||
<item>Tu mando aparece como un mando Xbox 360. Si no, usa los controles en pantalla.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Sesión</string>
|
||||
<string name="session_channel_description">Se muestra mientras hay una sesión de Steam o de escritorio abierta</string>
|
||||
<string name="session_notification">Steam se está ejecutando</string>
|
||||
<string name="session_notification_paused">Sesión en pausa - toca Reanudar cuando estés listo</string>
|
||||
<string name="resume_session">Reanudar</string>
|
||||
<string name="stop_session">Detener sesión</string>
|
||||
<string name="frame_gen_title">Generación de fotogramas</string>
|
||||
<string name="frame_gen_hint">Elige el modo de generación de fotogramas</string>
|
||||
<string name="frame_gen_off">Desactivada</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
|
||||
<string name="lsfg_needs_steam">Requiere Lossless Scaling en Steam</string>
|
||||
<string name="lsfg_needs_install">Instala Lossless Scaling en Steam o importa Lossless.dll</string>
|
||||
<string name="lsfg_import">Importar Lossless.dll…</string>
|
||||
<string name="lsfg_using_steam">Usando la copia de Steam</string>
|
||||
<string name="lsfg_using_import">Usando una copia importada</string>
|
||||
<string name="lsfg_pick_title">Elegir Lossless.dll</string>
|
||||
<string name="lsfg_imported">Lossless.dll importado</string>
|
||||
<string name="lsfg_import_unchanged">Este Lossless.dll ya está en uso</string>
|
||||
<string name="lsfg_import_not_owned">Lossless Scaling no está en esta cuenta de Steam</string>
|
||||
<string name="lsfg_import_invalid">Este archivo no es el Lossless.dll de Lossless Scaling</string>
|
||||
<string name="lsfg_import_failed">No se pudo preparar LSFG con este Lossless.dll</string>
|
||||
<string name="lsfg_problem_missing">LSFG necesita Lossless Scaling de Steam o un Lossless.dll importado</string>
|
||||
<string name="lsfg_problem_failed">LSFG no pudo preparar sus shaders desde Lossless.dll</string>
|
||||
<string name="hud_fg_unsupported">%1$s: no compatible</string>
|
||||
<string name="hud_fg_failed">%1$s: falló el motor</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: requiere Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · iniciando</string>
|
||||
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
|
||||
<string name="second_library_import_hint">Añade una biblioteca de Steam y busca juegos de Windows en ella. Los juegos que tienes usan sus entradas de Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
|
||||
<string name="added_games_forget_hint">Olvidar quita los accesos directos en la próxima sesión. Los archivos se conservan.</string>
|
||||
<string name="game_env_title">Entorno del juego</string>
|
||||
<string name="game_env_hint">Variables de entorno para juegos de Proton.</string>
|
||||
<string name="game_env_edit">Editar</string>
|
||||
<string name="game_env_shared">Todos los juegos de Proton</string>
|
||||
<string name="game_env_scope">Perfil</string>
|
||||
<string name="game_env_app_id">ID de app de Steam</string>
|
||||
<string name="game_env_open">Abrir</string>
|
||||
<string name="game_env_profile">Juego %1$s</string>
|
||||
<string name="game_env_add">Añadir variable</string>
|
||||
<string name="game_env_name">Nombre de la variable</string>
|
||||
<string name="game_env_value">Valor</string>
|
||||
<string name="game_env_save">Guardar</string>
|
||||
<string name="game_env_remove">Quitar</string>
|
||||
<string name="game_env_restore">Heredar de nuevo</string>
|
||||
<string name="game_env_reset">Restablecer este perfil</string>
|
||||
<string name="game_env_unset">Quitar valor en este perfil</string>
|
||||
<string name="game_env_empty">Sin variables definidas.</string>
|
||||
<string name="game_env_inherited">Heredada</string>
|
||||
<string name="game_env_custom">Variable personalizada</string>
|
||||
<string name="game_env_error">No se pudo cargar o guardar. Revisa el espacio libre e inténtalo de nuevo.</string>
|
||||
<string name="game_env_invalid">Usa un nombre válido y un valor de hasta 8192 caracteres.</string>
|
||||
<string name="game_env_unused">El runtime estándar de Proton no lee esta variable.</string>
|
||||
<string name="game_env_loading">Cargando…</string>
|
||||
<string name="game_env_feature">Forzar el nivel de funciones de D3D12. 12_2 informa de las capacidades de DirectX 12 Ultimate; las funciones no compatibles pueden fallar. Déjalo sin definir para detección automática.</string>
|
||||
<string name="game_env_shader">Forzar el modelo de shaders de D3D12. 6_9 requiere un VKD3D suficientemente reciente y funciones de GPU compatibles. Déjalo sin definir para detección automática.</string>
|
||||
<string name="game_env_cache">Mantén activada la caché de shaders de Mesa para reducir compilaciones repetidas. false activa la caché.</string>
|
||||
<string name="game_env_cache_size">Tamaño máximo de la caché de shaders de Mesa, como 1G. Las cachés grandes usan más almacenamiento.</string>
|
||||
<string name="game_env_vkd3d_config">Opciones de VKD3D para D3D12. nodxr desactiva el trazado de rayos; úsalo solo en un juego que lo necesite.</string>
|
||||
<string name="game_env_proton_log">Pon 1 para obtener un registro de diagnóstico de Proton. El registro puede reducir el rendimiento y usar almacenamiento.</string>
|
||||
<string name="game_env_wined3d">Pon 1 para usar WineD3D basado en OpenGL en lugar de DXVK, para pruebas de compatibilidad con D3D9–11.</string>
|
||||
<string name="game_env_xalia">Pon 0 para desactivar el asistente de navegación con mando de Proton en este juego.</string>
|
||||
<string name="game_env_hud">Overlay de DXVK para D3D9–11. fps muestra la tasa de fotogramas; sin definir la oculta.</string>
|
||||
<string name="game_env_dxvk_config">Ajustes de DXVK separados por punto y coma. Las versiones recientes usan dxvk.maxFrameRate para limitar fotogramas; para el uso normal prefiere el límite de fotogramas de la sesión.</string>
|
||||
<string name="game_env_vkd3d_limit">Límite de fotogramas de D3D12. 0 desactiva el límite.</string>
|
||||
<string name="game_env_glthread">Subprocesos de OpenGL de Mesa. true puede ayudar a juegos OpenGL limitados por la CPU; pruébalo en cada juego.</string>
|
||||
<string name="fex_preset_title">Ajuste preestablecido de FEX</string>
|
||||
<string name="fex_next_launch">Se aplica al iniciar el próximo juego.</string>
|
||||
<string name="game_settings_title">Juegos</string>
|
||||
<string name="fex_default_label">Valores de FEX</string>
|
||||
<string name="fex_default_detail">Usa los valores predeterminados de FEX.</string>
|
||||
<string name="fex_stability_label">Estabilidad</string>
|
||||
<string name="fex_stability_detail">Los ajustes más seguros y lentos.</string>
|
||||
<string name="fex_compatibility_label">Compatibilidad</string>
|
||||
<string name="fex_compatibility_detail">TSO completo con multibloque.</string>
|
||||
<string name="fex_intermediate_label">Intermedio</string>
|
||||
<string name="fex_intermediate_detail">TSO equilibrado con precisión x87 reducida.</string>
|
||||
<string name="fex_performance_label">Rendimiento</string>
|
||||
<string name="fex_performance_detail">Todo el TSO desactivado; multibloque.</string>
|
||||
<string name="fex_performance_tso_label">Rendimiento + TSO</string>
|
||||
<string name="fex_performance_tso_detail">Ajustes de rendimiento con el TSO principal activado.</string>
|
||||
<string name="fex_extreme_label">Extremo</string>
|
||||
<string name="fex_extreme_detail">Desactiva las comprobaciones de código automodificable. Los juegos con JIT y el DRM pueden fallar.</string>
|
||||
<string name="fex_extreme_tso_label">Extremo + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Ajustes extremos con el TSO principal activado.</string>
|
||||
<string name="fex_extreme_gn_label">Extreme-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Rendimiento con TSC y pistas de metadatos. Pruébalo antes que Extremo.</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Rendimiento con comprobaciones de código completas y el bit del hipervisor oculto.</string>
|
||||
<string name="game_env_custom_value">Valor personalizado</string>
|
||||
<string name="game_env_select_value">Elegir valor</string>
|
||||
<string name="user_apps_section">Emuladores y apps</string>
|
||||
<string name="user_apps_add">Añadir</string>
|
||||
<string name="user_apps_add_detail">Tu propia app o juego</string>
|
||||
<string name="user_apps_starting">Iniciando…</string>
|
||||
<string name="user_apps_added">%1$s añadida</string>
|
||||
<string name="user_apps_removed">%1$s quitada</string>
|
||||
<string name="user_apps_back">Escritorio</string>
|
||||
<string name="user_apps_gone">Esa app ya no está. Elígela de nuevo desde Escritorio.</string>
|
||||
<string name="user_apps_open">Abrir %1$s</string>
|
||||
<string name="user_apps_remove">Quitar</string>
|
||||
<string name="user_apps_remove_confirm">Pulsa de nuevo para quitar</string>
|
||||
<string name="user_apps_stop_session">Detén la sesión para quitar</string>
|
||||
<string name="user_apps_script_linked">Ejecuta %1$s desde su carpeta, enlazada a la sesión donde está. Quitar la app no toca esa carpeta.</string>
|
||||
<string name="user_apps_script_copied">Ejecuta %1$s desde una copia de su carpeta dentro de DroidDeck. Quitar la app borra la copia, no el original.</string>
|
||||
<string name="user_apps_runtime_required">Instala primero el runtime de Linux</string>
|
||||
<string name="user_apps_file_gone">El archivo ya no está</string>
|
||||
<string name="user_apps_no_space">No hay espacio libre suficiente: copiar la carpeta necesita %1$s</string>
|
||||
<string name="user_apps_copying">Copiando %1$s</string>
|
||||
<string name="user_apps_failed">No se pudo añadir la app</string>
|
||||
<string name="user_apps_github_checking">Buscando una versión de %1$s</string>
|
||||
<string name="user_apps_github_unreachable">No se pudieron leer las versiones de %1$s</string>
|
||||
<string name="user_apps_github_no_appimage">Ninguna versión de %1$s tiene un AppImage ARM64</string>
|
||||
<string name="user_apps_downloading">Descargando %1$s</string>
|
||||
<string name="user_apps_download_failed">La descarga falló</string>
|
||||
<string name="user_apps_update_failed">No se pudo actualizar la app</string>
|
||||
<string name="user_apps_icon_failed">No se pudo leer ese icono</string>
|
||||
<string name="user_apps_saved">%1$s guardada</string>
|
||||
<string name="user_apps_edit">Editar</string>
|
||||
<string name="user_apps_check_updates">Buscar actualizaciones</string>
|
||||
<string name="user_apps_checking_updates">Buscando actualizaciones…</string>
|
||||
<string name="user_apps_update_to">Actualizar a %1$s</string>
|
||||
<string name="user_apps_up_to_date">Al día · %1$s</string>
|
||||
<string name="user_apps_updated">%1$s actualizada</string>
|
||||
<string name="add_app_title">Añadir una app</string>
|
||||
<string name="add_app_script_hint">Un archivo .sh. Se ejecuta desde su propia carpeta, a pantalla completa con gamescope.</string>
|
||||
<string name="add_app_appimage_hint">Un AppImage ARM64 (aarch64). Se descomprime una vez en el runtime de Linux.</string>
|
||||
<string name="add_app_github_hint">Instala el AppImage ARM64 de la última versión del repositorio.</string>
|
||||
<string name="add_app_flatpak_hint">Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está.</string>
|
||||
<string name="add_app_file">Archivo</string>
|
||||
<string name="add_app_no_file">Ningún archivo elegido</string>
|
||||
<string name="add_app_choose_file">Elegir archivo</string>
|
||||
<string name="add_app_change_file">Cambiar</string>
|
||||
<string name="add_app_pick_script">Elige un script</string>
|
||||
<string name="add_app_pick_appimage">Elige un AppImage</string>
|
||||
<string name="add_app_pick_icon">Elige un icono</string>
|
||||
<string name="add_app_repository">Repositorio</string>
|
||||
<string name="add_app_repository_placeholder">propietario/repo o un enlace de github.com</string>
|
||||
<string name="add_app_repository_invalid">No es un repositorio de GitHub</string>
|
||||
<string name="add_app_flatpak_id">ID de app</string>
|
||||
<string name="add_app_flatpak_invalid">No es un ID de app de Flathub</string>
|
||||
<string name="add_app_source">Origen</string>
|
||||
<string name="add_app_appearance">En tu Escritorio</string>
|
||||
<string name="add_app_preview_name">Nueva app</string>
|
||||
<string name="add_app_name">Nombre</string>
|
||||
<string name="add_app_name_placeholder">De la app</string>
|
||||
<string name="add_app_icon">Icono</string>
|
||||
<string name="add_app_icon_default">Icono predeterminado</string>
|
||||
<string name="add_app_choose_icon">Elegir icono</string>
|
||||
<string name="add_app_clear_icon">Borrar</string>
|
||||
<string name="add_app_icon_suggestions">Del repositorio</string>
|
||||
<string name="add_app_icon_searching">Buscando iconos…</string>
|
||||
<string name="add_app_icon_reset">Predeterminado</string>
|
||||
<string name="edit_app_title">Editar %1$s</string>
|
||||
<string name="edit_app_save">Guardar</string>
|
||||
<string name="add_app_checking_folder">Comprobando la carpeta…</string>
|
||||
<string name="add_app_folder_linked">Su carpeta está enlazada a la sesión: %1$s</string>
|
||||
<string name="add_app_folder_copied">Su carpeta contiene programas de Linux, que el almacenamiento compartido no puede ejecutar, así que se copia en DroidDeck (%1$s).</string>
|
||||
<string name="add_app_confirm">Añadir</string>
|
||||
<string name="add_app_cancel">Cancelar</string>
|
||||
<string name="add_app_runtime_required">Instala primero el runtime de Linux desde Configuración.</string>
|
||||
<string name="add_app_busy">Espera a que termine %1$s.</string>
|
||||
<string name="common_cancel">Cancelar</string>
|
||||
<string name="common_auto">Auto</string>
|
||||
<string name="common_never">Nunca</string>
|
||||
<string name="common_always">Siempre</string>
|
||||
<string name="common_uninstall">Desinstalar</string>
|
||||
<string name="common_check">Comprobar</string>
|
||||
<string name="common_update">Actualizar</string>
|
||||
<string name="common_checking">Comprobando…</string>
|
||||
<string name="common_working">Trabajando…</string>
|
||||
<string name="common_forget">Olvidar</string>
|
||||
<string name="common_add_ellipsis">Añadir…</string>
|
||||
<string name="common_use">Usar</string>
|
||||
<string name="common_applies_next_session">Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_steam_title">Sesión de Steam</string>
|
||||
<string name="mode_desktop_title">Sesión de escritorio</string>
|
||||
<string name="mode_display">Pantalla</string>
|
||||
<string name="mode_resolution">Resolución</string>
|
||||
<string name="mode_res_720">Hasta 720p</string>
|
||||
<string name="mode_res_900">Hasta 900p</string>
|
||||
<string name="mode_res_1080">Hasta 1080p</string>
|
||||
<string name="mode_res_native">Nativa</string>
|
||||
<string name="mode_res_default">%1$s (predeterminada)</string>
|
||||
<string name="mode_res_custom_value">Personalizada · %1$d×%2$d</string>
|
||||
<string name="mode_res_custom">Personalizada…</string>
|
||||
<string name="mode_res_note">720p puede hacer los menús más ágiles.</string>
|
||||
<string name="mode_ratio">Relación de aspecto</string>
|
||||
<string name="mode_ratio_custom">Definida por la resolución personalizada.</string>
|
||||
<string name="mode_ratio_auto">Auto usa al menos 16:9.</string>
|
||||
<string name="mode_fps">Límite de fotogramas</string>
|
||||
<string name="mode_fps_note">Limita toda la sesión. La pantalla cambia a una tasa de refresco que el límite divide exactamente.</string>
|
||||
<string name="mode_hdr">HDR</string>
|
||||
<string name="mode_hdr10">Salida HDR10</string>
|
||||
<string name="mode_hdr_unavailable">No disponible: %1$s.</string>
|
||||
<string name="mode_hdr_restart">Reinicia la app para aplicarlo.</string>
|
||||
<string name="mode_drivers">Controladores</string>
|
||||
<string name="mode_gpu_drivers">Controladores de GPU</string>
|
||||
<string name="mode_gpu_drivers_hint">Compartidos por Steam y Escritorio. Se definen en la página Componentes.</string>
|
||||
<string name="mode_touch_controls">Táctil y controles</string>
|
||||
<string name="mode_touch">Táctil</string>
|
||||
<string name="mode_touch_touchpad">Touchpad</string>
|
||||
<string name="mode_touch_direct">Directo</string>
|
||||
<string name="mode_touch_note">Auto: touchpad en Escritorio, directo en Steam.</string>
|
||||
<string name="mode_osc">Controles en pantalla</string>
|
||||
<string name="mode_osc_qam">Steam + QAM</string>
|
||||
<string name="mode_osc_note">Auto muestra los controles cuando no hay ningún mando conectado.</string>
|
||||
<string name="mode_controller">Mando</string>
|
||||
<string name="mode_controller_hint">Cómo ve Steam tu mando. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_controller_deck">Mando de Steam Deck</string>
|
||||
<string name="mode_controller_x360">Mando Xbox 360</string>
|
||||
<string name="mode_controller_note">Deck: botón de Quick Access y giroscopio. Xbox 360: abre Quick Access con Guide+A.</string>
|
||||
<string name="mode_back">Atrás</string>
|
||||
<string name="mode_session">Sesión</string>
|
||||
<string name="mode_suspend">Comportamiento en segundo plano</string>
|
||||
<string name="mode_suspend_hint">Cuando la app se oculta o la pantalla se apaga.</string>
|
||||
<string name="mode_suspend_manual">Manual</string>
|
||||
<string name="mode_suspend_note">Auto pausa en segundo plano y reanuda al mostrarse. Manual espera a Reanudar. Nunca sigue ejecutándose.</string>
|
||||
<string name="mode_startup">Inicio</string>
|
||||
<string name="mode_steam_startup">Iniciar Steam con DroidDeck</string>
|
||||
<string name="mode_steam_startup_hint">Abre la sesión de Steam al iniciar DroidDeck.</string>
|
||||
<string name="mode_decky_checking">Buscando versiones…</string>
|
||||
<string name="mode_decky_ready">Listo para instalar %1$s.</string>
|
||||
<string name="mode_decky_none">No se encontró ninguna versión compatible. Inténtalo más tarde.</string>
|
||||
<string name="mode_decky_installed">Instalado · %1$s</string>
|
||||
<string name="mode_decky_update">Actualización disponible · %1$s</string>
|
||||
<string name="mode_decky_current">Al día · %1$s</string>
|
||||
<string name="mode_decky_loader">Loader</string>
|
||||
<string name="mode_decky_install_latest">Instalar la última</string>
|
||||
<string name="mode_decky_on">Se inicia con Steam. Mientras esté activado, otras apps de este dispositivo podrían controlar Steam.</string>
|
||||
<string name="mode_decky_off">Desactivado. El puerto de depuración remota de Steam permanece cerrado.</string>
|
||||
<string name="mode_decky_remove_title">¿Desinstalar Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">Elimina el loader. Tus plugins y ajustes se conservan.</string>
|
||||
<string name="mode_client">Cliente</string>
|
||||
<string name="mode_deck_mode">Modo Steam Deck</string>
|
||||
<string name="mode_deck_mode_hint">La interfaz Deck de Steam y los controles de rendimiento de Quick Access. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_mangoapp">Overlay de rendimiento</string>
|
||||
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_branch">Rama del cliente</string>
|
||||
<string name="mode_branch_deck">Beta pública de Steam Deck (requerida por el modo Deck).</string>
|
||||
<string name="mode_branch_hint">Se aplica en la próxima sesión. Steam puede actualizarse una vez.</string>
|
||||
<string name="mode_branch_public">Beta pública</string>
|
||||
<string name="mode_branch_deck_beta">Beta pública de Steam Deck</string>
|
||||
<string name="mode_branch_note">El modo Deck siempre usa la beta pública de Steam Deck.</string>
|
||||
<string name="mode_added_games">Juegos añadidos</string>
|
||||
<string name="mode_added_none">no hay carpetas de juegos con un .exe</string>
|
||||
<string name="mode_games_folder">Carpeta de juegos</string>
|
||||
<string name="mode_games_folder_another">Otra carpeta de juegos</string>
|
||||
<string name="mode_added_art">Imágenes de Steam</string>
|
||||
<string name="mode_added_art_hint">Completa las imágenes que faltan según el nombre de la carpeta. Tus propios cover.jpg, hero.jpg, logo.png o icon.png en la carpeta del juego tienen prioridad.</string>
|
||||
<string name="mode_added_launches">Inicia %1$s</string>
|
||||
<string name="mode_added_launches_in">Inicia %1$s · en %2$s</string>
|
||||
<string name="mode_added_choose">Elegir otro archivo…</string>
|
||||
<string name="mode_added_exe_note">Por defecto, el .exe con el nombre de la carpeta o, si no, el más grande.</string>
|
||||
<string name="mode_fill">Estirar juegos para llenar la pantalla</string>
|
||||
<string name="mode_fill_hint">Mantiene a pantalla completa los juegos que se redimensionan solos. Desactívalo si un juego se ve pequeño. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_stretch169">Estirar juegos 16:9</string>
|
||||
<string name="mode_stretch169_hint">En pantallas más cuadradas que 16:9, estira los juegos para llenar en lugar de mostrar barras. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
<string name="mode_directaudio">DirectAudio para juegos</string>
|
||||
<string name="mode_directaudio_hint">Evita PulseAudio para menor latencia.</string>
|
||||
<string name="mode_client_audio">Audio del cliente de Steam</string>
|
||||
<string name="mode_client_audio_hint">Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_client_audio_classic">Clásico</string>
|
||||
<string name="mode_mic">Micrófono</string>
|
||||
<string name="mode_mic_hint">Para el chat de voz.</string>
|
||||
<string name="mode_storage">Almacenamiento de juegos</string>
|
||||
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente</string>
|
||||
<string name="mode_storage_auto_none">Automático - la tarjeta SD si está presente (ninguna ahora)</string>
|
||||
<string name="mode_storage_internal">Solo interno</string>
|
||||
<string name="mode_storage_folder">Carpeta: %1$s</string>
|
||||
<string name="mode_second_library">Segunda biblioteca</string>
|
||||
<string name="mode_storage_note">Los juegos que cargan recursos desde la SD o el almacenamiento compartido pueden ir a tirones.</string>
|
||||
<string name="mode_choose_folder">Elegir una carpeta…</string>
|
||||
<string name="mode_renderer">Renderizador</string>
|
||||
<string name="mode_desktop_renderer">Renderizador del escritorio</string>
|
||||
<string name="mode_renderer_hint">Dibuja el escritorio.</string>
|
||||
<string name="mode_renderer_note">Vulkan ejecuta los programas del escritorio en la GPU y recurre a pixman si falla. Con pixman, los juegos se abren a pantalla completa en la GPU.</string>
|
||||
<string name="mode_custom_res_title">Resolución personalizada</string>
|
||||
<string name="mode_custom_res_text">Sustituye el límite y la relación de aspecto. Un tamaño que no coincide con la pantalla muestra barras.</string>
|
||||
<string name="mode_width">Ancho</string>
|
||||
<string name="mode_height">Alto</string>
|
||||
<string name="mode_custom_res_range">Entre 320×240 y 3840×2160.</string>
|
||||
<plurals name="mode_added_count">
|
||||
<item quantity="one">%1$d juego</item>
|
||||
<item quantity="other">%1$d juegos</item>
|
||||
</plurals>
|
||||
<string name="common_delete">Eliminar</string>
|
||||
<string name="common_restore">Restaurar</string>
|
||||
<string name="common_download">Descargar</string>
|
||||
<string name="common_delete_named">Eliminar %1$s</string>
|
||||
<string name="comp_runtime_driver">Controlador de runtime</string>
|
||||
<string name="comp_runtime_driver_hint">Lo usan Steam, los juegos y el escritorio. Se aplica en la próxima sesión.</string>
|
||||
<string name="comp_import_turnip">Importar zip de Turnip…</string>
|
||||
<string name="comp_display_driver">Controlador de pantalla</string>
|
||||
<string name="comp_display_driver_hint">Dibuja los fotogramas en pantalla en ambos modos. Reinicia la app para aplicarlo.</string>
|
||||
<string name="comp_import_adrenotools">Importar un zip de AdrenoTools…</string>
|
||||
<string name="comp_never">nunca</string>
|
||||
<string name="comp_nightlies_checked">Nightlies comprobadas %1$s</string>
|
||||
<string name="comp_title">Componentes</string>
|
||||
<string name="comp_about">Acerca de Componentes</string>
|
||||
<string name="comp_check_drivers">Buscar controladores nuevos</string>
|
||||
<string name="comp_check_nightlies">Buscar paquetes nuevos en las Nightlies</string>
|
||||
<string name="comp_proton_note">Steam reemplaza el Proton de Valve al actualizarse; los originales se conservan por versión.</string>
|
||||
<string name="comp_swapped">%1$s · %2$d reemplazados</string>
|
||||
<string name="comp_all_original">%1$s · todos originales</string>
|
||||
<string name="comp_game_running_suffix">%1$s · juego en ejecución</string>
|
||||
<string name="comp_gpu_drivers">Controladores de GPU</string>
|
||||
<string name="comp_reapplied">%1$s · reaplicado al iniciar %2$s</string>
|
||||
<string name="comp_next_queued">Siguiente: %1$s · cuando se cierre el juego</string>
|
||||
<string name="comp_in_use">%1$s en uso: %2$s</string>
|
||||
<string name="comp_waits">Espera a que se cierre el juego</string>
|
||||
<string name="comp_applies_next_game">Se aplica la próxima vez que se inicie un juego</string>
|
||||
<string name="comp_import_zip">Importar .zip</string>
|
||||
<string name="comp_reading">Leyendo Protons…</string>
|
||||
<string name="comp_no_proton">Aún no hay ningún Proton instalado. Inicia Steam una vez para descargar su Proton ARM64, o añade GE-Proton o CachyOS desde Configuración.</string>
|
||||
<string name="comp_original">Original · %1$s</string>
|
||||
<string name="comp_this_build">%1$s · esta versión</string>
|
||||
<string name="comp_earlier_build">%1$s · versión anterior</string>
|
||||
<string name="comp_installed_count">Instalados · %1$d</string>
|
||||
<string name="comp_nothing_stored">Aún no hay nada guardado para %1$s. Los archivos propios de este Proton se guardan como su original.</string>
|
||||
<string name="comp_waits_long">Hay un juego en ejecución con este Proton, así que se espera a que se cierre.</string>
|
||||
<string name="comp_applies_long">Se aplica la próxima vez que se inicie un juego.</string>
|
||||
<string name="comp_swap_title">¿Reemplazar %1$s?</string>
|
||||
<string name="comp_swap_body">¿Poner %1$s en %2$s? Sus archivos originales quedan guardados. %3$s</string>
|
||||
<string name="comp_restore_title">¿Restaurar %1$s?</string>
|
||||
<string name="comp_restore_body">¿Volver a poner el original de %1$s en %2$s? %3$s</string>
|
||||
<string name="comp_delete_package_body">Quita el paquete de la app. Puedes descargarlo de nuevo.</string>
|
||||
<string name="comp_delete_original_title">¿Eliminar este original?</string>
|
||||
<string name="comp_delete_original_body">Este original de %1$s es de una versión anterior de %2$s. El original de la versión actual siempre se conserva.</string>
|
||||
<string name="comp_nightlies_count">En las Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Actualiza para listar los paquetes de las Nightlies.</string>
|
||||
<string name="comp_nothing_new">Nada nuevo desde la última comprobación.</string>
|
||||
<string name="comp_import_wcp">Importar .wcp</string>
|
||||
<string name="comp_restore_original">Restaurar el original de este Proton</string>
|
||||
<string name="comp_restore_original_body">¿Volver a poner el original %2$s de %1$s?</string>
|
||||
<string name="comp_swap">Reemplazar</string>
|
||||
<string name="comp_about_text">FEX, DXVK y VKD3D-Proton para cada Proton con el que Steam ejecuta juegos.\n\nElige una fila instalada para ponerla en uso. Se aplica la próxima vez que se inicie un juego, o cuando se cierre el juego en ejecución.\n\nTus elecciones se comprueban antes de cada inicio y se restauran si algo las cambió. Los archivos originales de cada Proton se conservan, así que las actualizaciones de Steam nunca los pierden.</string>
|
||||
<string name="comp_tag_original">Original</string>
|
||||
<string name="comp_tag_in_use">En uso</string>
|
||||
<string name="comp_tag_new">Nuevo</string>
|
||||
<string name="comp_tag_stored">Guardado</string>
|
||||
<string name="perf_title">Rendimiento</string>
|
||||
<string name="perf_lede">Núcleos de CPU, correcciones de sesión y nombre del host. Se aplica en la próxima sesión.</string>
|
||||
<string name="perf_client_cores_group">Núcleos del cliente de Steam</string>
|
||||
<string name="perf_override">Forzar la elección de núcleos de Steam</string>
|
||||
<string name="perf_override_hint">Fija Steam, su ayudante de interfaz y gamescope a los núcleos de abajo.</string>
|
||||
<string name="perf_client_cores">Núcleos del cliente</string>
|
||||
<string name="perf_client_cores_on">Núcleos para Steam.</string>
|
||||
<string name="perf_client_cores_off">Activa el forzado para elegir.</string>
|
||||
<string name="perf_game_cores_group">Núcleos de juegos</string>
|
||||
<string name="perf_game_cores">Núcleos de juegos</string>
|
||||
<string name="perf_game_cores_hint">Núcleos para los juegos. Con todos seleccionados, Android los asigna libremente.</string>
|
||||
<string name="perf_client_ui">Interfaz del cliente</string>
|
||||
<string name="perf_glthread">GL con subprocesos</string>
|
||||
<string name="perf_glthread_hint">Puede hacer los menús de Steam más ágiles.</string>
|
||||
<string name="perf_zink">Zink: descriptores diferidos</string>
|
||||
<string name="perf_zink_hint">Recomendado para controladores sin búferes de descriptores.</string>
|
||||
<string name="perf_noglerror">Omitir comprobaciones de errores de GL</string>
|
||||
<string name="perf_noglerror_hint">Desactiva la validación de GL en cada llamada.</string>
|
||||
<string name="perf_gsrealtime">gamescope: cola de GPU en tiempo real</string>
|
||||
<string name="perf_gsrealtime_hint">Da prioridad al trabajo de GPU del compositor. Puede suavizar el ritmo de fotogramas a costa de algo de rendimiento en los juegos.</string>
|
||||
<string name="perf_gpuclock">Mantener la GPU a su reloj máximo</string>
|
||||
<string name="perf_gpuclock_hint">Solo Adreno. Menos tirones, más consumo de batería y calor.</string>
|
||||
<string name="perf_fixes">Correcciones de sesión</string>
|
||||
<string name="perf_sysmem">Turnip: renderizado sysmem</string>
|
||||
<string name="perf_sysmem_hint">Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722.</string>
|
||||
<string name="perf_xalia">Omitir el asistente xalia de Steam</string>
|
||||
<string name="perf_xalia_hint">Desactiva el asistente de navegación con mando de Proton, que consume CPU en cada juego.</string>
|
||||
<string name="perf_seccomp">Ejecutar proot sin seccomp</string>
|
||||
<string name="perf_seccomp_hint">Puede corregir errores de syscalls ausentes en algunos kernels. Puede reducir el rendimiento.</string>
|
||||
<string name="perf_fastpath">Ruta rápida de proot</string>
|
||||
<string name="perf_fastpath_needs_seccomp">Requiere el filtro seccomp de proot.</string>
|
||||
<string name="perf_fastpath_hint">Búsquedas de archivos más rápidas y menos tirones. Desactívalo si un programa no encuentra sus archivos.</string>
|
||||
<string name="perf_identity">Identidad de la sesión</string>
|
||||
<string name="perf_hostname">Nombre del host</string>
|
||||
<string name="perf_hostname_hint">El nombre que Steam ve para este dispositivo. En blanco restaura %1$s.</string>
|
||||
<string name="perf_hostname_invalid">Solo letras, dígitos y guiones internos, hasta 63 caracteres.</string>
|
||||
<string name="perf_phantom_warning">Android está configurado para cerrar esta sesión</string>
|
||||
<string name="driver_installed">Instalado</string>
|
||||
<string name="driver_available">Disponible para descargar</string>
|
||||
<string name="driver_nothing_new">Nada nuevo desde la última comprobación.</string>
|
||||
<string name="driver_restore_builtin">Restaurar controladores integrados</string>
|
||||
<string name="driver_delete_builtin">Integrado en la app: esto elimina sus archivos descomprimidos y lo oculta. Restaurar controladores integrados lo recupera.</string>
|
||||
<string name="driver_delete_bundle">Mitad de un paquete Android + Linux: se eliminan ambas mitades. Un controlador en uso vuelve al predeterminado.</string>
|
||||
<string name="driver_delete_plain">Sus archivos se eliminan de la app.</string>
|
||||
<string name="driver_delete_in_use">Sus archivos se eliminan. Está en uso, así que pasa a usarse el predeterminado.</string>
|
||||
<string name="drawer_page_display">Pantalla</string>
|
||||
<string name="drawer_page_controls">Controles</string>
|
||||
<string name="drawer_page_components">Componentes</string>
|
||||
<string name="drawer_page_settings">Ajustes</string>
|
||||
<string name="drawer_page_desc">Página %1$s</string>
|
||||
<string name="drawer_desktop">Escritorio</string>
|
||||
<string name="drawer_steam_menu">Menú de Steam</string>
|
||||
<string name="drawer_quick_access">Quick Access</string>
|
||||
<string name="drawer_open_qam">Abrir el menú Quick Access</string>
|
||||
<string name="drawer_prev_page">Página anterior</string>
|
||||
<string name="drawer_next_page">Página siguiente</string>
|
||||
<string name="drawer_hud">HUD de rendimiento</string>
|
||||
<string name="drawer_fill">Estirar juegos para llenar</string>
|
||||
<string name="drawer_touch_auto">Auto (%1$s)</string>
|
||||
<string name="drawer_keyboard">Teclado</string>
|
||||
<string name="drawer_pc_keyboard">Teclado de PC</string>
|
||||
<string name="drawer_android_keyboard">Android</string>
|
||||
<string name="drawer_second_screen">Segunda pantalla</string>
|
||||
<string name="drawer_second_screen_shows">Muestra</string>
|
||||
<string name="drawer_android_apps">Apps de Android</string>
|
||||
<string name="drawer_launch_app">Abrir una app</string>
|
||||
<string name="common_hide">Ocultar</string>
|
||||
<string name="common_show">Mostrar</string>
|
||||
<string name="drawer_no_apps">No hay apps que se puedan abrir</string>
|
||||
<string name="drawer_session_behavior">Comportamiento de la sesión</string>
|
||||
<string name="drawer_suspend_hint">Se aplica ahora y a las próximas sesiones en este modo.</string>
|
||||
<string name="drawer_next_session">Próxima sesión</string>
|
||||
<string name="drawer_support">Soporte</string>
|
||||
<string name="drawer_logs">Registros de sesión</string>
|
||||
<string name="drawer_logs_hint">Adjúntalos a un informe de error</string>
|
||||
<string name="drawer_share_logs">Compartir registros</string>
|
||||
<string name="drawer_background">Segundo plano</string>
|
||||
<string name="drawer_stop">Detener</string>
|
||||
<string name="drawer_stop_title">¿Detener la sesión?</string>
|
||||
<string name="drawer_running_now">En ejecución</string>
|
||||
<string name="drawer_no_game">Ningún juego se ejecuta con un Proton</string>
|
||||
<string name="drawer_no_proton">Aún no hay ningún Proton instalado.</string>
|
||||
<string name="drawer_game_running">juego en ejecución</string>
|
||||
<string name="drawer_reapplied">reaplicado al iniciar %1$s</string>
|
||||
<string name="drawer_original">Original</string>
|
||||
<string name="drawer_components_note">Descarga, importa y elimina en la página Componentes.</string>
|
||||
<string name="setup_check_done">Hecho</string>
|
||||
<string name="setup_check_warn">Requiere atención</string>
|
||||
<string name="setup_check_busy">Trabajando</string>
|
||||
<string name="setup_limit_adb_failed">Revisa la dirección IP y el puerto de la Depuración inalámbrica, o vincula de nuevo si Android quitó este dispositivo.</string>
|
||||
<string name="setup_install">Instalar</string>
|
||||
<string name="setup_manage">Gestionar</string>
|
||||
<string name="setup_tab_overview">Resumen</string>
|
||||
<string name="setup_tab_controller">Mando</string>
|
||||
<string name="setup_tab_session">Sesión</string>
|
||||
<string name="setup_tab_launcher">Launcher</string>
|
||||
<string name="setup_title">Configuración</string>
|
||||
<string name="setup_all_set">● Todo listo</string>
|
||||
<string name="setup_n_ready">%1$d de %2$d listos</string>
|
||||
<string name="setup_system_check">Comprobación del sistema</string>
|
||||
<string name="setup_gpu_supported">Dispositivo compatible</string>
|
||||
<string name="setup_gpu_untested">GPU sin probar</string>
|
||||
<string name="setup_gpu_unsupported">GPU no compatible</string>
|
||||
<string name="setup_gpu_unsupported_detail">Steam necesita un controlador Adreno; %1$s puede mostrar una pantalla negra</string>
|
||||
<string name="setup_runtime">Runtime de Linux</string>
|
||||
<string name="setup_runtime_missing">No instalado · unos 3 GB, se instala en el primer Play</string>
|
||||
<string name="setup_installed">Instalado</string>
|
||||
<string name="setup_runtime_update">%1$s · actualización disponible</string>
|
||||
<string name="setup_runtime_current">%1$s · al día</string>
|
||||
<string name="setup_limit">Límite de procesos secundarios</string>
|
||||
<string name="setup_limit_on">Activado · Android puede cerrar Steam. Desactivarlo tarda un minuto.</string>
|
||||
<string name="setup_limit_unset">Sin definir · Android puede cerrar Steam. Desactivarlo tarda un minuto.</string>
|
||||
<string name="setup_limit_unknown">No se pudo comprobar · desactivarlo tarda un minuto.</string>
|
||||
<string name="setup_fix_it">Solucionar</string>
|
||||
<string name="setup_details">Detalles</string>
|
||||
<string name="setup_dev_options">Opciones de desarrollador</string>
|
||||
<string name="setup_use_wireless">Usar Depuración inalámbrica</string>
|
||||
<string name="setup_check_again">Comprobar de nuevo</string>
|
||||
<string name="setup_limit_turn_on">Activar límite</string>
|
||||
<string name="setup_account">Cuenta de Steam</string>
|
||||
<string name="setup_signed_in_offline">Sesión iniciada como %1$s · modo sin conexión</string>
|
||||
<string name="setup_signed_in">Sesión iniciada como %1$s</string>
|
||||
<string name="setup_sign_in">Pulsa Play e inicia sesión en Steam</string>
|
||||
<string name="setup_tools">Herramientas</string>
|
||||
<string name="setup_controller">Mando</string>
|
||||
<string name="setup_controller_unavailable">Los ajustes del mando no están disponibles.</string>
|
||||
<string name="setup_session">Sesión</string>
|
||||
<string name="setup_logs">Registros de sesión</string>
|
||||
<string name="setup_logs_hint">Se guardan tras cada sesión</string>
|
||||
<string name="setup_latest_logs">Últimos registros de sesión</string>
|
||||
<string name="setup_saved_logs">Registros de sesión guardados</string>
|
||||
<string name="setup_saved_logs_hint">Conserva los %1$d más recientes</string>
|
||||
<string name="setup_clear_logs">Borrar registros</string>
|
||||
<string name="setup_offline">Modo sin conexión</string>
|
||||
<string name="setup_sign_in_first">Inicia sesión en Steam primero</string>
|
||||
<string name="setup_launcher">Launcher</string>
|
||||
<string name="setup_theme">Tema</string>
|
||||
<string name="setup_theme_hint">Apariencia del launcher</string>
|
||||
<string name="setup_home">Usar como pantalla de inicio</string>
|
||||
<string name="setup_home_on">DroidDeck puede ser la app de inicio</string>
|
||||
<string name="setup_home_off">DroidDeck nunca se ofrece como app de inicio</string>
|
||||
<string name="setup_fullscreen">Pantalla completa</string>
|
||||
<string name="setup_fullscreen_on">Oculta las barras de estado y navegación de Android</string>
|
||||
<string name="setup_fullscreen_off">Muestra las barras de estado y navegación de Android</string>
|
||||
<string name="setup_default_home">App de inicio predeterminada</string>
|
||||
<string name="setup_choose_home">Elige una app de inicio</string>
|
||||
<string name="setup_choose">Elegir</string>
|
||||
<string name="setup_linux_apps">Apps de Linux (beta)</string>
|
||||
<string name="setup_store">Tienda Flathub</string>
|
||||
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Instala apps y juegos de Linux desde Flathub</string>
|
||||
<string name="setup_tool_files">Archivos</string>
|
||||
<string name="setup_tool_files_hint">Explorar y gestionar archivos</string>
|
||||
<string name="setup_tool_protons">Versiones de Proton</string>
|
||||
<string name="setup_tool_protons_hint">Instala versiones de Proton ARM64</string>
|
||||
<string name="setup_tool_performance">Rendimiento</string>
|
||||
<string name="setup_tool_performance_hint">Asignación de núcleos de CPU</string>
|
||||
<string name="setup_tool_roms">Carpeta de ROMs</string>
|
||||
<string name="setup_tool_roms_hint">Donde se guardan los juegos de emuladores</string>
|
||||
<string name="setup_card_components">Componentes</string>
|
||||
<string name="setup_card_controls">Controles</string>
|
||||
<string name="setup_card_controls_hint">Asignación de botones</string>
|
||||
<string name="store_tab_discover">Descubrir</string>
|
||||
<string name="store_tab_search">Buscar</string>
|
||||
<string name="store_tab_installed">Instaladas</string>
|
||||
<string name="store_title">Tienda</string>
|
||||
<string name="store_chip_ready">● Flatpak listo</string>
|
||||
<string name="store_needs_runtime">Instala primero el runtime de Linux desde Configuración.</string>
|
||||
<string name="store_setup_title">Configurar Flatpak</string>
|
||||
<string name="store_setup_text">Añade Flatpak y lo conecta a Flathub (unos 20 MB). La primera app también descarga su runtime.</string>
|
||||
<string name="store_setting_up">Configurando…</string>
|
||||
<string name="store_updates">Actualizaciones</string>
|
||||
<string name="store_starting">Iniciando…</string>
|
||||
<string name="store_categories">Categorías</string>
|
||||
<string name="store_unreachable">No se pudo conectar con Flathub. Revisa tu conexión e inténtalo de nuevo.</string>
|
||||
<string name="store_try_again">Reintentar</string>
|
||||
<string name="store_search_label">Buscar en Flathub</string>
|
||||
<string name="store_search_placeholder">Firefox, juegos, emuladores…</string>
|
||||
<string name="store_search">Buscar</string>
|
||||
<string name="store_all">Todas</string>
|
||||
<string name="store_searching">Buscando…</string>
|
||||
<string name="store_search_hint">Busca por nombre o elige una categoría. Solo se listan apps ARM64.</string>
|
||||
<string name="store_no_results">Ninguna app ARM64 de Flathub coincide.</string>
|
||||
<string name="store_results">Resultados</string>
|
||||
<string name="store_check_updates">Buscar actualizaciones</string>
|
||||
<string name="store_update_all">Actualizar todas (%1$d)</string>
|
||||
<string name="store_all_current">● Todo está al día</string>
|
||||
<string name="store_no_apps">Aún no hay apps. Encuentra alguna en Descubrir o Buscar.</string>
|
||||
<string name="store_setup_first_note">Configura Flatpak para instalar apps.</string>
|
||||
<string name="store_update_available_prefix">Actualización disponible · %1$s</string>
|
||||
<string name="store_open">Abrir</string>
|
||||
<string name="store_details">Detalles</string>
|
||||
<string name="store_remove">Quitar</string>
|
||||
<string name="store_press_again">Pulsa de nuevo para quitar</string>
|
||||
<string name="store_installing">Instalando…</string>
|
||||
<string name="store_install">Instalar</string>
|
||||
<string name="store_setup_first">Configura Flatpak primero</string>
|
||||
<string name="store_download_size">Descarga de %1$s</string>
|
||||
<string name="store_installed_chip">● Instalada</string>
|
||||
<string name="store_details_failed">No se pudo cargar la página de esta app en Flathub.</string>
|
||||
<string name="store_loading">Cargando desde Flathub…</string>
|
||||
<string name="store_no_arm">No hay versión ARM64 en Flathub, así que no se puede instalar aquí.</string>
|
||||
<string name="store_screenshots">Capturas de pantalla</string>
|
||||
<string name="store_about">Acerca de</string>
|
||||
<string name="store_app_id">ID de app</string>
|
||||
<string name="store_license">Licencia</string>
|
||||
<string name="store_runtime">Runtime</string>
|
||||
<string name="store_installed_size">Tamaño instalado</string>
|
||||
<string name="store_installed_size_value">%1$s (más su runtime compartido)</string>
|
||||
<string name="store_website">Sitio web</string>
|
||||
<string name="store_verified">Verificada</string>
|
||||
<string name="upd_title">Actualizaciones</string>
|
||||
<string name="upd_checked">Comprobado %1$s</string>
|
||||
<string name="upd_not_checked">Aún sin comprobar</string>
|
||||
<string name="upd_perm_title">Permitir que DroidDeck se actualice solo</string>
|
||||
<string name="upd_perm_text">En la siguiente pantalla, activa Permitir desde esta fuente y vuelve. La actualización continúa sola.</string>
|
||||
<string name="upd_open_settings">Abrir ajustes</string>
|
||||
<string name="upd_not_now">Ahora no</string>
|
||||
<string name="upd_signed_status">Firmada de otro modo</string>
|
||||
<string name="upd_signed_headline">No se puede actualizar sobre la instalada</string>
|
||||
<string name="upd_signed_detail">Esta copia no está firmada con la clave de publicación de DroidDeck, así que las actualizaciones no se pueden instalar sobre ella. Desinstálala para cambiar.</string>
|
||||
<string name="upd_available">Actualización disponible</string>
|
||||
<string name="upd_blocked_status">No se puede instalar</string>
|
||||
<string name="upd_switch">Lista para cambiar</string>
|
||||
<string name="upd_ahead_status">Por delante de Stable</string>
|
||||
<string name="upd_ahead_headline">Vas por delante de Stable</string>
|
||||
<string name="upd_ahead_detail">Esta versión es más reciente que la última Stable. Pasarás a Stable con su próxima versión.</string>
|
||||
<string name="upd_gone_status">Prueba finalizada</string>
|
||||
<string name="upd_gone_headline">Esta prueba ha terminado</string>
|
||||
<string name="upd_gone_detail">Su corrección se fusionó o se descartó. Sigue Preview para las correcciones más recientes.</string>
|
||||
<string name="upd_current_status">Al día</string>
|
||||
<string name="upd_current_headline">Tienes la última %1$s</string>
|
||||
<string name="upd_update_button">Actualizar%1$s</string>
|
||||
<string name="upd_install_button">Instalar%1$s</string>
|
||||
<string name="upd_install_anyway">Instalar Stable %1$s de todos modos</string>
|
||||
<string name="upd_follow_preview">Seguir Preview</string>
|
||||
<string name="upd_stop_session">Detén la sesión en ejecución para actualizar.</string>
|
||||
<string name="upd_less">Menos</string>
|
||||
<string name="upd_more">Más</string>
|
||||
<string name="upd_new_preview">Nueva versión Preview</string>
|
||||
<string name="upd_new_test">Versión de prueba del PR #%1$d</string>
|
||||
<string name="upd_channel">CANAL DE ACTUALIZACIÓN</string>
|
||||
<string name="upd_stable_hint">Versiones probadas, para la mayoría</string>
|
||||
<string name="upd_preview_hint">Las correcciones más recientes, antes que Stable</string>
|
||||
<string name="upd_tests">Versiones de prueba</string>
|
||||
<string name="upd_tests_hint">Prueba una corrección antes de su publicación</string>
|
||||
<string name="upd_tests_none">Ninguna por ahora</string>
|
||||
<string name="upd_not_built">No compilada para esta copia de DroidDeck</string>
|
||||
<string name="upd_updated">Actualizada %1$s</string>
|
||||
<string name="upd_test_name">la prueba del PR #%1$d</string>
|
||||
<string name="upd_while_ago">hace un tiempo</string>
|
||||
<string name="upd_just_now">ahora mismo</string>
|
||||
<plurals name="upd_tests_count">
|
||||
<item quantity="one">%1$d para probar</item>
|
||||
<item quantity="other">%1$d para probar</item>
|
||||
</plurals>
|
||||
<string name="gate_title">Un ajuste de Android por cambiar</string>
|
||||
<string name="gate_lede">Android detiene las apps que inician muchos procesos en segundo plano, y Steam inicia decenas. Hasta que esto cambie, los juegos se cierran sin mostrar ningún error.</string>
|
||||
<string name="gate_fix_for_me">Solucionarlo por mí</string>
|
||||
<string name="gate_toggle_steps">Activa “Desactivar restricciones de procesos secundarios” y vuelve. Deja activadas las Opciones de desarrollador o el interruptor se desactivará de nuevo.</string>
|
||||
<string name="gate_open_dev">Abrir Opciones de desarrollador</string>
|
||||
<string name="gate_no_toggle">Esta versión de Android no tiene un interruptor para ello, así que DroidDeck lo cambia mediante la Depuración inalámbrica. Tarda un minuto y no necesitas ordenador.</string>
|
||||
<string name="gate_needs_wifi">La Depuración inalámbrica necesita Wi-Fi. Sirve cualquier red; no necesita internet.</string>
|
||||
<string name="gate_needs_notifications">El código de vinculación aparece en una notificación. Permite las notificaciones de DroidDeck o usa Otras formas.</string>
|
||||
<string name="gate_allow_notifications">Permitir notificaciones</string>
|
||||
<string name="gate_step1">Abre Ajustes → Opciones de desarrollador → Depuración inalámbrica y actívala</string>
|
||||
<string name="gate_step2">Toca “Vincular dispositivo con código de vinculación”</string>
|
||||
<string name="gate_step3">Escribe el código en la notificación de DroidDeck</string>
|
||||
<string name="gate_waiting">Esperando la ventana de vinculación…</string>
|
||||
<string name="gate_code_needed">Ventana encontrada. Introduce su código en la notificación.</string>
|
||||
<string name="gate_status">Estado</string>
|
||||
<string name="gate_polling">Se comprueba cada 2 segundos. Esta página se cierra cuando esté desactivado.</string>
|
||||
<string name="gate_other_ways">Otras formas</string>
|
||||
<string name="gate_other_toggle">Depuración inalámbrica, dirección manual, ADB desde ordenador</string>
|
||||
<string name="gate_other_no_toggle">Dirección manual, ADB desde ordenador, Opciones de desarrollador</string>
|
||||
<string name="gate_use_wireless_instead">Usar Depuración inalámbrica en su lugar</string>
|
||||
<string name="gate_some_roms">Algunas ROM añaden el interruptor de todos modos. Busca “procesos secundarios” en Opciones de desarrollador.</string>
|
||||
<string name="gate_manual_hint">Si la notificación no funciona, introduce tú mismo la dirección y el código de la ventana.</string>
|
||||
<string name="gate_enter_manually">Introducir dirección manualmente</string>
|
||||
<string name="gate_from_computer">Desde un ordenador con ADB:</string>
|
||||
<string name="gate_copy_command">Copiar comando</string>
|
||||
<string name="gate_choose_display">Elige en qué pantalla abrir los Ajustes de Android.</string>
|
||||
<string name="gate_main_screen">Pantalla principal</string>
|
||||
<string name="wadb_finding_port">Vinculado. Buscando el puerto de la Depuración inalámbrica…</string>
|
||||
<string name="wadb_enter_port">Vinculado. Introduce el puerto de Ajustes.</string>
|
||||
<string name="wadb_applying">Dispositivo encontrado. Aplicando el ajuste…</string>
|
||||
<string name="wadb_apply_failed">No se pudo aplicar automáticamente. Revisa la dirección de abajo y reintenta. %1$s</string>
|
||||
<string name="wadb_title_wireless">Depuración inalámbrica</string>
|
||||
<string name="wadb_title_done">Límite de procesos secundarios actualizado</string>
|
||||
<string name="wadb_title">Cambiar el límite de procesos secundarios</string>
|
||||
<string name="wadb_lede_compact0">Si falta la opción, vincula mediante la Depuración inalámbrica.</string>
|
||||
<string name="wadb_lede_compact1">Vinculado. Revisa la dirección IP y el puerto en Depuración inalámbrica.</string>
|
||||
<string name="wadb_lede_done">El límite de procesos secundarios se actualizó.</string>
|
||||
<string name="wadb_lede0">Usa el paso 2 si las Opciones de desarrollador no tienen un ajuste de procesos secundarios. Abre Opciones de desarrollador → Depuración inalámbrica → Vincular dispositivo con código de vinculación.</string>
|
||||
<string name="wadb_lede1">Vinculado. Usa la dirección de abajo o actualízala desde Depuración inalámbrica → Dirección IP y puerto.</string>
|
||||
<string name="wadb_lede_done_long">El límite de procesos secundarios se actualizó mediante la Depuración inalámbrica.</string>
|
||||
<string name="wadb_confirmed_on">Android confirmó que el límite está activado.</string>
|
||||
<string name="wadb_confirmed_off">Android confirmó que el límite está desactivado.</string>
|
||||
<string name="wadb_pair_group">2 · Vincular Depuración inalámbrica</string>
|
||||
<string name="wadb_pair_address">Dirección IP y puerto de la ventana de vinculación</string>
|
||||
<string name="wadb_pin">Código de vinculación (PIN)</string>
|
||||
<string name="wadb_pin_placeholder">6 dígitos</string>
|
||||
<string name="wadb_pair">Vincular</string>
|
||||
<string name="wadb_apply_group">2 · Aplicar mediante Depuración inalámbrica</string>
|
||||
<string name="wadb_connect_address">Dirección IP y puerto de conexión</string>
|
||||
<string name="wadb_pair_again">Vincular de nuevo</string>
|
||||
<string name="wadb_turn_off">Desactivar límite</string>
|
||||
<string name="wadb_other_ways">Otras formas de cambiarlo</string>
|
||||
<string name="wadb_dev_try_first">1 · Opciones de desarrollador (prueba primero)</string>
|
||||
<string name="wadb_restrict_on">Activa “Restringir procesos secundarios”.</string>
|
||||
<string name="wadb_restrict_off">Desactiva “Restringir procesos secundarios”.</string>
|
||||
<string name="wadb_computer_last">3 · ADB desde ordenador (último recurso)</string>
|
||||
<string name="wadb_only_if">Solo si la Depuración inalámbrica no está disponible.</string>
|
||||
<string name="wadb_computer_group">3 · ADB desde ordenador (último recurso)</string>
|
||||
<string name="ctrl_osc_hint">Cuándo aparecen los controles táctiles en una sesión</string>
|
||||
<string name="ctrl_color">Color</string>
|
||||
<string name="ctrl_color_hint">Tinte de los botones en pantalla</string>
|
||||
<string name="ctrl_custom">Personalizado</string>
|
||||
<string name="ctrl_opacity">Opacidad</string>
|
||||
<string name="ctrl_size">Tamaño de botones</string>
|
||||
<string name="ctrl_size_hint" formatted="false">100% es el tamaño estándar</string>
|
||||
<string name="ctrl_stick_click">Clic del stick</string>
|
||||
<string name="ctrl_stick_click_hint">Toca dos veces y mantén un stick para L3 o R3</string>
|
||||
<string name="ctrl_adaptive">Sticks adaptables</string>
|
||||
<string name="ctrl_adaptive_hint">Los sticks aparecen donde tocas y se ocultan al soltar</string>
|
||||
<string name="ctrl_layout">Disposición</string>
|
||||
<string name="ctrl_layout_custom">Posiciones personalizadas guardadas</string>
|
||||
<string name="ctrl_layout_default">Ajustada a esta pantalla</string>
|
||||
<string name="ctrl_edit">Editar</string>
|
||||
<string name="ctrl_reset">Restablecer</string>
|
||||
<string name="ctrl_mapping">Asignación de botones</string>
|
||||
<string name="ctrl_mapping_default">Cada botón envía su propia entrada</string>
|
||||
<string name="ctrl_mapping_count">%1$d de %2$d botones reasignados</string>
|
||||
<string name="ctrl_configure">Configurar</string>
|
||||
<string name="ctrl_reset_all">Restablecer mando</string>
|
||||
<string name="ctrl_reset_all_hint">Restaura todos los ajustes del mando y la disposición</string>
|
||||
<string name="ctrl_mapping_lede">Lo que cada botón en pantalla envía al juego. Oculto quita el botón.</string>
|
||||
<string name="ctrl_onscreen_buttons">Botones en pantalla</string>
|
||||
<string name="ctrl_defaults">Predeterminados</string>
|
||||
<string name="ctrl_reset_mapping">Restablecer asignación</string>
|
||||
<string name="content_android_apps">Apps de Android</string>
|
||||
<string name="content_apps_count">%1$d apps</string>
|
||||
<string name="content_no_android_apps">No se encontraron apps de Android que se puedan abrir.</string>
|
||||
<string name="content_desktop_installed">● Escritorio instalado</string>
|
||||
<string name="content_desktop_first_open">Se instala al abrirlo por primera vez</string>
|
||||
<string name="content_available">Disponible para instalar</string>
|
||||
<string name="content_not_installed">No instalado.</string>
|
||||
<string name="content_not_installed_suffix">%1$s · no instalado</string>
|
||||
<string name="content_open_named">Abrir %1$s</string>
|
||||
<string name="content_games">Juegos</string>
|
||||
<string name="content_choose_roms">Elige una carpeta de ROMs.</string>
|
||||
<string name="content_retroarch_roms">Busca /root/ROMs en RetroArch.</string>
|
||||
<string name="content_add_roms">Añade juegos de %1$s a ROMs/%2$s.</string>
|
||||
<string name="content_install_named">Instalar %1$s</string>
|
||||
<string name="content_stop_to_install">Detén la sesión para instalar</string>
|
||||
<string name="content_runtime_required">Runtime necesario</string>
|
||||
<string name="content_loading_details">Cargando detalles de instalación…</string>
|
||||
<string name="content_details_unavailable">Los detalles de instalación no están disponibles. Inténtalo más tarde.</string>
|
||||
<string name="content_needs_runtime">Instala primero el runtime de Linux desde Configuración.</string>
|
||||
<string name="content_stop_first">Detén la sesión en ejecución para instalar apps de escritorio.</string>
|
||||
<string name="content_install_into">Instala %1$s en el escritorio de Linux.</string>
|
||||
<string name="content_hide_note">Ocultar la quita de Escritorio. Sus archivos permanecen en el runtime de Linux.</string>
|
||||
<string name="content_rom_gone">Ese juego ya no está en la carpeta de ROMs.</string>
|
||||
<string name="content_desktop_system">Escritorio · %1$s</string>
|
||||
<string name="content_launch_in">Iniciar en %1$s</string>
|
||||
<string name="content_also_in">También en %1$s</string>
|
||||
<string name="content_page_gone">Esa página ya no existe. Elige una sección a la izquierda.</string>
|
||||
<string name="content_open_desktop">Abrir escritorio</string>
|
||||
<string name="content_install_open_desktop">Instalar y abrir escritorio</string>
|
||||
<string name="content_linux_desktop">Escritorio de Linux</string>
|
||||
<string name="content_linux_desktop_hint">LXQt, Firefox y tus emuladores</string>
|
||||
<string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string>
|
||||
<string name="games_play_steam">Jugar en Steam</string>
|
||||
<string name="games_launch_settings">Ajustes de inicio</string>
|
||||
<string name="games_launch">Iniciar</string>
|
||||
<string name="games_files">Archivos del juego</string>
|
||||
<string name="games_prefix">Prefijo de Proton</string>
|
||||
<string name="games_saves_looking">Buscando partidas guardadas…</string>
|
||||
<string name="games_saves_none">No se encontró ninguna carpeta de guardado por nombre. Exportar toma toda la carpeta de usuario.</string>
|
||||
<string name="games_saves_folders">%1$d carpetas de guardado</string>
|
||||
<string name="games_saves_summary">%1$s · %2$d archivos · %3$s</string>
|
||||
<string name="games_manage_saves">Gestionar partidas guardadas</string>
|
||||
<string name="games_saves">Partidas guardadas</string>
|
||||
<string name="games_import_saves">Importar partidas guardadas…</string>
|
||||
<string name="games_import_saves_hint">Zip de GameHub o Winlator. Antes se hace una copia de las partidas actuales.</string>
|
||||
<string name="games_export_gamehub">Exportar como zip de GameHub…</string>
|
||||
<string name="games_export_gamehub_hint">Para GameHub y BannerHub</string>
|
||||
<string name="games_export_winlator">Exportar como zip de Winlator…</string>
|
||||
<string name="games_export_winlator_hint">Para Winlator, WinNative y Bannerlator</string>
|
||||
<string name="games_open_save_folder">Abrir carpeta de guardado</string>
|
||||
<string name="games_never_played">Nunca jugado</string>
|
||||
<string name="games_runtime_busy_percent">Runtime ocupado · %1$d%%</string>
|
||||
<string name="games_runtime_busy">Runtime ocupado</string>
|
||||
<string name="games_runtime_first_play">El runtime se instala en el primer Play</string>
|
||||
<string name="games_runtime_update">Actualización del runtime disponible</string>
|
||||
<string name="games_runtime_ready">● Runtime listo</string>
|
||||
<string name="games_last_played">Última partida %1$s</string>
|
||||
<string name="games_internal">Almacenamiento interno</string>
|
||||
<string name="games_added">Juego añadido</string>
|
||||
<string name="gpu_auto_hint">El mejor par para esta GPU, siempre actualizado</string>
|
||||
<string name="gpu_manual_hint">Elige un par tú mismo</string>
|
||||
<string name="gpu_checking">Buscando controladores…</string>
|
||||
<string name="gpu_none_listed">Aún no hay controladores listados. Actualiza para buscarlos.</string>
|
||||
<string name="gpu_hide_other">Ocultar controladores de otras GPU</string>
|
||||
<string name="gpu_show_other">Mostrar controladores de otras GPU</string>
|
||||
<string name="gpu_advanced">Avanzado</string>
|
||||
<string name="gpu_set_with_display">Se define junto con el controlador de pantalla: %1$s incluye ambos.</string>
|
||||
<string name="gpu_set_with_runtime">Se define junto con el controlador de runtime: %1$s incluye ambos.</string>
|
||||
<string name="gpu_runtime_hint">Lo usan los juegos y el escritorio.</string>
|
||||
<string name="gpu_runtime_default">Predeterminado del runtime</string>
|
||||
<string name="gpu_display_hint">Dibuja los fotogramas en pantalla. Se aplica al reiniciar DroidDeck.</string>
|
||||
<string name="gpu_auto_by_gpu">Auto - elegido según la GPU</string>
|
||||
<string name="gpu_manage">Descargar, importar o eliminar…</string>
|
||||
<string name="gpu_in_use_caps">EN USO</string>
|
||||
<string name="gpu_builtin">Los controladores integrados en DroidDeck</string>
|
||||
<string name="gpu_matched_pair">Controladores de runtime y de pantalla definidos juntos como un par compatible.</string>
|
||||
<string name="gpu_recommended_for">Recomendado para esta GPU: %1$s.</string>
|
||||
<string name="gpu_refresh_hint">Actualiza para buscar controladores para esta GPU.</string>
|
||||
<string name="gpu_recommended_caps">RECOMENDADO</string>
|
||||
<string name="gpu_unavailable">No disponible</string>
|
||||
<string name="load_runtime">Runtime de Linux</string>
|
||||
<string name="load_runtime_detail">Preparando el runtime de Linux</string>
|
||||
<string name="load_session">Sesión</string>
|
||||
<string name="load_session_detail">Iniciando pantalla y audio</string>
|
||||
<string name="load_steam">Cliente de Steam</string>
|
||||
<string name="load_steam_detail">Comprobando Steam y sus herramientas de compatibilidad</string>
|
||||
<string name="load_opening_steam">Abriendo Steam</string>
|
||||
<string name="load_opening_steam_detail">Esperando el primer fotograma de Steam</string>
|
||||
<string name="load_desktop">Escritorio</string>
|
||||
<string name="load_desktop_detail">Preparando el escritorio</string>
|
||||
<string name="load_opening">Abriendo</string>
|
||||
<string name="load_opening_detail">Esperando el primer fotograma</string>
|
||||
<string name="load_starting_steam">Iniciando Steam</string>
|
||||
<string name="load_ended">La sesión terminó</string>
|
||||
<string name="load_step">Paso %1$d de %2$d</string>
|
||||
<string name="load_steam_paused">Steam está en pausa</string>
|
||||
<string name="deck_tab_grips">Agarres</string>
|
||||
<string name="deck_tab_left">Pad izquierdo</string>
|
||||
<string name="deck_tab_right">Pad derecho</string>
|
||||
<string name="deck_tab_both">Ambos</string>
|
||||
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
|
||||
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
|
||||
<string name="deck_left_upper">Izquierdo · superior</string>
|
||||
<string name="deck_left_lower">Izquierdo · inferior</string>
|
||||
<string name="deck_right_upper">Derecho · superior</string>
|
||||
<string name="deck_right_lower">Derecho · inferior</string>
|
||||
<string name="deck_click">Clic</string>
|
||||
<string name="deck_right_trackpad">Trackpad derecho</string>
|
||||
<string name="deck_left_trackpad">Trackpad izquierdo</string>
|
||||
<string name="second_trackpad">Trackpad</string>
|
||||
<string name="second_show_keyboard">Mostrar teclado</string>
|
||||
<string name="second_hide_keyboard">Ocultar teclado</string>
|
||||
<string name="second_terminal">Terminal de Linux</string>
|
||||
<string name="second_terminal_not_ready">La sesión de Linux aún no está lista para un terminal.</string>
|
||||
<string name="rail_games">Juegos</string>
|
||||
<string name="rail_desktop">Escritorio</string>
|
||||
<string name="rail_store">Tienda</string>
|
||||
<string name="rail_components">Componentes</string>
|
||||
<string name="rail_apps">Apps</string>
|
||||
<string name="rail_setup">Configuración</string>
|
||||
<string name="rail_updates">Actualizar</string>
|
||||
<string name="rail_resume_named">Reanudar %1$s</string>
|
||||
<string name="roms_text">Aparece como /root/ROMs en cada sesión. El almacenamiento interno está en /root/Storage.</string>
|
||||
<string name="roms_none">Ninguna carpeta elegida</string>
|
||||
<string name="roms_applies">Se aplica en la próxima sesión. Las tarjetas SD funcionan.</string>
|
||||
<string name="roms_choose">Elegir carpeta</string>
|
||||
<string name="roms_change">Cambiar carpeta</string>
|
||||
<string name="proton_lede">Instala una versión de Proton ARM64 y luego elígela para cada juego en Steam → Propiedades → Compatibilidad.</string>
|
||||
<string name="proton_stop_first">Detén la sesión en ejecución para instalar o quitar una herramienta de compatibilidad.</string>
|
||||
<string name="proton_available">Versiones disponibles</string>
|
||||
<string name="proton_installed">Instalada %1$s</string>
|
||||
<string name="proton_queued">Pendiente de una solicitud anterior</string>
|
||||
<string name="proton_not_installed">No instalada</string>
|
||||
<string name="proton_install_now">Instalar ahora</string>
|
||||
<string name="proton_cancel_queued">Cancelar en cola</string>
|
||||
<string name="pckb_android">Teclado de Android</string>
|
||||
<string name="pckb_hide">Ocultar ✕</string>
|
||||
<string name="main_launch_named">Iniciar %1$s</string>
|
||||
<string name="main_choose_screen">Elige una pantalla. Secundaria: %1$s.</string>
|
||||
<string name="main_secondary_gone">La pantalla secundaria ya no está disponible.</string>
|
||||
<string name="main_primary">Pantalla principal</string>
|
||||
<string name="main_secondary">Pantalla secundaria</string>
|
||||
<string name="widgets_prev_tab">Pestaña anterior</string>
|
||||
<string name="widgets_next_tab">Pestaña siguiente</string>
|
||||
<string name="widgets_on">Activado</string>
|
||||
<string name="widgets_all_n">Todos %1$d</string>
|
||||
<string name="widgets_none">Ninguno</string>
|
||||
<string name="widgets_n_of_m">%1$d de %2$d</string>
|
||||
<string name="widgets_off">Desactivado</string>
|
||||
<string name="screen_bottom">Pantalla inferior</string>
|
||||
<string name="game_env_applies">Se aplica al iniciar el próximo juego. El perfil de un juego tiene prioridad sobre el compartido.</string>
|
||||
<string name="game_env_other">Otro juego por ID de app…</string>
|
||||
<string name="game_env_done">Listo</string>
|
||||
<string name="game_env_default">Predeterminado</string>
|
||||
<string name="game_env_off">Desactivado</string>
|
||||
<string name="game_env_change">Cambiar…</string>
|
||||
<string name="game_env_change_title">Cambiar variable</string>
|
||||
<string name="game_env_restore_default">Volver al predeterminado</string>
|
||||
<string name="game_env_eyebrow">Juegos de Proton</string>
|
||||
</resources>
|
||||
@@ -41,36 +41,32 @@
|
||||
<string name="hud_fg_failed">%1$s: engine failed</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · starting</string>
|
||||
<string name="added_games_import_hint">Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games.</string>
|
||||
<string name="second_library_import_hint">Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts.</string>
|
||||
<string name="added_games_forget_hint">Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk.</string>
|
||||
<string name="added_games_import_hint">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts.</string>
|
||||
<string name="second_library_import_hint">Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts.</string>
|
||||
<string name="added_games_forget_hint">Forget removes the shortcuts next session. Files stay on disk.</string>
|
||||
<string name="game_env_title">Game environment</string>
|
||||
<string name="game_env_hint">Shared defaults and per-game overrides for games using Proton. Applies on the next game launch.</string>
|
||||
<string name="game_env_edit">Edit variables</string>
|
||||
<string name="game_env_hint">Environment variables for Proton games.</string>
|
||||
<string name="game_env_edit">Edit</string>
|
||||
<string name="game_env_shared">All Proton games</string>
|
||||
<string name="game_env_scope">Game profile</string>
|
||||
<string name="game_env_app_id">Steam app ID or non-Steam compatibility ID</string>
|
||||
<string name="game_env_open">Open profile</string>
|
||||
<string name="game_env_scope">Profile</string>
|
||||
<string name="game_env_app_id">Steam app ID</string>
|
||||
<string name="game_env_open">Open</string>
|
||||
<string name="game_env_profile">Game %1$s</string>
|
||||
<string name="game_env_add">Add variable</string>
|
||||
<string name="game_env_name">Variable name</string>
|
||||
<string name="game_env_value">Value</string>
|
||||
<string name="game_env_save">Save</string>
|
||||
<string name="game_env_cancel">Cancel</string>
|
||||
<string name="game_env_close">Close</string>
|
||||
<string name="game_env_remove">Remove</string>
|
||||
<string name="game_env_restore">Restore inherited settings</string>
|
||||
<string name="game_env_restore">Inherit again</string>
|
||||
<string name="game_env_reset">Reset this profile</string>
|
||||
<string name="game_env_unset">Unset for this profile</string>
|
||||
<string name="game_env_empty">No variables set. Driver and Proton defaults apply.</string>
|
||||
<string name="game_env_empty">No variables set.</string>
|
||||
<string name="game_env_inherited">Inherited</string>
|
||||
<string name="game_env_custom">Custom variable</string>
|
||||
<string name="game_env_defaults">D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game.</string>
|
||||
<string name="game_env_error">Could not load or apply the settings. Check available storage and try again.</string>
|
||||
<string name="game_env_invalid">Use a valid variable name and a value without NUL characters (up to 8192 characters).</string>
|
||||
<string name="game_env_unused">The standard Linux Proton runtime does not use this variable; it may require a custom component.</string>
|
||||
<string name="game_env_error">Couldn\'t load or save. Check free storage and try again.</string>
|
||||
<string name="game_env_invalid">Use a valid name and a value up to 8192 characters.</string>
|
||||
<string name="game_env_unused">The standard Proton runtime doesn\'t read this variable.</string>
|
||||
<string name="game_env_loading">Loading…</string>
|
||||
<string name="game_env_overrides">Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value.</string>
|
||||
<string name="game_env_feature">D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection.</string>
|
||||
<string name="game_env_shader">D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection.</string>
|
||||
<string name="game_env_cache">Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching.</string>
|
||||
@@ -108,4 +104,800 @@
|
||||
<string name="fex_denuvo_detail">Performance with full code checks and the hypervisor bit hidden.</string>
|
||||
<string name="game_env_custom_value">Custom value</string>
|
||||
<string name="game_env_select_value">Select value</string>
|
||||
<string name="user_apps_section">Emulators & apps</string>
|
||||
<string name="user_apps_add">Add</string>
|
||||
<string name="user_apps_add_detail">Your own app or game</string>
|
||||
<string name="user_apps_kind_script">Script</string>
|
||||
<string name="user_apps_kind_appimage">AppImage</string>
|
||||
<string name="user_apps_kind_flatpak">Flatpak</string>
|
||||
<string name="user_apps_progress">%1$s · %2$s</string>
|
||||
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
|
||||
<string name="user_apps_starting">Starting…</string>
|
||||
<string name="user_apps_added">%1$s added</string>
|
||||
<string name="user_apps_removed">%1$s removed</string>
|
||||
<string name="user_apps_back">Desktop</string>
|
||||
<string name="user_apps_gone">That app is gone. Pick it again from Desktop.</string>
|
||||
<string name="user_apps_open">Open %1$s</string>
|
||||
<string name="user_apps_remove">Remove</string>
|
||||
<string name="user_apps_remove_confirm">Press again to remove</string>
|
||||
<string name="user_apps_stop_session">Stop the session to remove</string>
|
||||
<string name="user_apps_script_linked">Runs %1$s from its folder, linked into the session where it lies. Removing the app leaves that folder alone.</string>
|
||||
<string name="user_apps_script_copied">Runs %1$s from a copy of its folder inside DroidDeck. Removing the app deletes the copy, not the original.</string>
|
||||
<string name="user_apps_runtime_required">Install the Linux runtime first</string>
|
||||
<string name="user_apps_file_gone">The file is gone</string>
|
||||
<string name="user_apps_no_space">Not enough free space: copying the folder needs %1$s</string>
|
||||
<string name="user_apps_copying">Copying %1$s</string>
|
||||
<string name="user_apps_failed">Adding the app failed</string>
|
||||
<string name="user_apps_github_checking">Looking for a release of %1$s</string>
|
||||
<string name="user_apps_github_unreachable">Could not read the releases of %1$s</string>
|
||||
<string name="user_apps_github_no_appimage">No release of %1$s has an ARM64 AppImage</string>
|
||||
<string name="user_apps_downloading">Downloading %1$s</string>
|
||||
<string name="user_apps_download_failed">Download failed</string>
|
||||
<string name="user_apps_update_failed">Updating the app failed</string>
|
||||
<string name="user_apps_icon_failed">That icon could not be read</string>
|
||||
<string name="user_apps_saved">%1$s saved</string>
|
||||
<string name="user_apps_edit">Edit</string>
|
||||
<string name="user_apps_check_updates">Check for updates</string>
|
||||
<string name="user_apps_checking_updates">Checking for updates…</string>
|
||||
<string name="user_apps_update_to">Update to %1$s</string>
|
||||
<string name="user_apps_up_to_date">Up to date · %1$s</string>
|
||||
<string name="user_apps_updated">%1$s updated</string>
|
||||
<string name="user_apps_github_version">%1$s · %2$s</string>
|
||||
<string name="add_app_title">Add an app</string>
|
||||
<string name="add_app_tab_script">Script</string>
|
||||
<string name="add_app_tab_appimage">AppImage</string>
|
||||
<string name="add_app_tab_github">GitHub</string>
|
||||
<string name="add_app_tab_flatpak">Flatpak</string>
|
||||
<string name="add_app_script_hint">A .sh file. It runs from its own folder, full screen under gamescope.</string>
|
||||
<string name="add_app_appimage_hint">An ARM64 (aarch64) AppImage. It is unpacked into the Linux runtime once.</string>
|
||||
<string name="add_app_github_hint">Installs the ARM64 AppImage from the repository\'s newest release.</string>
|
||||
<string name="add_app_flatpak_hint">A Flathub app ID or its Flathub page. Flatpak is set up first if it is not yet.</string>
|
||||
<string name="add_app_file">File</string>
|
||||
<string name="add_app_no_file">No file chosen</string>
|
||||
<string name="add_app_choose_file">Choose file</string>
|
||||
<string name="add_app_change_file">Change</string>
|
||||
<string name="add_app_pick_script">Choose a script</string>
|
||||
<string name="add_app_pick_appimage">Choose an AppImage</string>
|
||||
<string name="add_app_pick_icon">Choose an icon</string>
|
||||
<string name="add_app_repository">Repository</string>
|
||||
<string name="add_app_repository_placeholder">owner/repo or a github.com link</string>
|
||||
<string name="add_app_repository_invalid">Not a GitHub repository</string>
|
||||
<string name="add_app_flatpak_id">App ID</string>
|
||||
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
|
||||
<string name="add_app_flatpak_invalid">Not a Flathub app ID</string>
|
||||
<string name="add_app_source">Source</string>
|
||||
<string name="add_app_appearance">On your Desktop</string>
|
||||
<string name="add_app_preview_name">New app</string>
|
||||
<string name="add_app_name">Name</string>
|
||||
<string name="add_app_name_placeholder">From the app</string>
|
||||
<string name="add_app_icon">Icon</string>
|
||||
<string name="add_app_icon_default">Default icon</string>
|
||||
<string name="add_app_choose_icon">Choose icon</string>
|
||||
<string name="add_app_clear_icon">Clear</string>
|
||||
<string name="add_app_icon_suggestions">From the repository</string>
|
||||
<string name="add_app_icon_searching">Looking for icons…</string>
|
||||
<string name="add_app_icon_reset">Default</string>
|
||||
<string name="edit_app_title">Edit %1$s</string>
|
||||
<string name="edit_app_save">Save</string>
|
||||
<string name="add_app_checking_folder">Checking the folder…</string>
|
||||
<string name="add_app_folder_linked">Its folder is linked into the session: %1$s</string>
|
||||
<string name="add_app_folder_copied">Its folder holds Linux programs, which shared storage cannot run, so it is copied into DroidDeck (%1$s).</string>
|
||||
<string name="add_app_confirm">Add</string>
|
||||
<string name="add_app_cancel">Cancel</string>
|
||||
<string name="add_app_runtime_required">Install the Linux runtime from Setup first.</string>
|
||||
<string name="add_app_busy">Wait for %1$s to finish.</string>
|
||||
<string name="common_cancel">Cancel</string>
|
||||
<string name="common_auto">Auto</string>
|
||||
<string name="common_never">Never</string>
|
||||
<string name="common_always">Always</string>
|
||||
<string name="common_uninstall">Uninstall</string>
|
||||
<string name="common_check">Check</string>
|
||||
<string name="common_update">Update</string>
|
||||
<string name="common_checking">Checking…</string>
|
||||
<string name="common_working">Working…</string>
|
||||
<string name="common_forget">Forget</string>
|
||||
<string name="common_add_ellipsis">Add…</string>
|
||||
<string name="common_use">Use</string>
|
||||
<string name="common_applies_next_session">Applies next session.</string>
|
||||
<string name="mode_steam_title">Steam session</string>
|
||||
<string name="mode_desktop_title">Desktop session</string>
|
||||
<string name="mode_display">Display</string>
|
||||
<string name="mode_resolution">Resolution</string>
|
||||
<string name="mode_res_720">Up to 720p</string>
|
||||
<string name="mode_res_900">Up to 900p</string>
|
||||
<string name="mode_res_1080">Up to 1080p</string>
|
||||
<string name="mode_res_native">Native</string>
|
||||
<string name="mode_res_default">%1$s (default)</string>
|
||||
<string name="mode_res_custom_value">Custom · %1$d×%2$d</string>
|
||||
<string name="mode_res_custom">Custom…</string>
|
||||
<string name="mode_res_note">720p can make menus more responsive.</string>
|
||||
<string name="mode_ratio">Screen ratio</string>
|
||||
<string name="mode_ratio_custom">Set by the custom resolution.</string>
|
||||
<string name="mode_ratio_auto">Auto uses at least 16:9.</string>
|
||||
<string name="mode_fps">Frame limit</string>
|
||||
<string name="mode_fps_note">Caps the whole session. The display switches to a refresh rate the cap divides evenly.</string>
|
||||
<string name="mode_hdr">HDR</string>
|
||||
<string name="mode_hdr10">HDR10 output</string>
|
||||
<string name="mode_hdr_unavailable">Not available: %1$s.</string>
|
||||
<string name="mode_hdr_restart">Restart the app to apply.</string>
|
||||
<string name="mode_drivers">Drivers</string>
|
||||
<string name="mode_gpu_drivers">GPU drivers</string>
|
||||
<string name="mode_gpu_drivers_hint">Shared by Steam and Desktop. Set on the Components page.</string>
|
||||
<string name="mode_touch_controls">Touch & controls</string>
|
||||
<string name="mode_touch">Touch</string>
|
||||
<string name="mode_touch_touchpad">Touchpad</string>
|
||||
<string name="mode_touch_direct">Direct</string>
|
||||
<string name="mode_touch_note">Auto: touchpad on Desktop, direct in Steam.</string>
|
||||
<string name="mode_osc">On-screen controls</string>
|
||||
<string name="mode_osc_qam">Steam + QAM</string>
|
||||
<string name="mode_osc_note">Auto shows the controls when no controller is connected.</string>
|
||||
<string name="mode_controller">Controller</string>
|
||||
<string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string>
|
||||
<string name="mode_controller_deck">Steam Deck controller</string>
|
||||
<string name="mode_controller_x360">Xbox 360 controller</string>
|
||||
<string name="mode_controller_note">Deck: Quick Access button and gyro. Xbox 360: open Quick Access with Guide+A.</string>
|
||||
<string name="mode_back">Back</string>
|
||||
<string name="mode_session">Session</string>
|
||||
<string name="mode_suspend">Background behavior</string>
|
||||
<string name="mode_suspend_hint">When the app is hidden or the screen turns off.</string>
|
||||
<string name="mode_suspend_manual">Manual</string>
|
||||
<string name="mode_suspend_note">Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running.</string>
|
||||
<string name="mode_startup">Startup</string>
|
||||
<string name="mode_steam_startup">Start Steam with DroidDeck</string>
|
||||
<string name="mode_steam_startup_hint">Opens the Steam session when DroidDeck launches.</string>
|
||||
<string name="mode_decky">Decky</string>
|
||||
<string name="mode_decky_checking">Checking for releases…</string>
|
||||
<string name="mode_decky_ready">Ready to install %1$s.</string>
|
||||
<string name="mode_decky_none">No compatible build found. Try again later.</string>
|
||||
<string name="mode_decky_installed">Installed · %1$s</string>
|
||||
<string name="mode_decky_update">Update available · %1$s</string>
|
||||
<string name="mode_decky_current">Up to date · %1$s</string>
|
||||
<string name="mode_decky_loader">Loader</string>
|
||||
<string name="mode_decky_install_latest">Install latest</string>
|
||||
<string name="mode_decky_on">Starts with Steam. While on, other apps on this device may be able to control Steam.</string>
|
||||
<string name="mode_decky_off">Off. Steam\'s remote debugging port stays closed.</string>
|
||||
<string name="mode_decky_remove_title">Uninstall Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">Removes the loader. Your plugins and settings are kept.</string>
|
||||
<string name="mode_client">Client</string>
|
||||
<string name="mode_deck_mode">Steam Deck mode</string>
|
||||
<string name="mode_deck_mode_hint">Steam\'s Deck interface and Quick Access performance controls. Applies next session.</string>
|
||||
<string name="mode_mangoapp">Performance overlay</string>
|
||||
<string name="mode_mangoapp_hint">Turn off if games crash or go black in Deck mode. Applies next session.</string>
|
||||
<string name="mode_branch">Client branch</string>
|
||||
<string name="mode_branch_deck">Steam Deck public beta (required by Deck mode).</string>
|
||||
<string name="mode_branch_hint">Applies next session. Steam may update itself once.</string>
|
||||
<string name="mode_branch_public">Public beta</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
|
||||
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
|
||||
<string name="mode_added_games">Added games</string>
|
||||
<string name="mode_added_none">no game folders with a .exe found</string>
|
||||
<string name="mode_games_folder">Games folder</string>
|
||||
<string name="mode_games_folder_another">Another games folder</string>
|
||||
<string name="mode_added_art">Artwork from Steam</string>
|
||||
<string name="mode_added_art_hint">Fills in missing art by folder name. Your own cover.jpg, hero.jpg, logo.png or icon.png in the game folder takes priority.</string>
|
||||
<string name="mode_added_launches">Launches %1$s</string>
|
||||
<string name="mode_added_launches_in">Launches %1$s · in %2$s</string>
|
||||
<string name="mode_added_choose">Choose another file…</string>
|
||||
<string name="mode_added_exe_note">Defaults to the .exe named after the folder, or else the largest.</string>
|
||||
<string name="mode_fill">Stretch games to fill the screen</string>
|
||||
<string name="mode_fill_hint">Keeps self-resizing games full screen. Turn off if a game shows up small. Applies next session.</string>
|
||||
<string name="mode_stretch169">Stretch 16:9 games</string>
|
||||
<string name="mode_stretch169_hint">On screens squarer than 16:9, stretches games to fill instead of showing bars. Applies next session.</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
<string name="mode_directaudio">DirectAudio for games</string>
|
||||
<string name="mode_directaudio_hint">Bypasses PulseAudio for lower latency.</string>
|
||||
<string name="mode_client_audio">Steam client audio</string>
|
||||
<string name="mode_client_audio_hint">Applies next session.</string>
|
||||
<string name="mode_client_audio_classic">Classic</string>
|
||||
<string name="mode_client_audio_direct">DirectAudio</string>
|
||||
<string name="mode_mic">Microphone</string>
|
||||
<string name="mode_mic_hint">For voice chat.</string>
|
||||
<string name="mode_storage">Game storage</string>
|
||||
<string name="mode_storage_auto">Automatic - the SD card when present</string>
|
||||
<string name="mode_storage_auto_none">Automatic - the SD card when present (none now)</string>
|
||||
<string name="mode_storage_internal">Internal only</string>
|
||||
<string name="mode_storage_folder">Folder: %1$s</string>
|
||||
<string name="mode_second_library">Second library</string>
|
||||
<string name="mode_storage_note">Games that stream assets from SD or shared storage may stutter.</string>
|
||||
<string name="mode_choose_folder">Choose a folder…</string>
|
||||
<string name="mode_renderer">Renderer</string>
|
||||
<string name="mode_desktop_renderer">Desktop renderer</string>
|
||||
<string name="mode_renderer_hint">Draws the desktop.</string>
|
||||
<string name="mode_renderer_vulkan">vulkan - GPU</string>
|
||||
<string name="mode_renderer_gles2">gles2 - GPU (experimental)</string>
|
||||
<string name="mode_renderer_pixman">pixman - software</string>
|
||||
<string name="mode_renderer_note">Vulkan runs desktop programs on the GPU, falling back to pixman if it fails. On pixman, games open full screen on the GPU.</string>
|
||||
<string name="mode_custom_res_title">Custom resolution</string>
|
||||
<string name="mode_custom_res_text">Replaces the cap and ratio. A size that doesn\'t match the screen gets bars.</string>
|
||||
<string name="mode_width">Width</string>
|
||||
<string name="mode_height">Height</string>
|
||||
<string name="mode_custom_res_range">Between 320×240 and 3840×2160.</string>
|
||||
<plurals name="mode_added_count">
|
||||
<item quantity="one">%1$d game</item>
|
||||
<item quantity="other">%1$d games</item>
|
||||
</plurals>
|
||||
<string name="common_ok">OK</string>
|
||||
<string name="common_delete">Delete</string>
|
||||
<string name="common_restore">Restore</string>
|
||||
<string name="common_download">Download</string>
|
||||
<string name="common_delete_named">Delete %1$s</string>
|
||||
<string name="common_size_mb">%1$.1f MB</string>
|
||||
<string name="comp_runtime_driver">Runtime driver</string>
|
||||
<string name="comp_runtime_driver_hint">Used by Steam, games and the desktop. Applies next session.</string>
|
||||
<string name="comp_import_turnip">Import Turnip zip…</string>
|
||||
<string name="comp_display_driver">Display driver</string>
|
||||
<string name="comp_display_driver_hint">Draws frames to the screen in both modes. Restart the app to apply.</string>
|
||||
<string name="comp_import_adrenotools">Import an AdrenoTools zip…</string>
|
||||
<string name="comp_never">never</string>
|
||||
<string name="comp_nightlies_checked">Nightlies checked %1$s</string>
|
||||
<string name="comp_title">Components</string>
|
||||
<string name="comp_about">About Components</string>
|
||||
<string name="comp_check_drivers">Check for new drivers</string>
|
||||
<string name="comp_check_nightlies">Check the Nightlies for new packages</string>
|
||||
<string name="comp_proton">Proton</string>
|
||||
<string name="comp_proton_note">Steam replaces Valve\'s Proton when it updates; originals are kept per build.</string>
|
||||
<string name="comp_swapped">%1$s · %2$d swapped</string>
|
||||
<string name="comp_all_original">%1$s · all original</string>
|
||||
<string name="comp_game_running_suffix">%1$s · game running</string>
|
||||
<string name="comp_gpu_drivers">GPU drivers</string>
|
||||
<string name="comp_reapplied">%1$s · re-applied at launch %2$s</string>
|
||||
<string name="comp_next_queued">Next: %1$s · after the game closes</string>
|
||||
<string name="comp_in_use">%1$s in use: %2$s</string>
|
||||
<string name="comp_waits">Waits until the game closes</string>
|
||||
<string name="comp_applies_next_game">Applies the next time a game starts</string>
|
||||
<string name="comp_import_zip">Import .zip</string>
|
||||
<string name="comp_reading">Reading Protons…</string>
|
||||
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Setup.</string>
|
||||
<string name="comp_original">Original · %1$s</string>
|
||||
<string name="comp_this_build">%1$s · this build</string>
|
||||
<string name="comp_earlier_build">%1$s · earlier build</string>
|
||||
<string name="comp_installed_count">Installed · %1$d</string>
|
||||
<string name="comp_nothing_stored">Nothing stored for %1$s yet. This Proton\'s own files are saved as its original.</string>
|
||||
<string name="comp_waits_long">A game is running on this Proton, so this waits until it closes.</string>
|
||||
<string name="comp_applies_long">Applies the next time a game starts.</string>
|
||||
<string name="comp_swap_title">Swap %1$s?</string>
|
||||
<string name="comp_swap_body">Put %1$s into %2$s? Its original files stay saved. %3$s</string>
|
||||
<string name="comp_restore_title">Restore %1$s?</string>
|
||||
<string name="comp_restore_body">Put the %1$s original back into %2$s? %3$s</string>
|
||||
<string name="comp_delete_package_body">Removes the package from the app. You can download it again.</string>
|
||||
<string name="comp_delete_original_title">Delete this original?</string>
|
||||
<string name="comp_delete_original_body">This %1$s original is from an earlier build of %2$s. The current build\'s original is always kept.</string>
|
||||
<string name="comp_nightlies_count">On the Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Refresh to list packages on the Nightlies.</string>
|
||||
<string name="comp_nothing_new">Nothing new since the last check.</string>
|
||||
<string name="comp_import_wcp">Import .wcp</string>
|
||||
<string name="comp_restore_original">Restore this Proton\'s original</string>
|
||||
<string name="comp_restore_original_body">Put %1$s\'s original %2$s back?</string>
|
||||
<string name="comp_swap">Swap</string>
|
||||
<string name="comp_about_text">FEX, DXVK and VKD3D-Proton for each Proton that Steam runs games with.\n\nSelect an installed row to swap it in. It applies the next time a game starts, or after the running game closes.\n\nYour choices are rechecked before every launch and put back if anything changed them. Each Proton\'s original files are kept, so Steam updates never lose them.</string>
|
||||
<string name="comp_tag_original">Original</string>
|
||||
<string name="comp_tag_in_use">In use</string>
|
||||
<string name="comp_tag_new">New</string>
|
||||
<string name="comp_tag_stored">Stored</string>
|
||||
<string name="perf_title">Performance</string>
|
||||
<string name="perf_lede">CPU cores, session fixes and the host name. Applies next session.</string>
|
||||
<string name="perf_client_cores_group">Steam client cores</string>
|
||||
<string name="perf_override">Override Steam\'s core choice</string>
|
||||
<string name="perf_override_hint">Pins Steam, its UI helper and gamescope to the cores below.</string>
|
||||
<string name="perf_client_cores">Client cores</string>
|
||||
<string name="perf_client_cores_on">Cores for Steam.</string>
|
||||
<string name="perf_client_cores_off">Turn on the override to choose.</string>
|
||||
<string name="perf_game_cores_group">Game cores</string>
|
||||
<string name="perf_game_cores">Game cores</string>
|
||||
<string name="perf_game_cores_hint">Cores for games. With all selected, Android schedules freely.</string>
|
||||
<string name="perf_client_ui">Client interface</string>
|
||||
<string name="perf_glthread">Threaded GL</string>
|
||||
<string name="perf_glthread_hint">May make Steam menus more responsive.</string>
|
||||
<string name="perf_zink">Zink: lazy descriptors</string>
|
||||
<string name="perf_zink_hint">Recommended for drivers without descriptor buffers.</string>
|
||||
<string name="perf_noglerror">Skip GL error checks</string>
|
||||
<string name="perf_noglerror_hint">Turns off per-call GL validation.</string>
|
||||
<string name="perf_gsrealtime">gamescope: realtime GPU queue</string>
|
||||
<string name="perf_gsrealtime_hint">Prioritizes the compositor\'s GPU work. Can smooth frame pacing at some cost to games.</string>
|
||||
<string name="perf_gpu">GPU</string>
|
||||
<string name="perf_gpuclock">Hold the GPU at its top clock</string>
|
||||
<string name="perf_gpuclock_hint">Adreno only. Fewer hitches, more battery use and heat.</string>
|
||||
<string name="perf_fixes">Session fixes</string>
|
||||
<string name="perf_sysmem">Turnip: sysmem rendering</string>
|
||||
<string name="perf_sysmem_hint">Faster for Steam and most games. Required on Adreno 710/720/722.</string>
|
||||
<string name="perf_xalia">Skip Steam\'s xalia helper</string>
|
||||
<string name="perf_xalia_hint">Disables Proton\'s gamepad navigation helper, which costs CPU in every game.</string>
|
||||
<string name="perf_seccomp">Run proot without seccomp</string>
|
||||
<string name="perf_seccomp_hint">May fix missing-syscall errors on some kernels. Can reduce performance.</string>
|
||||
<string name="perf_fastpath">proot fast path</string>
|
||||
<string name="perf_fastpath_needs_seccomp">Needs proot\'s seccomp filter.</string>
|
||||
<string name="perf_fastpath_hint">Faster file lookups and less stutter. Turn off if a program can\'t find its files.</string>
|
||||
<string name="perf_identity">Session identity</string>
|
||||
<string name="perf_hostname">Host name</string>
|
||||
<string name="perf_hostname_hint">The name Steam sees for this device. Blank restores %1$s.</string>
|
||||
<string name="perf_hostname_invalid">Letters, digits and inner hyphens only, up to 63 characters.</string>
|
||||
<string name="perf_phantom_warning">Android is set to kill this session</string>
|
||||
<string name="driver_installed">Installed</string>
|
||||
<string name="driver_available">Available to download</string>
|
||||
<string name="driver_nothing_new">Nothing new since the last check.</string>
|
||||
<string name="driver_restore_builtin">Restore built-in drivers</string>
|
||||
<string name="driver_delete_builtin">Built into the app: this removes its unpacked files and hides it. Restore built-in drivers brings it back.</string>
|
||||
<string name="driver_delete_bundle">Half of an Android + Linux bundle: both halves are removed. A driver in use falls back to the default.</string>
|
||||
<string name="driver_delete_plain">Its files are removed from the app.</string>
|
||||
<string name="driver_delete_in_use">Its files are removed. It\'s in use, so the default takes over.</string>
|
||||
<string name="drawer_page_display">Display</string>
|
||||
<string name="drawer_page_controls">Controls</string>
|
||||
<string name="drawer_page_components">Components</string>
|
||||
<string name="drawer_page_settings">Settings</string>
|
||||
<string name="drawer_page_desc">%1$s page</string>
|
||||
<string name="drawer_steam">Steam</string>
|
||||
<string name="drawer_desktop">Desktop</string>
|
||||
<string name="drawer_steam_menu">Steam menu</string>
|
||||
<string name="drawer_quick_access">Quick access</string>
|
||||
<string name="drawer_open_qam">Open Quick Access Menu</string>
|
||||
<string name="drawer_prev_page">Previous page</string>
|
||||
<string name="drawer_next_page">Next page</string>
|
||||
<string name="drawer_hud">Performance HUD</string>
|
||||
<string name="drawer_fill">Stretch games to fill</string>
|
||||
<string name="drawer_touch_auto">Auto (%1$s)</string>
|
||||
<string name="drawer_keyboard">Keyboard</string>
|
||||
<string name="drawer_pc_keyboard">PC keyboard</string>
|
||||
<string name="drawer_android_keyboard">Android</string>
|
||||
<string name="drawer_second_screen">Second screen</string>
|
||||
<string name="drawer_second_screen_shows">Shows</string>
|
||||
<string name="drawer_android_apps">Android apps</string>
|
||||
<string name="drawer_launch_app">Launch an app</string>
|
||||
<string name="common_hide">Hide</string>
|
||||
<string name="common_show">Show</string>
|
||||
<string name="drawer_no_apps">No launchable apps</string>
|
||||
<string name="drawer_session_behavior">Session behavior</string>
|
||||
<string name="drawer_suspend_hint">Applies now and to future sessions in this mode.</string>
|
||||
<string name="drawer_next_session">Next session</string>
|
||||
<string name="drawer_support">Support</string>
|
||||
<string name="drawer_logs">Session logs</string>
|
||||
<string name="drawer_logs_hint">Attach to a bug report</string>
|
||||
<string name="drawer_share_logs">Share logs</string>
|
||||
<string name="drawer_background">Background</string>
|
||||
<string name="drawer_stop">Stop</string>
|
||||
<string name="drawer_stop_title">Stop session?</string>
|
||||
<string name="drawer_running_now">Running now</string>
|
||||
<string name="drawer_no_game">No game is running on a Proton</string>
|
||||
<string name="drawer_no_proton">No Proton is installed yet.</string>
|
||||
<string name="drawer_game_running">game running</string>
|
||||
<string name="drawer_reapplied">re-applied at launch %1$s</string>
|
||||
<string name="drawer_original">Original</string>
|
||||
<string name="drawer_components_note">Download, import and delete on the Components page.</string>
|
||||
<string name="setup_check_done">Done</string>
|
||||
<string name="setup_check_warn">Needs attention</string>
|
||||
<string name="setup_check_busy">Working</string>
|
||||
<string name="setup_limit_adb_failed">Check the Wireless debugging IP address and port, or pair again if Android removed this device.</string>
|
||||
<string name="setup_install">Install</string>
|
||||
<string name="setup_manage">Manage</string>
|
||||
<string name="setup_tab_overview">Overview</string>
|
||||
<string name="setup_tab_controller">Controller</string>
|
||||
<string name="setup_tab_session">Session</string>
|
||||
<string name="setup_tab_launcher">Launcher</string>
|
||||
<string name="setup_title">Setup</string>
|
||||
<string name="setup_all_set">● All set</string>
|
||||
<string name="setup_n_ready">%1$d of %2$d ready</string>
|
||||
<string name="setup_system_check">System check</string>
|
||||
<string name="setup_gpu_supported">Device supported</string>
|
||||
<string name="setup_gpu_untested">Untested GPU</string>
|
||||
<string name="setup_gpu_unsupported">GPU not supported</string>
|
||||
<string name="setup_gpu_detail">%1$s · %2$s</string>
|
||||
<string name="setup_gpu_untested_detail">%1$s: %2$s</string>
|
||||
<string name="setup_gpu_unsupported_detail">Steam needs an Adreno driver; %1$s may show a black screen</string>
|
||||
<string name="setup_runtime">Linux runtime</string>
|
||||
<string name="setup_runtime_progress">%1$s · %2$d%%</string>
|
||||
<string name="setup_runtime_missing">Not installed · about 3 GB, installed on first Play</string>
|
||||
<string name="setup_installed">Installed</string>
|
||||
<string name="setup_runtime_update">%1$s · update available</string>
|
||||
<string name="setup_runtime_current">%1$s · up to date</string>
|
||||
<string name="setup_limit">Child-process limit</string>
|
||||
<string name="setup_limit_on">On · Android may close Steam. Turning it off takes a minute.</string>
|
||||
<string name="setup_limit_unset">Not set · Android may close Steam. Turning it off takes a minute.</string>
|
||||
<string name="setup_limit_unknown">Couldn\'t be checked · turning it off takes a minute.</string>
|
||||
<string name="setup_fix_it">Fix it</string>
|
||||
<string name="setup_details">Details</string>
|
||||
<string name="setup_dev_options">Developer options</string>
|
||||
<string name="setup_use_wireless">Use Wireless debugging</string>
|
||||
<string name="setup_check_again">Check again</string>
|
||||
<string name="setup_limit_turn_on">Turn limit on</string>
|
||||
<string name="setup_account">Steam account</string>
|
||||
<string name="setup_signed_in_offline">Signed in as %1$s · offline mode</string>
|
||||
<string name="setup_signed_in">Signed in as %1$s</string>
|
||||
<string name="setup_sign_in">Press Play and sign in to Steam</string>
|
||||
<string name="setup_tools">Tools</string>
|
||||
<string name="setup_controller">Controller</string>
|
||||
<string name="setup_controller_unavailable">Controller settings are unavailable.</string>
|
||||
<string name="setup_session">Session</string>
|
||||
<string name="setup_logs">Session logs</string>
|
||||
<string name="setup_logs_hint">Saved after each session</string>
|
||||
<string name="setup_latest_logs">Latest session logs</string>
|
||||
<string name="setup_saved_logs">Saved session logs</string>
|
||||
<string name="setup_saved_logs_hint">Keeps the newest %1$d</string>
|
||||
<string name="setup_clear_logs">Clear logs</string>
|
||||
<string name="setup_offline">Offline mode</string>
|
||||
<string name="setup_sign_in_first">Sign in to Steam first</string>
|
||||
<string name="setup_launcher">Launcher</string>
|
||||
<string name="setup_theme">Theme</string>
|
||||
<string name="setup_theme_hint">Launcher appearance</string>
|
||||
<string name="setup_home">Use as Home screen</string>
|
||||
<string name="setup_home_on">DroidDeck can be the Home app</string>
|
||||
<string name="setup_home_off">DroidDeck is never offered as a Home app</string>
|
||||
<string name="setup_fullscreen">Fullscreen</string>
|
||||
<string name="setup_fullscreen_on">Hides the Android status and navigation bars</string>
|
||||
<string name="setup_fullscreen_off">Shows the Android status and navigation bars</string>
|
||||
<string name="setup_default_home">Default Home app</string>
|
||||
<string name="setup_choose_home">Choose a Home app</string>
|
||||
<string name="setup_choose">Choose</string>
|
||||
<string name="setup_linux_apps">Linux apps (beta)</string>
|
||||
<string name="setup_store">Flathub Store</string>
|
||||
<string name="setup_store_on">In the menu. Some apps may not start; logs are in Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Install Linux apps and games from Flathub</string>
|
||||
<string name="setup_tool_files">Files</string>
|
||||
<string name="setup_tool_files_hint">Browse and manage files</string>
|
||||
<string name="setup_tool_protons">Proton versions</string>
|
||||
<string name="setup_tool_protons_hint">Install ARM64 Proton builds</string>
|
||||
<string name="setup_tool_performance">Performance</string>
|
||||
<string name="setup_tool_performance_hint">CPU core assignment</string>
|
||||
<string name="setup_tool_roms">ROMs folder</string>
|
||||
<string name="setup_tool_roms_hint">Where emulator games are stored</string>
|
||||
<string name="setup_card_components">Components</string>
|
||||
<string name="setup_card_components_hint">FEX, DXVK, VKD3D</string>
|
||||
<string name="setup_card_controls">Controls</string>
|
||||
<string name="setup_card_controls_hint">Button mapping</string>
|
||||
<string name="store_tab_discover">Discover</string>
|
||||
<string name="store_tab_search">Search</string>
|
||||
<string name="store_tab_installed">Installed</string>
|
||||
<string name="store_tab_count">%1$s · %2$d</string>
|
||||
<string name="store_title">Store</string>
|
||||
<string name="store_chip_flathub">Flathub · ARM64</string>
|
||||
<string name="store_chip_ready">● Flatpak ready</string>
|
||||
<string name="store_needs_runtime">Install the Linux runtime from Setup first.</string>
|
||||
<string name="store_setup_title">Set up Flatpak</string>
|
||||
<string name="store_setup_text">Adds Flatpak and connects it to Flathub (about 20 MB). The first app also downloads its runtime.</string>
|
||||
<string name="store_setting_up">Setting up…</string>
|
||||
<string name="store_flatpak">Flatpak</string>
|
||||
<string name="store_updates">Updates</string>
|
||||
<string name="store_busy">%1$s · %2$s</string>
|
||||
<string name="store_busy_percent">%1$s · %2$s · %3$d%%</string>
|
||||
<string name="store_starting">Starting…</string>
|
||||
<string name="store_categories">Categories</string>
|
||||
<string name="store_unreachable">Couldn\'t reach Flathub. Check your connection and try again.</string>
|
||||
<string name="store_try_again">Try again</string>
|
||||
<string name="store_search_label">Search Flathub</string>
|
||||
<string name="store_search_placeholder">Firefox, games, emulators…</string>
|
||||
<string name="store_search">Search</string>
|
||||
<string name="store_all">All</string>
|
||||
<string name="store_searching">Searching…</string>
|
||||
<string name="store_search_hint">Search by name or pick a category. Only ARM64 apps are listed.</string>
|
||||
<string name="store_no_results">No ARM64 apps on Flathub match that.</string>
|
||||
<string name="store_results">Results</string>
|
||||
<string name="store_check_updates">Check for updates</string>
|
||||
<string name="store_update_all">Update all (%1$d)</string>
|
||||
<string name="store_all_current">● Everything is up to date</string>
|
||||
<string name="store_no_apps">No apps yet. Find some under Discover or Search.</string>
|
||||
<string name="store_setup_first_note">Set up Flatpak to install apps.</string>
|
||||
<string name="store_update_available_prefix">Update available · %1$s</string>
|
||||
<string name="store_open">Open</string>
|
||||
<string name="store_details">Details</string>
|
||||
<string name="store_remove">Remove</string>
|
||||
<string name="store_press_again">Press again to remove</string>
|
||||
<string name="store_installing">Installing…</string>
|
||||
<string name="store_install">Install</string>
|
||||
<string name="store_setup_first">Set up Flatpak first</string>
|
||||
<string name="store_download_size">%1$s download</string>
|
||||
<string name="store_installed_chip">● Installed</string>
|
||||
<string name="store_details_failed">Couldn\'t load this app\'s Flathub page.</string>
|
||||
<string name="store_loading">Loading from Flathub…</string>
|
||||
<string name="store_no_arm">No ARM64 build on Flathub, so it can\'t be installed here.</string>
|
||||
<string name="store_screenshots">Screenshots</string>
|
||||
<string name="store_about">About</string>
|
||||
<string name="store_app_id">App ID</string>
|
||||
<string name="store_license">License</string>
|
||||
<string name="store_runtime">Runtime</string>
|
||||
<string name="store_installed_size">Installed size</string>
|
||||
<string name="store_installed_size_value">%1$s (plus its shared runtime)</string>
|
||||
<string name="store_website">Website</string>
|
||||
<string name="store_verified">Verified</string>
|
||||
<string name="upd_title">Updates</string>
|
||||
<string name="upd_build">DroidDeck %1$s · %2$s</string>
|
||||
<string name="upd_checked">Checked %1$s</string>
|
||||
<string name="upd_not_checked">Not checked yet</string>
|
||||
<string name="upd_perm_title">Let DroidDeck update itself</string>
|
||||
<string name="upd_perm_text">On the next screen, turn on Allow from this source, then come back. The update continues by itself.</string>
|
||||
<string name="upd_open_settings">Open settings</string>
|
||||
<string name="upd_not_now">Not now</string>
|
||||
<string name="upd_signed_status">Signed differently</string>
|
||||
<string name="upd_signed_headline">Can\'t update in place</string>
|
||||
<string name="upd_signed_detail">This copy isn\'t signed with DroidDeck\'s release key, so updates can\'t install over it. Uninstall it to switch.</string>
|
||||
<string name="upd_available">Update available</string>
|
||||
<string name="upd_blocked_status">Can\'t install</string>
|
||||
<string name="upd_switch">Ready to switch</string>
|
||||
<string name="upd_ahead_status">Ahead of Stable</string>
|
||||
<string name="upd_ahead_headline">You\'re ahead of Stable</string>
|
||||
<string name="upd_ahead_detail">This build is newer than the latest Stable. You\'ll move to Stable with its next release.</string>
|
||||
<string name="upd_gone_status">Test ended</string>
|
||||
<string name="upd_gone_headline">This test has ended</string>
|
||||
<string name="upd_gone_detail">Its fix was merged or dropped. Follow Preview for the newest fixes.</string>
|
||||
<string name="upd_current_status">Up to date</string>
|
||||
<string name="upd_current_headline">You have the latest %1$s</string>
|
||||
<string name="upd_dot_ago">· %1$s</string>
|
||||
<string name="upd_stage_percent">%1$s… %2$d%%</string>
|
||||
<string name="upd_stage">%1$s…</string>
|
||||
<string name="upd_update_button">Update%1$s</string>
|
||||
<string name="upd_install_button">Install%1$s</string>
|
||||
<string name="upd_install_anyway">Install Stable %1$s anyway</string>
|
||||
<string name="upd_follow_preview">Follow Preview</string>
|
||||
<string name="upd_stop_session">Stop the running session to update.</string>
|
||||
<string name="upd_less">Less</string>
|
||||
<string name="upd_more">More</string>
|
||||
<string name="upd_new_stable">DroidDeck %1$s</string>
|
||||
<string name="upd_new_preview">New Preview build</string>
|
||||
<string name="upd_new_test">PR #%1$d test build</string>
|
||||
<string name="upd_mb">%1$d MB</string>
|
||||
<string name="upd_channel">UPDATE CHANNEL</string>
|
||||
<string name="upd_stable">Stable</string>
|
||||
<string name="upd_stable_hint">Tested releases, for most people</string>
|
||||
<string name="upd_version_ago">%1$s · %2$s</string>
|
||||
<string name="upd_preview">Preview</string>
|
||||
<string name="upd_preview_hint">Newest fixes, before Stable</string>
|
||||
<string name="upd_tests">Test builds</string>
|
||||
<string name="upd_tests_hint">Try a fix before it\'s released</string>
|
||||
<string name="upd_tests_none">None right now</string>
|
||||
<string name="upd_pr">PR #%1$d</string>
|
||||
<string name="upd_not_built">Not built for this copy of DroidDeck</string>
|
||||
<string name="upd_updated">Updated %1$s</string>
|
||||
<string name="upd_test_name">the PR #%1$d test</string>
|
||||
<string name="upd_while_ago">a while ago</string>
|
||||
<string name="upd_just_now">just now</string>
|
||||
<plurals name="upd_tests_count">
|
||||
<item quantity="one">%1$d to try</item>
|
||||
<item quantity="other">%1$d to try</item>
|
||||
</plurals>
|
||||
<string name="gate_title">One Android setting to change</string>
|
||||
<string name="gate_eyebrow">Steam</string>
|
||||
<string name="gate_lede">Android stops apps that start many background processes, and Steam starts dozens. Until this changes, games close without an error.</string>
|
||||
<string name="gate_fix_for_me">Fix it for me</string>
|
||||
<string name="gate_toggle_steps">Turn on “Disable child process restrictions”, then come back. Leave Developer options on, or the switch turns off again.</string>
|
||||
<string name="gate_open_dev">Open Developer options</string>
|
||||
<string name="gate_no_toggle">This Android version has no switch for it, so DroidDeck changes it over Wireless debugging. Takes about a minute, no computer needed.</string>
|
||||
<string name="gate_needs_wifi">Wireless debugging needs Wi-Fi. Any network works; it doesn\'t need internet.</string>
|
||||
<string name="gate_needs_notifications">The pairing code goes in a notification. Allow notifications for DroidDeck, or use Other ways.</string>
|
||||
<string name="gate_allow_notifications">Allow notifications</string>
|
||||
<string name="gate_step1">Open Settings → Developer options → Wireless debugging and turn it on</string>
|
||||
<string name="gate_step2">Tap “Pair device with pairing code”</string>
|
||||
<string name="gate_step3">Type the code into the DroidDeck notification</string>
|
||||
<string name="gate_waiting">Waiting for the pairing pop-up…</string>
|
||||
<string name="gate_code_needed">Pop-up found. Enter its code in the notification.</string>
|
||||
<string name="gate_status">Status</string>
|
||||
<string name="gate_polling">Checking every 2 seconds. This page closes when it\'s off.</string>
|
||||
<string name="gate_other_ways">Other ways</string>
|
||||
<string name="gate_other_toggle">Wireless debugging, manual address, computer ADB</string>
|
||||
<string name="gate_other_no_toggle">Manual address, computer ADB, Developer options</string>
|
||||
<string name="gate_use_wireless_instead">Use Wireless debugging instead</string>
|
||||
<string name="gate_some_roms">Some ROMs add the switch anyway. Look for “child process” in Developer options.</string>
|
||||
<string name="gate_manual_hint">If the notification doesn\'t work, enter the pop-up\'s address and code yourself.</string>
|
||||
<string name="gate_enter_manually">Enter address manually</string>
|
||||
<string name="gate_from_computer">From a computer with ADB:</string>
|
||||
<string name="gate_copy_command">Copy command</string>
|
||||
<string name="gate_choose_display">Choose which display to open Android Settings on.</string>
|
||||
<string name="gate_main_screen">Main screen</string>
|
||||
<string name="wadb_finding_port">Paired. Finding the Wireless debugging port…</string>
|
||||
<string name="wadb_enter_port">Paired. Enter the port from Settings.</string>
|
||||
<string name="wadb_applying">Device found. Applying the setting…</string>
|
||||
<string name="wadb_apply_failed">Couldn\'t apply automatically. Check the address below and retry. %1$s</string>
|
||||
<string name="wadb_title_wireless">Wireless debugging</string>
|
||||
<string name="wadb_title_done">Child-process limit updated</string>
|
||||
<string name="wadb_title">Change the child-process limit</string>
|
||||
<string name="wadb_lede_compact0">If the option is missing, pair over Wireless debugging.</string>
|
||||
<string name="wadb_lede_compact1">Paired. Check the IP address and port in Wireless debugging.</string>
|
||||
<string name="wadb_lede_done">The child-process limit was updated.</string>
|
||||
<string name="wadb_lede0">Use step 2 if Developer options has no child-process setting. Open Developer options → Wireless debugging → Pair device with pairing code.</string>
|
||||
<string name="wadb_lede1">Paired. Use the address below, or update it from Wireless debugging → IP address & Port.</string>
|
||||
<string name="wadb_lede_done_long">The child-process limit was updated over Wireless debugging.</string>
|
||||
<string name="wadb_confirmed_on">Android confirmed the limit is on.</string>
|
||||
<string name="wadb_confirmed_off">Android confirmed the limit is off.</string>
|
||||
<string name="wadb_pair_group">2 · Pair Wireless debugging</string>
|
||||
<string name="wadb_pair_address">Pairing pop-up IP address & port</string>
|
||||
<string name="wadb_pin">Pairing code (PIN)</string>
|
||||
<string name="wadb_pin_placeholder">6 digits</string>
|
||||
<string name="wadb_pair">Pair</string>
|
||||
<string name="wadb_apply_group">2 · Apply over Wireless debugging</string>
|
||||
<string name="wadb_connect_address">Connection IP address & port</string>
|
||||
<string name="wadb_pair_again">Pair again</string>
|
||||
<string name="wadb_turn_off">Turn limit off</string>
|
||||
<string name="wadb_other_ways">Other ways to change it</string>
|
||||
<string name="wadb_dev_try_first">1 · Developer options (try first)</string>
|
||||
<string name="wadb_restrict_on">Set “Restrict child processes” on.</string>
|
||||
<string name="wadb_restrict_off">Set “Restrict child processes” off.</string>
|
||||
<string name="wadb_computer_last">3 · Computer ADB (last resort)</string>
|
||||
<string name="wadb_only_if">Only if Wireless debugging is unavailable.</string>
|
||||
<string name="wadb_computer_group">3 · Computer ADB (last resort)</string>
|
||||
<string name="ctrl_osc_hint">When the touch controls appear in a session</string>
|
||||
<string name="ctrl_color">Color</string>
|
||||
<string name="ctrl_color_hint">Tint of the on-screen buttons</string>
|
||||
<string name="ctrl_custom">Custom</string>
|
||||
<string name="ctrl_opacity">Opacity</string>
|
||||
<string name="ctrl_percent">%1$d%%</string>
|
||||
<string name="ctrl_size">Button size</string>
|
||||
<string name="ctrl_size_hint" formatted="false">100% is the standard size</string>
|
||||
<string name="ctrl_stick_click">Stick click</string>
|
||||
<string name="ctrl_stick_click_hint">Double-tap and hold a stick for L3 or R3</string>
|
||||
<string name="ctrl_adaptive">Adaptive sticks</string>
|
||||
<string name="ctrl_adaptive_hint">Sticks appear where you touch and hide on release</string>
|
||||
<string name="ctrl_layout">Layout</string>
|
||||
<string name="ctrl_layout_custom">Custom positions saved</string>
|
||||
<string name="ctrl_layout_default">Fitted to this screen</string>
|
||||
<string name="ctrl_edit">Edit</string>
|
||||
<string name="ctrl_reset">Reset</string>
|
||||
<string name="ctrl_mapping">Button mapping</string>
|
||||
<string name="ctrl_mapping_default">Every button sends its own input</string>
|
||||
<string name="ctrl_mapping_count">%1$d of %2$d buttons remapped</string>
|
||||
<string name="ctrl_configure">Configure</string>
|
||||
<string name="ctrl_reset_all">Reset controller</string>
|
||||
<string name="ctrl_reset_all_hint">Restores all controller settings and the layout</string>
|
||||
<string name="ctrl_mapping_lede">What each on-screen button sends to the game. Hidden removes the button.</string>
|
||||
<string name="ctrl_onscreen_buttons">On-screen buttons</string>
|
||||
<string name="ctrl_defaults">Defaults</string>
|
||||
<string name="ctrl_reset_mapping">Reset mapping</string>
|
||||
<string name="content_android_apps">Android apps</string>
|
||||
<string name="content_apps_count">%1$d apps</string>
|
||||
<string name="content_no_android_apps">No launchable Android apps found.</string>
|
||||
<string name="content_desktop_installed">● Desktop installed</string>
|
||||
<string name="content_desktop_first_open">Installs on first open</string>
|
||||
<string name="content_available">Available to install</string>
|
||||
<string name="content_not_installed">Not installed.</string>
|
||||
<string name="content_not_installed_suffix">%1$s · not installed</string>
|
||||
<string name="content_open_named">Open %1$s</string>
|
||||
<string name="content_games">Games</string>
|
||||
<string name="content_choose_roms">Choose a ROMs folder.</string>
|
||||
<string name="content_retroarch_roms">Browse to /root/ROMs in RetroArch.</string>
|
||||
<string name="content_add_roms">Add %1$s games to ROMs/%2$s.</string>
|
||||
<string name="content_install_named">Install %1$s</string>
|
||||
<string name="content_stop_to_install">Stop session to install</string>
|
||||
<string name="content_runtime_required">Runtime required</string>
|
||||
<string name="content_loading_details">Loading install details…</string>
|
||||
<string name="content_details_unavailable">Install details are unavailable. Try again later.</string>
|
||||
<string name="content_needs_runtime">Install the Linux runtime from Setup first.</string>
|
||||
<string name="content_stop_first">Stop the running session to install desktop apps.</string>
|
||||
<string name="content_install_into">Installs %1$s into the Linux desktop.</string>
|
||||
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
|
||||
<string name="content_hide_note">Hiding removes it from Desktop. Its files stay in the Linux runtime.</string>
|
||||
<string name="content_rom_gone">That game is no longer in the ROMs folder.</string>
|
||||
<string name="content_desktop_system">Desktop · %1$s</string>
|
||||
<string name="content_launch_in">Launch in %1$s</string>
|
||||
<string name="content_also_in">Also in %1$s</string>
|
||||
<string name="content_page_gone">That page is gone. Pick a section on the left.</string>
|
||||
<string name="content_open_desktop">Open desktop</string>
|
||||
<string name="content_install_open_desktop">Install & open desktop</string>
|
||||
<string name="content_linux_desktop">Linux desktop</string>
|
||||
<string name="content_linux_desktop_hint">LXQt, Firefox and your emulators</string>
|
||||
<string name="games_empty">Games you install in Steam show up here after the session.</string>
|
||||
<string name="games_play_steam">Play Steam</string>
|
||||
<string name="games_launch_settings">Launch settings</string>
|
||||
<string name="games_launch">Launch</string>
|
||||
<string name="games_files">Game files</string>
|
||||
<string name="games_prefix">Proton prefix</string>
|
||||
<string name="games_saves_looking">Looking for saves…</string>
|
||||
<string name="games_saves_none">No save folder found by name. Export takes the whole user folder.</string>
|
||||
<string name="games_saves_folders">%1$d save folders</string>
|
||||
<string name="games_saves_summary">%1$s · %2$d files · %3$s</string>
|
||||
<string name="games_kb">%1$d KB</string>
|
||||
<string name="games_manage_saves">Manage saves</string>
|
||||
<string name="games_saves">Game saves</string>
|
||||
<string name="games_import_saves">Import saves…</string>
|
||||
<string name="games_import_saves_hint">GameHub or Winlator zip. Current saves are backed up first.</string>
|
||||
<string name="games_export_gamehub">Export as GameHub zip…</string>
|
||||
<string name="games_export_gamehub_hint">For GameHub and BannerHub</string>
|
||||
<string name="games_export_winlator">Export as Winlator zip…</string>
|
||||
<string name="games_export_winlator_hint">For Winlator, WinNative and Bannerlator</string>
|
||||
<string name="games_open_save_folder">Open save folder</string>
|
||||
<string name="games_never_played">Never played</string>
|
||||
<string name="games_runtime_busy_percent">Runtime busy · %1$d%%</string>
|
||||
<string name="games_runtime_busy">Runtime busy</string>
|
||||
<string name="games_runtime_first_play">Runtime installs on first Play</string>
|
||||
<string name="games_runtime_update">Runtime update available</string>
|
||||
<string name="games_runtime_ready">● Runtime ready</string>
|
||||
<string name="games_last_played">Last played %1$s</string>
|
||||
<string name="games_internal">Internal storage</string>
|
||||
<string name="games_added">Added game</string>
|
||||
<string name="gpu_auto_hint">The best pair for this GPU, kept up to date</string>
|
||||
<string name="gpu_manual_hint">Pick a pair yourself</string>
|
||||
<string name="gpu_checking">Checking for drivers…</string>
|
||||
<string name="gpu_none_listed">No drivers listed yet. Refresh to look for them.</string>
|
||||
<string name="gpu_hide_other">Hide drivers for other GPUs</string>
|
||||
<string name="gpu_show_other">Show drivers for other GPUs</string>
|
||||
<string name="gpu_advanced">Advanced</string>
|
||||
<string name="gpu_set_with_display">Set together with the display driver: %1$s carries both.</string>
|
||||
<string name="gpu_set_with_runtime">Set together with the runtime driver: %1$s carries both.</string>
|
||||
<string name="gpu_runtime_hint">Used by games and the desktop.</string>
|
||||
<string name="gpu_runtime_default">Runtime default</string>
|
||||
<string name="gpu_display_hint">Draws frames to the screen. Applies after DroidDeck restarts.</string>
|
||||
<string name="gpu_auto_by_gpu">Auto - picked by GPU</string>
|
||||
<string name="gpu_manage">Download, import or delete…</string>
|
||||
<string name="gpu_in_use_caps">IN USE</string>
|
||||
<string name="gpu_builtin">The drivers built into DroidDeck</string>
|
||||
<string name="gpu_matched_pair">Runtime and display drivers set together as a matched pair.</string>
|
||||
<string name="gpu_recommended_for">Recommended for this GPU: %1$s.</string>
|
||||
<string name="gpu_refresh_hint">Refresh to look for drivers for this GPU.</string>
|
||||
<string name="gpu_recommended_caps">RECOMMENDED</string>
|
||||
<string name="gpu_unavailable">Unavailable</string>
|
||||
<string name="load_runtime">Linux runtime</string>
|
||||
<string name="load_runtime_detail">Getting the Linux runtime ready</string>
|
||||
<string name="load_session">Session</string>
|
||||
<string name="load_session_detail">Starting display and audio</string>
|
||||
<string name="load_steam">Steam client</string>
|
||||
<string name="load_steam_detail">Checking Steam and its compatibility tools</string>
|
||||
<string name="load_opening_steam">Opening Steam</string>
|
||||
<string name="load_opening_steam_detail">Waiting for Steam\'s first frame</string>
|
||||
<string name="load_desktop">Desktop</string>
|
||||
<string name="load_desktop_detail">Getting the desktop ready</string>
|
||||
<string name="load_opening">Opening</string>
|
||||
<string name="load_opening_detail">Waiting for the first frame</string>
|
||||
<string name="load_starting_steam">Starting Steam</string>
|
||||
<string name="load_ended">The session ended</string>
|
||||
<string name="load_step">Step %1$d of %2$d</string>
|
||||
<string name="load_steam_paused">Steam is paused</string>
|
||||
<string name="deck_tab_grips">Grips</string>
|
||||
<string name="deck_tab_left">Left pad</string>
|
||||
<string name="deck_tab_right">Right pad</string>
|
||||
<string name="deck_tab_both">Both</string>
|
||||
<string name="deck_close">Close second-screen controls</string>
|
||||
<string name="deck_open_steam_menu">Open Steam menu</string>
|
||||
<string name="deck_left_upper">Left · upper</string>
|
||||
<string name="deck_left_lower">Left · lower</string>
|
||||
<string name="deck_right_upper">Right · upper</string>
|
||||
<string name="deck_right_lower">Right · lower</string>
|
||||
<string name="deck_click">Click</string>
|
||||
<string name="deck_right_trackpad">Right trackpad</string>
|
||||
<string name="deck_left_trackpad">Left trackpad</string>
|
||||
<string name="second_trackpad">Trackpad</string>
|
||||
<string name="second_show_keyboard">Show keyboard</string>
|
||||
<string name="second_hide_keyboard">Hide keyboard</string>
|
||||
<string name="second_terminal">Linux terminal</string>
|
||||
<string name="second_terminal_not_ready">The Linux session isn\'t ready for a terminal yet.</string>
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Games</string>
|
||||
<string name="rail_desktop">Desktop</string>
|
||||
<string name="rail_store">Store</string>
|
||||
<string name="rail_components">Components</string>
|
||||
<string name="rail_apps">Apps</string>
|
||||
<string name="rail_setup">Setup</string>
|
||||
<string name="rail_updates">Updates</string>
|
||||
<string name="rail_percent">%1$d%%</string>
|
||||
<string name="rail_resume_named">Resume %1$s</string>
|
||||
<string name="roms_text">Shows up as /root/ROMs in each session. Internal storage is at /root/Storage.</string>
|
||||
<string name="roms_none">No folder chosen</string>
|
||||
<string name="roms_applies">Applies next session. SD cards work.</string>
|
||||
<string name="roms_choose">Choose folder</string>
|
||||
<string name="roms_change">Change folder</string>
|
||||
<string name="proton_lede">Install an ARM64 Proton build, then pick it per game in Steam → Properties → Compatibility.</string>
|
||||
<string name="proton_stop_first">Stop the running session to install or remove a compatibility tool.</string>
|
||||
<string name="proton_available">Available builds</string>
|
||||
<string name="proton_installed">Installed %1$s</string>
|
||||
<string name="proton_queued">Pending from an earlier request</string>
|
||||
<string name="proton_not_installed">Not installed</string>
|
||||
<string name="proton_install_now">Install now</string>
|
||||
<string name="proton_cancel_queued">Cancel queued</string>
|
||||
<string name="pckb_android">Android keyboard</string>
|
||||
<string name="pckb_hide">Hide ✕</string>
|
||||
<string name="main_launch_named">Launch %1$s</string>
|
||||
<string name="main_choose_screen">Choose a screen. Secondary: %1$s.</string>
|
||||
<string name="main_secondary_gone">The secondary display is no longer available.</string>
|
||||
<string name="main_primary">Primary screen</string>
|
||||
<string name="main_secondary">Secondary screen</string>
|
||||
<string name="widgets_prev_tab">Previous tab</string>
|
||||
<string name="widgets_next_tab">Next tab</string>
|
||||
<string name="widgets_on">On</string>
|
||||
<string name="widgets_all_n">All %1$d</string>
|
||||
<string name="widgets_none">None</string>
|
||||
<string name="widgets_n_of_m">%1$d of %2$d</string>
|
||||
<string name="widgets_off">Off</string>
|
||||
<string name="screen_bottom">Bottom screen</string>
|
||||
<string name="game_env_applies">Applies on the next game launch. A game\'s profile overrides the shared one.</string>
|
||||
<string name="game_env_other">Another game by app ID…</string>
|
||||
<string name="game_env_done">Done</string>
|
||||
<string name="game_env_default">Default</string>
|
||||
<string name="game_env_off">Off</string>
|
||||
<string name="game_env_change">Change…</string>
|
||||
<string name="game_env_change_title">Change variable</string>
|
||||
<string name="game_env_restore_default">Back to default</string>
|
||||
<string name="game_env_eyebrow">Proton games</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The languages DroidDeck ships, for Android's per-app language setting. -->
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="es" />
|
||||
</locale-config>
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class AddedGamesTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun steamInstallsInALibraryAreNotScannedAsAddedGames() {
|
||||
val library = tmp.newFolder("card")
|
||||
val common = File(library, "steamapps/common").apply { mkdirs() }
|
||||
File(library, "steamapps/appmanifest_2852190.acf").writeText(
|
||||
"\"AppState\"\n{\n\t\"appid\"\t\t\"2852190\"\n\t\"installdir\"\t\t\"MONSTER_HUNTER_STORIES_3_TWISTED_REFLECTION\"\n}\n",
|
||||
)
|
||||
File(library, "steamapps/appmanifest_17410.acf").writeText(
|
||||
"\"AppState\"\n{\n\t\"appid\"\t\t\"17410\"\n\t\"installdir\"\t\t\"Mirrors Edge\"\n}\n",
|
||||
)
|
||||
assertEquals(setOf("monster_hunter_stories_3_twisted_reflection", "mirrors edge"), AddedGames.steamInstallDirs(common))
|
||||
// Loose game folders at the library root are never Steam's.
|
||||
assertEquals(emptySet<String>(), AddedGames.steamInstallDirs(library))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class SecondaryLibraryTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun gamesStaySharedButPrefixesAndNativeDownloadsArePrivate() {
|
||||
val files = tmp.newFolder("files")
|
||||
val library = tmp.newFolder("card")
|
||||
File(library, "steamapps/common/Game").mkdirs()
|
||||
val binds = SecondaryLibrary.binds(files, library)
|
||||
val private = SecondaryLibrary.privateRoot(files, library)
|
||||
for (guest in listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")) {
|
||||
assertTrue(binds.contains("${library.path}:$guest"))
|
||||
assertTrue(binds.contains("$private/steamapps/compatdata:$guest/steamapps/compatdata"))
|
||||
assertTrue(binds.contains("$private/steamapps/downloading/4185400:$guest/steamapps/downloading/4185400"))
|
||||
assertTrue(binds.contains("$private/steamapps/common/SteamLinuxRuntime_4-arm64:$guest/steamapps/common/steamlinuxruntime_4-arm64"))
|
||||
assertFalse(binds.any { it.endsWith(":$guest/steamapps/common/Game") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun migrationPreservesSavesLinksAndOriginalAndNeverOverwritesNewSaves() {
|
||||
val files = tmp.newFolder("files")
|
||||
val library = tmp.newFolder("card")
|
||||
val save = File(library, "steamapps/compatdata/42/pfx/save.dat")
|
||||
save.parentFile!!.mkdirs()
|
||||
save.writeText("original")
|
||||
Files.createSymbolicLink(File(save.parentFile, "link").toPath(), File("save.dat").toPath())
|
||||
SecondaryLibrary.binds(files, library)
|
||||
val copied = File(SecondaryLibrary.privateRoot(files, library), "steamapps/compatdata/42/pfx/save.dat")
|
||||
assertEquals("original", copied.readText())
|
||||
assertTrue(Files.isSymbolicLink(File(copied.parentFile, "link").toPath()))
|
||||
copied.writeText("new save")
|
||||
SecondaryLibrary.binds(files, library)
|
||||
assertEquals("new save", copied.readText())
|
||||
assertEquals("original", save.readText())
|
||||
}
|
||||
|
||||
@Test fun changingLibraryUsesADifferentPrivatePrefixDirectory() {
|
||||
val files = tmp.newFolder("files")
|
||||
assertNotEquals(SecondaryLibrary.privateRoot(files, tmp.newFolder("one")),
|
||||
SecondaryLibrary.privateRoot(files, tmp.newFolder("two")))
|
||||
}
|
||||
|
||||
@Test fun installedNativeToolVersionsAlsoGetPrivateStaging() {
|
||||
val files = tmp.newFolder("files")
|
||||
val library = tmp.newFolder("card")
|
||||
val manifest = File(library, "steamapps/appmanifest_123.acf")
|
||||
manifest.parentFile!!.mkdirs()
|
||||
manifest.writeText("\"AppState\" { \"appid\" \"123\" \"installdir\" \"Proton 99.0 (ARM64)\" }")
|
||||
assertTrue(SecondaryLibrary.binds(files, library).any {
|
||||
it.endsWith(":/mnt/droiddeck-sd/steamapps/downloading/123")
|
||||
})
|
||||
}
|
||||
|
||||
@Test fun deletingAStagedDepotDoesNotRestoreTheOldSharedDownload() {
|
||||
val files = tmp.newFolder("files")
|
||||
val library = tmp.newFolder("card")
|
||||
val stale = File(library, "steamapps/downloading/4185400/stale")
|
||||
stale.parentFile!!.mkdirs()
|
||||
stale.writeText("old download")
|
||||
SecondaryLibrary.binds(files, library)
|
||||
val target = File(SecondaryLibrary.privateRoot(files, library), "steamapps/downloading/4185400")
|
||||
assertTrue(File(target, "stale").isFile)
|
||||
target.deleteRecursively()
|
||||
SecondaryLibrary.binds(files, library)
|
||||
assertFalse(File(target, "stale").exists())
|
||||
assertTrue(stale.isFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
# proot, glibc and where the time goes
|
||||
|
||||
Research notes for `feat/performance-fixes-2` (2026-09-30). The question was whether Steam and the
|
||||
games should keep running under proot, run directly on glibc, or a mix of the two, and what the
|
||||
fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel
|
||||
6.12.38-android16, Android 16) over adb, unless it says otherwise.
|
||||
|
||||
## Short answer
|
||||
|
||||
- **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every
|
||||
syscall that Android's app seccomp policy blocks (`rseq`, `set_robust_list`, `faccessat2`,
|
||||
`fchmodat2`, `openat2`, the `set*id` family, `futex_waitv`, ...).
|
||||
- Stock Arch glibc without a tracer is killed by `SIGSYS` on its first `pthread_create`. That
|
||||
stays true even with an in-process `SIGSYS` handler, because glibc makes those calls with every
|
||||
signal blocked.
|
||||
- Going "pure glibc" means building Termux-style glibc (relocated prefix, blocked syscalls
|
||||
rewritten), an exec shim for every ELF Steam self-updates, a `/proc/self/exe` emulator for
|
||||
Chromium, path redirects for `/tmp`, `/dev/shm`, `/mnt/*` and the fake `/proc` and `/sys`
|
||||
files, and Wine's hard-coded `/tmp/.wine-<uid>`.
|
||||
- That is a project of its own, and its failure mode is a crash instead of a slow call.
|
||||
- **During gameplay, proot already costs almost nothing**, thanks to patches 0004–0013. GPU submits
|
||||
(`ioctl`), `futex`, `read`/`write`, `mmap`, `ppoll`/`epoll`, `sendmsg`/`recvmsg` and
|
||||
`clock_gettime` (via the vDSO) all run at native speed under the filter.
|
||||
- **What proot still costs** is every *path* syscall, `execve`, `brk`, thread and process creation,
|
||||
and every Android-blocked syscall: **15–60 µs each**, against ~0.5 µs natively. All of it goes
|
||||
through **one tracer thread**, which tops out around 75–95k traps/s. Steam start-up, game loading
|
||||
and asset streaming, Proton's Python, and any burst on several threads at once all hit this.
|
||||
- **Most of that cost is not proot's code but the two cross-core wake-ups per trap.** Any
|
||||
out-of-process mechanism pays them: ptrace, and seccomp user-notification too (measured the same).
|
||||
The only real fix is to not leave the process.
|
||||
- **Recommendation: proot plus an in-process fast path.** A preloaded library answers the common
|
||||
path calls inside the process. It sends them through a trampoline page that proot's filter lets
|
||||
through, and it falls back to proot for anything it can't prove proot would answer the same way.
|
||||
- The prototype is in this branch: `tools/proot/fastpath/` and `patches/0014`.
|
||||
- Speed: `stat` 25 µs → 0.6 µs, `open` 25 µs → 0.9 µs, missing-file lookups 23 µs → 1.6 µs.
|
||||
Eight threads doing `stat` went from 89k/s to 3.6M/s.
|
||||
- Correctness: an equivalence suite of awkward paths produced byte-identical results.
|
||||
- It only uses syscalls the app policy allows.
|
||||
|
||||
## How a session runs today
|
||||
|
||||
```
|
||||
SessionService ─HostProcess─▶ libproot.so (ONE tracer process, nice -6 after 2 s)
|
||||
└─ /usr/bin/env -i … bannerlator-session steam
|
||||
└─ gamescope ─▶ bannerlator-session (BL_INSIDE) ─▶ steamrtarm64/steam (+ steamwebhelper/CEF tree)
|
||||
└─ reaper ─▶ bannerlator-game-env ─▶ bannerlator-proton ─▶ Valve ARM64 Proton (python)
|
||||
└─ wine (arm64ec, FEX loaded in-process as a DLL) ─▶ wineserver, game.exe, …
|
||||
```
|
||||
|
||||
- Every process in the tree, from the session script to the game, is a tracee of the same proot.
|
||||
- The options are `--kill-on-exit`, `--kernel-release=…` (kompat, for the hostname), `-i uid:uid`
|
||||
(fake_id0, for Xwayland's `setgid`/`setuid`), and about 25 binds: `/dev`, `/proc`, `/sys`, fake
|
||||
`/proc` and `/sys` files, the GPU node, `/dev/shm`, storage and the game libraries.
|
||||
- `/etc/ld.so.preload` loads `libblsession.so` (the `tools/linuxfs/preload/*.c` shims) into every
|
||||
guest process.
|
||||
- The Flatpak sandboxes (`BwrapSpawner`) already run in **separate** proot instances, started from
|
||||
the Android side.
|
||||
|
||||
### What proot does per traced syscall
|
||||
|
||||
The tracee runs into proot's seccomp filter, which returns `SECCOMP_RET_TRACE`. Then:
|
||||
|
||||
1. The kernel stops the tracee and wakes the tracer: a cross-core IPI, and possibly an exit from a
|
||||
deep idle state.
|
||||
2. The tracer reads the registers (`PTRACE_GETREGSET`) and reads the path (`process_vm_readv`).
|
||||
3. It canonicalises the path against the rootfs and bindings: an `lstat` per component, or one
|
||||
`O_PATH` open plus a `/proc/self/fd` readlink with patch 0002.
|
||||
4. It writes the host path into a scratch area in the tracee (`process_vm_writev`) and updates the
|
||||
registers.
|
||||
5. It resumes the tracee, which is another cross-core wake-up.
|
||||
|
||||
Calls flagged `FILTER_SYSEXIT` stop a second time on the way out, for example to translate the
|
||||
result of `readlink` or `getcwd`. Android-blocked syscalls arrive as a `SIGSYS` signal stop instead,
|
||||
and proot emulates them (`src/tracee/seccomp.c`). Fork, clone, exec and thread exit each add
|
||||
`PTRACE_EVENT_*` stops.
|
||||
|
||||
## Measurements
|
||||
|
||||
The tools are in `tools/proot/bench/`; `run-device.sh` explains the device layout.
|
||||
|
||||
**Caveat:** `adb shell` is *not* under the app seccomp filter. Mechanism costs are the same, but
|
||||
syscalls that Android blocks only for apps (see below) can't be reproduced there.
|
||||
|
||||
**Caveat:** the phone in Doze (screen off) runs everything about 7× slower. All numbers below were
|
||||
taken with the screen awake.
|
||||
|
||||
### Per call, as a session runs proot today
|
||||
|
||||
`sysbench` in the patched (`patched`) and unpatched (`vanilla`) columns, using the session's own
|
||||
proot options and a similar set of binds.
|
||||
|
||||
| call | direct glibc | termux proot (vanilla) | this repo's proot (0001–0013) | + fast path (0014) |
|
||||
|---|---|---|---|---|
|
||||
| getppid / futex / ioctl / pread (untraced) | 0.1–0.4 µs | futex **16.6**, fstat **32.8**, ioctl **30.5** µs | 0.2–0.4 µs | 0.2–0.4 µs |
|
||||
| stat, deep absolute path | 0.8 µs | 41.7 | 25.3 | **0.6** |
|
||||
| open + close | 1.7 µs | 40.4 | 25.3 | **0.9** |
|
||||
| access | | 27.2 | 26.2 | **1.0** |
|
||||
| fstatat relative to a dirfd | | 45.5 | 26.8 | **1.9** |
|
||||
| stat of a missing file | | 38.9 | 22.9 | **1.6** |
|
||||
| readlink /proc/self/exe | | 42.8 | 38–43 | (proot) |
|
||||
| getcwd / uname / brk | | 31–35 | 30–33 | (proot) |
|
||||
| memfd_create | | 20.4 | 18.6 | (proot) |
|
||||
| pthread create+join | | 58 | 48 | 48 |
|
||||
| fork + exec + wait | ~1.1 ms | 1.16 ms | 0.70–0.78 ms | +0.2–0.4 ms (see open items) |
|
||||
|
||||
The patches already in the repo pay off: kompat (0011) and fake_id0 (0012) took `futex`, `fstat` and
|
||||
`sendmsg` off the trap list, and 0013 did the same for `ioctl`.
|
||||
|
||||
### What one trap costs, by mechanism (`mechbench`; the handler does nothing)
|
||||
|
||||
| mechanism | cost |
|
||||
|---|---|
|
||||
| native syscall | 0.1 µs |
|
||||
| seccomp filter of 0 / 50 / 150 / 300 compares, allowed syscall | **no difference** (kernel ≥5.11 caches constant decisions per syscall number) |
|
||||
| ptrace + seccomp, tracer and tracee on the **same core** | 6.2 µs (6.7 µs with register get/set) |
|
||||
| ptrace + seccomp, **unpinned** (as the app runs it) | 15.5 µs |
|
||||
| ptrace + seccomp, different cores (same cluster / across clusters) | 34–36 / 42–61 µs |
|
||||
| ptrace with a spinning tracer, different cores | 20 µs (the tracee's side still has to wake) |
|
||||
| seccomp user-notification (the "modern proot" design) | 5.7 µs same core, 34–58 µs across, 43 µs unpinned; `SYNC_WAKE_UP` doesn't help |
|
||||
| **in-process SIGSYS** (`SECCOMP_RET_TRAP`) | **0.83 µs** |
|
||||
| openat2 `RESOLVE_IN_ROOT` (kernel-side chroot lookup) | 1.0 µs, but **blocked in apps** |
|
||||
|
||||
Takeaways:
|
||||
- The tracer's own work is about 8 µs of the ~14 µs same-core trap. Everything above that is
|
||||
scheduling.
|
||||
- Moving to seccomp user-notification would be a rewrite for no gain.
|
||||
- The filter's length doesn't matter. What matters is not breaking the kernel's cache:
|
||||
- **Any check on an argument or the instruction pointer must come after the syscall-number
|
||||
dispatch.** The first version of 0014 checked the address first. That made every syscall in
|
||||
the session run the whole filter, and cost +0.5 ms per exec.
|
||||
- Patches 0004 and 0013 already follow this rule.
|
||||
|
||||
### One tracer for everything (`parstat`: N threads doing `stat`)
|
||||
|
||||
| | 1 thread | 4 threads | 8 threads |
|
||||
|---|---|---|---|
|
||||
| one proot | 37k/s, 27 µs | 74–95k/s, 42–54 µs | 68–89k/s, **90–118 µs** each |
|
||||
| two proots, 2 threads each | | 187k/s total, 21 µs | |
|
||||
| proot + fast path | 1.64M/s, 0.6 µs | 2.76M/s | **3.60M/s, 2.2 µs** |
|
||||
|
||||
A burst of path calls on several threads therefore queues behind the single tracer. CEF loading
|
||||
assets, Proton's Python, a game streaming files on worker threads and the shader-cache threads all
|
||||
produce such bursts.
|
||||
|
||||
### CPU placement
|
||||
|
||||
The workload ran with `taskset`; no fast path. Times are per traced call, and the last column is one
|
||||
fork + exec.
|
||||
|
||||
| affinity | stat | exec |
|
||||
|---|---|---|
|
||||
| default | 25.8 µs | 750 µs |
|
||||
| one core | 14.0 µs | 370 µs |
|
||||
| the two prime cores | 14.6 µs | |
|
||||
| the six-core cluster | 25.9 µs | |
|
||||
|
||||
The scheduler's wake-affine placement helps when it can put the tracer and the tracee together, and
|
||||
spreading them apart hurts. **Never pin the tracer away from its tracees.** Today `raiseTracer`
|
||||
only renices it, which is correct.
|
||||
|
||||
### Whole programs
|
||||
|
||||
Times are in ms, each with proot's ~40 ms start-up already subtracted.
|
||||
|
||||
| | direct glibc | vanilla proot | this repo's proot | + fast path |
|
||||
|---|---|---|---|---|
|
||||
| python3: 10 stdlib imports | 75–89 | ~130 | ~110 | ~90 (878 of 879 path calls answered in-process) |
|
||||
| tar of 2.7k files | 10–40 | ~180 | ~80 | ~20 |
|
||||
| 200 × fork+exec of `true` | 230 | ~600 | ~500 | ~500 |
|
||||
| find over 2.7k files | 16–23 | noisy | noisy | unchanged: glibc's fts calls internal entry points |
|
||||
|
||||
## Android's app seccomp policy decides more than proot does
|
||||
|
||||
The app filter is generated from bionic's `SYSCALLS.TXT`, minus the blocklists, plus the
|
||||
allowlists. It is the same for every app whatever its `targetSdk`, and anything not listed gets
|
||||
`SECCOMP_RET_TRAP`, a `SIGSYS`. Filters stack, and the strictest one wins: a blocked call never
|
||||
reaches proot's `RET_TRACE`. It arrives as a `SIGSYS` stop, and proot answers it (usually `ENOSYS`).
|
||||
|
||||
Checked against bionic `android16-release`:
|
||||
|
||||
| syscall | app policy | who calls it |
|
||||
|---|---|---|
|
||||
| `rseq`, `set_robust_list` | **blocked** | glibc, at the start of every thread, while all signals are blocked |
|
||||
| `faccessat2` | **blocked** | glibc's `faccessat()`, which tries it on **every** call before falling back, so 2 round trips (`access()` calls the plain `faccessat` syscall directly) |
|
||||
| `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx |
|
||||
| `setuid`, `setgid` and the rest of the family | **blocked** | Xwayland (hence `-i`) |
|
||||
| `futex_waitv`, `io_uring_*`, `landlock_*` | **blocked** | Proton's fsync, some engines |
|
||||
| `clone3` | blocked before Android 15 | glibc `pthread_create` |
|
||||
| `close_range` | blocked on Android 12 | |
|
||||
| `statx`, `process_vm_readv`/`writev`, `memfd_create`, `pidfd_*`, `seccomp` | allowed | |
|
||||
|
||||
On top of the ptrace round trips, every one of these costs a signal round trip through proot.
|
||||
|
||||
## Every traced syscall: what it needs and whether it can bypass proot
|
||||
|
||||
This is the filter of termux proot at `source.env` plus patches 0001–0013. "Fast path" means the
|
||||
call can be answered in-process with the 0014 design.
|
||||
|
||||
| syscalls | why proot traps them | can it bypass proot? |
|
||||
|---|---|---|
|
||||
| `openat`/`open`/`creat`, `newfstatat`/`stat`/`lstat`, `statx`, `faccessat`/`access`, `readlinkat` (exit), `mkdirat`, `unlinkat`, `renameat(2)`, `symlinkat`, `linkat`, `fchmodat`, `fchownat`, `utimensat`, `truncate`, `*xattr`, `inotify_add_watch`, `name_to_handle_at` | path translation (guest → host), plus exit fixups for `readlink` and `rename` | **Yes, through libc wrappers.** The prototype covers open, stat, statx, access, readlink, opendir and fopen; the write family is next. Raw `svc` and glibc's internal callers (ld.so, fts, realpath, nss) still go to proot. |
|
||||
| `chdir`/`fchdir`, `getcwd` (exit) | proot emulates the cwd; until 0014 the kernel's cwd never moved | With 0014 the kernel's cwd follows the guest's. `getcwd` stays in proot (cheap, rare). |
|
||||
| `execve`/`execveat` | runs the loader, maps `PT_INTERP` inside the rootfs, shebangs, tracks `/proc/self/exe` | **No.** This is the core of what proot is for. Costs 0.4–0.8 ms per exec; the session scripts already avoid exec-heavy loops (`nap`). |
|
||||
| `brk` (entry and exit) | heap emulation: programs are mapped by proot's loader, so the kernel's brk area is the loader's | No (`PR_SET_MM` needs `CAP_SYS_RESOURCE`). **Fewer calls:** `GLIBC_TUNABLES=glibc.malloc.top_pad=…` grows the heap in larger steps. |
|
||||
| `bind`, `connect`, `accept(4)`, `getsockname`, `getpeername` | `sun_path` of Unix sockets | Same-path binds (runtime dir, files dir) need no translation, but the filter can't see the address. Possible fast-path candidate. |
|
||||
| `wait4`/`waitpid`, `ptrace` | ptrace emulation inside the guest (breakpad, gdb) | Could become opt-in; then `wait4` is free (one stop per wait today). |
|
||||
| `prctl(PR_SET_DUMPABLE)`, `setrlimit`/`prlimit64(RLIMIT_STACK)` | loader and stack fixups | Already narrowed to these arguments (0004). |
|
||||
| `ioctl(TCSETSF, termios2, FICLONE)` | Android pty policy, FICLONE `EACCES` | Already narrowed to these requests; GPU ioctls are free (0013). |
|
||||
| `uname`, `sethostname`, `setdomainname` | the `DroidDeck` hostname (kompat) | Rare. Keep. |
|
||||
| `set*id`, `*setxattr` | fake_id0 identity mode (0012) | Rare. Keep. |
|
||||
| `memfd_create` | Qt JIT and php workarounds (string argument) | Every Wayland `wl_shm` buffer and Chromium shared memory pays one stop. Could be dropped if the DroidDeck runtime doesn't need those workarounds. |
|
||||
| `statfs` (exit) | fakes tmpfs for `/dev/shm` | Rare. |
|
||||
| `open_tree`, `move_mount`, `fspick`, `mount_setattr`, `openat2` | answered `ENOSYS` (0008, 0010) | Fine. |
|
||||
| Android-blocked calls (previous table) | `SIGSYS` emulation | **Avoidable at the source:** `GLIBC_TUNABLES=glibc.pthread.rseq=0` removes one stop per thread. The rest need a patched glibc. |
|
||||
| clone/fork/vfork/exec/exit events | tracking tracees | Needed while proot traces. |
|
||||
|
||||
## Options weighed
|
||||
|
||||
1. **Pure glibc, no proot.** Rejected as the main path, for the reasons in the short answer.
|
||||
- Closest prior art: huntergdavis/steamclienttermux, which runs `steamrtarm64` on a patched
|
||||
Termux glibc 2.44 with an exec shim and a `/tmp` and `/dev/shm` redirect preload.
|
||||
- They report Steam's first window 7× faster and about 5% more FPS, with proot having used 60–65%
|
||||
of a core.
|
||||
- **They still run Proton and the games under proot.**
|
||||
- DroidDeck also has four package names (`tools/release/variants.txt`), so a relocated prefix
|
||||
would need four builds or Winlator-style padded path rewriting.
|
||||
2. **Hybrid by process (Steam in proot, game on glibc, or the reverse).** The game side is the
|
||||
*harder* half. Wine re-execs itself and `wineserver`, which hits `PT_INTERP`. Its server
|
||||
directory is hard-coded under `/tmp`, and Proton's esync and fsync use `shm_open` (`/dev/shm`)
|
||||
and `futex_waitv`, which is blocked. A game's steady state is already mostly untraced, so the
|
||||
gain is in loading. Not worth it before the fast path.
|
||||
3. **Seccomp user-notification instead of ptrace.** It measures the same as ptrace, and it can't
|
||||
rewrite arguments, so every call would have to be emulated. No.
|
||||
4. **In-process `SIGSYS` for everything** (0.8 µs). The kernel resets a blocked `SIGSYS` to the
|
||||
default action and kills the process (`force_sig_seccomp`, `HANDLER_CURRENT`). glibc and Wine
|
||||
block all signals around exactly these calls. Unsafe without also trapping `rt_sigprocmask`. No.
|
||||
5. **proot plus an in-process fast path (this branch).** Recommended. Details below.
|
||||
6. **A proot-aware glibc**, the follow-up to 5. Rebuild the rootfs's own glibc (same version, same
|
||||
Arch package) with two changes:
|
||||
- its internal path entry points (`__open_nocancel`, `__fstatat64`, ld.so's opens) go through the
|
||||
fast path, which covers ld.so, fts, realpath and nss;
|
||||
- it never makes the Android-blocked calls, as Termux's `fakesyscall.json` does.
|
||||
|
||||
This removes the remaining library-loading traps and all the `SIGSYS` round trips. The rootfs is
|
||||
versioned (`linuxfs-rN`), so glibc rebuilds stay under control.
|
||||
7. **On the tracer side, with no in-process changes:**
|
||||
- **A second proot for the game tree.** Measured 2.5× trap throughput, and Steam/CEF bursts no
|
||||
longer stall the game. `BwrapSpawner` already shows the pattern: a stand-in process in the
|
||||
session relays stdio, the exit status and signals to a proot started from the app.
|
||||
- Leave the tracer unpinned (above).
|
||||
- Set `GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216` in the session
|
||||
environment.
|
||||
|
||||
## The fast path (prototype)
|
||||
|
||||
The code is `tools/proot/fastpath/fastpath.c`, preloaded into every guest process. On the proot side
|
||||
it needs `patches/0014`, enabled with `PROOT_FASTPATH=1`.
|
||||
|
||||
- **Trampoline.** The library maps a 4 KiB executable page at the fixed address `0xffff00000`. It
|
||||
holds `mov x8,x0 … svc #0; ret`. With `PROOT_FASTPATH`, proot's filter checks the caller's address
|
||||
just before each `RET_TRACE` and allows syscalls coming from that page.
|
||||
- **Mapping a path.** The library takes the literal guest path and finds the longest binding whose
|
||||
guest path is a prefix of it; otherwise the path is under the rootfs.
|
||||
- It opens the *parent* host directory `O_PATH` and requires `/proc/self/fd` to name exactly that
|
||||
path. That proves no component on the way was a symlink, so proot would have walked the same
|
||||
directories.
|
||||
- The last component is never followed: a symlink there goes to proot.
|
||||
- **Missing files.** For a missing file, the deepest existing ancestor is verified the same way, and
|
||||
the next component must not exist even as a symlink. Then `ENOENT` is exact.
|
||||
- **What always goes to proot:**
|
||||
- `..`, a trailing `/`, `/proc`, `/dev/fd` and `/dev/std*`;
|
||||
- `O_TMPFILE`, `AT_EACCESS`, and any flag not handled;
|
||||
- any doubt at all. "Go to proot" means calling the real libc function, so proot decides.
|
||||
- **Relative paths.** Patch 0014 keeps the kernel's cwd at the guest cwd's host directory. Relative
|
||||
and dirfd lookups are mapped through `/proc/self/{cwd,fd/N}`.
|
||||
- **Verified-parent cache.** Verified parents are cached for 2 s (`PROOT_FP_TTL_MS`). That is the
|
||||
same trade-off `preload/pathcache.c` already makes for the Steam client. A parent directory turned
|
||||
into a symlink inside that window would be missed; `PROOT_FP_TTL_MS=0` verifies every call, at
|
||||
2.0 µs instead of 0.6 µs.
|
||||
- **Allowed syscalls only.** It uses only `openat`, `newfstatat`, `statx`, `faccessat`, `readlinkat`
|
||||
and `close`, all allowed for apps. A first version used `openat2 RESOLVE_IN_ROOT`, which the app
|
||||
policy blocks.
|
||||
- **Equivalence check.** `bench/equiv.py` runs stat, lstat, readlink, read, listdir and access over
|
||||
awkward paths, from several working directories and through a dirfd. Its output is byte-identical
|
||||
with and without the fast path, at TTL 0 and at TTL 2000. The paths include:
|
||||
- absolute and relative symlinks into binds, `..` symlinks, dangling links and loops;
|
||||
- files inside binds, nested binds (`/dev/shm` inside `/dev`) and `/proc`;
|
||||
- missing paths behind symlinks.
|
||||
|
||||
### Open items before shipping it
|
||||
|
||||
- **Validate inside the app**, not just from adb. Three things to check:
|
||||
- The app domain allows an anonymous `PROT_EXEC` page (`execmem`). If not, map a page of the
|
||||
library file itself at the fixed address instead, as proot's loader does for every program.
|
||||
- Nothing claims `0xffff00000` first.
|
||||
- The stub's syscalls pass Android's filter. They should: all are allowed.
|
||||
- **Integrate into `libblsession.so`** rather than a second preload. `opens.c` and `pathcache.c`
|
||||
already define `open` and `access`, so their "real" call should become the fast path. That also
|
||||
removes the second library's load cost, about 0.2–0.4 ms per exec that is still unexplained (see
|
||||
the exec row above).
|
||||
- **Wire the launcher.** The app already knows the binds (`LinuxRuntime.binds`):
|
||||
- set `PROOT_FASTPATH=1` in proot's environment;
|
||||
- pass `PROOT_FP_ROOT` and `PROOT_FP_BINDS` to the guest;
|
||||
- add a Performance toggle next to "Run proot without seccomp", with the fast path off whenever
|
||||
seccomp is off.
|
||||
- **Cover the write family** (mkdir, unlink, rename, symlink, chmod, utimens) and Unix-socket
|
||||
`connect`/`bind` for same-path binds. Then measure a real Steam start in the app, a game load,
|
||||
and `PROOT_FP_STATS` hit rates for steam, steamwebhelper, wine and wineserver.
|
||||
- **FEX**, inside Proton as a DLL, translates x86 path syscalls through glibc's `syscall()`, not the
|
||||
wrappers, so they still trap. A `syscall()` hook could route `openat` and `newfstatat` there too.
|
||||
@@ -0,0 +1,125 @@
|
||||
# DroidDeck 0.3.0 (beta)
|
||||
|
||||
<p align="center"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
|
||||
|
||||
## Highlights
|
||||
|
||||
### Steam, as on a Steam Deck
|
||||
Deck mode is now the default. Steam's performance overlay goes from level 1 to 4 in Quick Access and shows GPU and CPU load, clocks, temperatures, VRAM and battery, even on phones that lock those files away. Games keep running behind the Quick Access Menu and the Steam menu, and Quick Access shows battery life and time to full.
|
||||
|
||||

|
||||
|
||||
### A Steam Deck controller
|
||||
In Steam sessions the client reads your pad as a Steam Deck controller, with a real Quick Access button and your handheld's gyro. Games get Steam Input's virtual pads, so your Steam Input layouts reach them. On a dual-screen handheld, the second screen can be the Deck's back grips and trackpads.
|
||||
|
||||
<p>
|
||||
<img src="media/0.3.0/deck-controller.gif" width="60%" alt="Steam's Steam Deck Controller test on the top screen reading the trackpads and clicks played on the second screen's Deck trackpads and grips">
|
||||
</p>
|
||||
|
||||
### Faster to Steam, faster in games
|
||||
Steam's own startup is down from about 31 seconds to about 6, and that makes **Offline mode** work. proot has been optimized for calls games make most, and there's a new per-mode frame limiter.
|
||||
|
||||

|
||||
|
||||
### GPU drivers that set themselves
|
||||
A new **GPU drivers** tab detects your GPU and keeps a matched pair of runtime and display drivers installed for it, including DroidDeck's own DD-Turnip builds. Switch to **Manual** to pick a pair yourself.
|
||||
|
||||

|
||||
|
||||
### Linux apps from Flathub, bring your own Appimage, and more
|
||||
Turn on the **Flathub Store** in Setup to install ARM64 apps and games from Flathub. They open full screen from the front end with your controller. The Desktop page also takes your own apps: a script, an AppImage, a GitHub repo's newest ARM64 AppImage, or a Flatpak ID.
|
||||
|
||||
<p>
|
||||
<img src="media/0.3.0/store-app.jpg" width="49%" alt="Space Cadet Pinball's page in the Flathub Store">
|
||||
<img src="media/0.3.0/add-app.jpg" width="49%" alt="Adding Zen Browser from its GitHub repo, with icons suggested from the repo">
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
### Updates in the app
|
||||
The **Updates** page installs new builds without leaving DroidDeck: **Stable**, **Preview** (each build of main) or a **Test build** of an open pull request. Every download is checked against its checksum and DroidDeck's release key before Android sees it.
|
||||
|
||||

|
||||
|
||||
### A front end that moves as one
|
||||
One focus ring slides between controls, settings flood out of the cog the way Play floods into a session, and Stop asks before it ends anything. DroidDeck is now available in Spanish, with more languages coming soon (contributions welcome).
|
||||
|
||||
<p>
|
||||
<img src="media/0.3.0/focus-ring.gif" width="59%" alt="Moving the focus ring with a D-pad, opening Steam settings from the cog and backing out">
|
||||
<img src="media/0.3.0/stop-session.gif" width="39%" alt="Stopping a session: the confirmation grows out of the Stop button, then the session floods back into Play Steam">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Everything in 0.3.0
|
||||
|
||||
### Steam
|
||||
|
||||
- Steam's startup down from about 31 s to about 6 s, so Offline mode works (@xXJSONDeruloXx, #69)
|
||||
- Deck mode on for everyone, moved once on update, always on the Steam Deck client branch so Steam stops offering a downgrade (@xXJSONDeruloXx, #100, #101, #141)
|
||||
- Steam's performance overlay (MangoHud), levels 1–4, with Adreno GPU, CPU, VRAM and temperature stats, and a switch to turn it off (@xXJSONDeruloXx, #55, #101)
|
||||
- The overlay keeps running and filled in under an enforcing SELinux policy, as on retail phones (@xXJSONDeruloXx, #127)
|
||||
- Games keep running behind the Quick Access Menu and the Steam menu (@xXJSONDeruloXx, #131)
|
||||
- A game that minimized itself behind the Steam menu comes back on Resume (@The412Banner, #89)
|
||||
- Quick Access shows battery life and time to full (@xXJSONDeruloXx, #117)
|
||||
- Steam shuts down cleanly when you stop a session, so it keeps its caches (@xXJSONDeruloXx, #68)
|
||||
- Games you own in a second library or games folder become real Steam games instead of shortcuts (@maxjivi05, #62)
|
||||
- Imported games keep their installed build, so Steam doesn't re-verify them (@maxjivi05, #108)
|
||||
- Launch options on added games are kept between sessions (@xXJSONDeruloXx, #111)
|
||||
- Choose the host name Steam sees (@magarto, #61)
|
||||
- Stretch 16:9 games onto screens narrower than 16:9, such as foldables (opt-in) (@magarto, #63)
|
||||
|
||||
### Controls
|
||||
|
||||
- Steam Deck controller for the Steam client: a real Quick Access button, the handheld's gyro, and Steam Input's virtual pads for games (@xXJSONDeruloXx, #86)
|
||||
- Deck back grips and trackpads on the second screen (@xXJSONDeruloXx, #86)
|
||||
- Xbox 360 controller option, with the right axis layout for games that have Steam Input off (@xXJSONDeruloXx, #86, #102)
|
||||
- The Steam client finds the Deck controller under an enforcing SELinux policy (@xXJSONDeruloXx, #125)
|
||||
- Touch scrolls Steam on phones with no controller at start (@xXJSONDeruloXx, #123)
|
||||
- Quick Access from Back comes sooner, and no longer opens the focused game under load (@xXJSONDeruloXx, #70, #73)
|
||||
- Shift and Caps Lock type capitals (@xXJSONDeruloXx, #71)
|
||||
- Adaptive sticks hide when idle and appear where you touch (@maxjivi05, #53)
|
||||
|
||||
### Display and performance
|
||||
|
||||
- GPU drivers tab: Auto keeps a matched runtime and display pair for your GPU, Manual lets you pick one (@xXJSONDeruloXx, #103)
|
||||
- DD-Turnip, DroidDeck's own driver bundles (@maxjivi05, #106)
|
||||
- Adreno 840v2 (retail SM8850) works with the runtime's own driver (@xXJSONDeruloXx, #93)
|
||||
- Setup says when a GPU is untested (Adreno 6xx, 710, 720, 722) (@xXJSONDeruloXx, #103)
|
||||
- LSFG takes Lossless Scaling from your Steam library by itself, adds 60, 90 and 120 fps targets, and paces generation to the game's frames (@maxjivi05, #96, #129)
|
||||
- Per-mode frame limiter, live panel refresh, opt-in GPU clock pin and Mesa's database shader cache (@maxjivi05, #87)
|
||||
- proot stops less often: kompat, fake_id0 and ioctl traps trimmed, and common path lookups answered in-process (@xXJSONDeruloXx, @maxjivi05, #81, #84, #113)
|
||||
- Turnip's GPU timestamp polls answered without blocking (@maxjivi05, #87)
|
||||
- Cheaper defaults: Steam's xalia helper skipped, gamescope's realtime queue off, microphone off (@xXJSONDeruloXx, #85)
|
||||
- Per-game environment variables, now in an editor that works with a pad (@maxjivi05, @xXJSONDeruloXx, #60, #140)
|
||||
|
||||
### Apps and desktop
|
||||
|
||||
- Flathub Store: ARM64 Flatpak apps and games, opened full screen from the front end and listed on the Desktop page (@xXJSONDeruloXx, #77)
|
||||
- Add your own apps to the Desktop page: a script, an AppImage, a GitHub repo or a Flatpak ID, with icons and update checks (@maxjivi05, #130)
|
||||
- The desktop hands its cursor to the app, so full-screen games skip a compositing pass (@xXJSONDeruloXx, #57)
|
||||
|
||||
### Launcher
|
||||
|
||||
- Updates page: Stable, Preview and Test builds, installed in the app and verified first (@xXJSONDeruloXx, #98, #112, #115)
|
||||
- Game saves: import and export GameHub and Winlator save zips for added games (@The412Banner, #91)
|
||||
- One focus ring slides between controls, settings flood out of the cog, and focus returns to what opened a page (@xXJSONDeruloXx, #105, #140)
|
||||
- Stop asks first, and quitting floods back into the launch button (@xXJSONDeruloXx, #72, #105)
|
||||
- Spanish, with every screen's text moved to translatable strings (@xXJSONDeruloXx, #140)
|
||||
- Pair wireless debugging from a notification to lift Android's child-process limit (@xXJSONDeruloXx, #67)
|
||||
|
||||
### Sessions and logs
|
||||
|
||||
- Session folders are named by day, number and mode, the newest 30 are kept, and Setup has **Clear logs** (@xXJSONDeruloXx, #122)
|
||||
- Shared logs carry controller and touch-mode details for input reports (@xXJSONDeruloXx, #125)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Steam sessions failing to start with the app on adopted SD storage (@xXJSONDeruloXx, #119)
|
||||
- Second-library games failing to launch or install their dependencies (@xXJSONDeruloXx, #134)
|
||||
- Steam's own installs on a second library showing up as non-Steam shortcuts (@xXJSONDeruloXx, #138)
|
||||
- A white veil left after closing the session menu (@xXJSONDeruloXx, #135)
|
||||
- Update channel cards flashing as a controller moved over them (@xXJSONDeruloXx, #99)
|
||||
- Flatpak installs failing (@maxjivi05, #82)
|
||||
- gamescope respawning a crashing overlay every second (@xXJSONDeruloXx, #81)
|
||||
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -117,6 +117,9 @@ docker run --rm --platform linux/amd64 \
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$d/libblsession.so" tools/linuxfs/preload/*.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$d/libblsession.so"
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$d/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$d/libblfastpath.so"
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
|
||||
install -Dm644 "$script" "$d/usr/local/bin/$(basename "$script")"
|
||||
done
|
||||
@@ -221,9 +224,13 @@ while read -r package_sha256 package_url; do
|
||||
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"
|
||||
for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do
|
||||
for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
|
||||
cp -L "${mango_pkgs}/usr/lib/${library}" "${mango_dir}/${library}"
|
||||
done
|
||||
# Ours, not the package's: GPU memory without tracefs (tools/mangoapp/libtracefs-shim.c).
|
||||
docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" -v "${repo_root}:/src" -w /src "${image_name}" \
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
|
||||
-o app/src/main/assets/linuxfs/usr/local/lib/mangoapp/libtracefs.so.1 tools/mangoapp/libtracefs-shim.c
|
||||
mkdir -p "${linuxfs_dir}/usr/local/bin"
|
||||
install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/bin/mangoapp"
|
||||
|
||||
|
||||
@@ -39,5 +39,13 @@ library list, before anything is published.
|
||||
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).
|
||||
|
||||
- `0113-steam-overlay-keeps-game-keyboard-focus.patch` - this app: the Steam client opens the Quick
|
||||
Access Menu and Steam menu over a game with `STEAM_INPUT_FOCUS` 1, which moves X keyboard focus to
|
||||
its overlay; wine then deactivates the game and a fullscreen game minimizes itself, freezing
|
||||
behind the menu instead of running on as on a Steam Deck. Steam's own overlay taking input now
|
||||
keeps keyboard focus on the game, as mode 2 does; its input comes from the controller through
|
||||
Steam Input, not the X keyboard. The pointer warps gamescope makes as input moves to Steam and
|
||||
back are skipped around it, since the game - still taking input - saw them as a mouse jump.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,104 @@
|
||||
From: DroidDeck
|
||||
Subject: [PATCH] steamcompmgr: keep the game's keyboard focus under Steam's overlay
|
||||
|
||||
The Steam client opens its Quick Access Menu and Steam menu over a game by setting
|
||||
STEAM_OVERLAY 1 and STEAM_INPUT_FOCUS 1 on its window. Mode 1 moves X keyboard focus to the
|
||||
overlay, so the game's window gets FocusOut: wine moves the foreground to the desktop, a fullscreen
|
||||
game deactivates and minimizes itself (WM_CHANGE_STATE IconicState), and it stops rendering behind
|
||||
the menu - frozen, all threads asleep, until the menu closes (Alan Wake's American Nightmare,
|
||||
Proton Experimental ARM64). On a Steam Deck the game runs on behind the menu.
|
||||
|
||||
Mode 2 already means "input to the overlay, keyboard focus stays on the focus window". Treat
|
||||
Steam's own overlay taking input in mode 1 the same way at the three places mode 2 is honoured.
|
||||
The overlay's input does not need X keyboard focus: the controller drives it through Steam Input.
|
||||
Measured by rewriting the property to 2 by hand: the game kept running behind the menu and the
|
||||
controller still moved only the menu.
|
||||
|
||||
With the game keeping focus, the pointer resets gamescope makes when input moves to Steam (warp
|
||||
to the centre, park in the corner) and back (warp to the centre) reached it as a sudden mouse
|
||||
move: a mouse-look camera spun every time the menu opened and closed. Skip them while Steam's
|
||||
overlay opens over a game that keeps focus, and as it closes - by then the overlay has dropped its
|
||||
STEAM_INPUT_FOCUS, so whether the opening kept focus is remembered.
|
||||
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index 372a82c..3fab3c6 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -1321,6 +1321,22 @@ window_is_steam( steamcompmgr_win_t *w )
|
||||
return w && ( w->isSteamLegacyBigPicture || w->appID == 769 );
|
||||
}
|
||||
|
||||
+// Whether keyboard focus stays on the focus window while this window has input focus.
|
||||
+// STEAM_INPUT_FOCUS 2 asks for that. The Steam client also opens its Quick Access Menu and Steam
|
||||
+// menu over a game with 1, which moves X keyboard focus to the overlay: wine then deactivates the
|
||||
+// game, a fullscreen game minimizes itself, and it froze behind the menu instead of running on as
|
||||
+// on a Steam Deck. The overlay's own input does not need X keyboard focus (the controller drives
|
||||
+// it through Steam Input), so Steam's overlay keeps the game's keyboard focus too.
|
||||
+static bool
|
||||
+window_keeps_game_keyboard_focus( steamcompmgr_win_t *w )
|
||||
+{
|
||||
+ if ( !w )
|
||||
+ return false;
|
||||
+ if ( w->inputFocusMode == 2 )
|
||||
+ return true;
|
||||
+ return w->inputFocusMode == 1 && w->isOverlay && window_is_steam( w );
|
||||
+}
|
||||
+
|
||||
static bool
|
||||
window_is_vr_scene_app( steamcompmgr_win_t *w )
|
||||
{
|
||||
@@ -4728,7 +4744,7 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win
|
||||
|
||||
if ( gamescope::VirtualConnectorIsSingleOutput() )
|
||||
{
|
||||
- if ( inputFocus && inputFocus->inputFocusMode == 2 )
|
||||
+ if ( window_keeps_game_keyboard_focus( inputFocus ) )
|
||||
keyboardFocusWin = ctx->focus.focusWindow;
|
||||
}
|
||||
else
|
||||
@@ -4834,12 +4850,26 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win
|
||||
if ( ctx->focus.inputFocusWindow != inputFocus ||
|
||||
ctx->focus.inputFocusMode != inputFocus->inputFocusMode )
|
||||
{
|
||||
+ // Steam's overlay opening over a game, or closing again, while the game keeps keyboard focus
|
||||
+ // (window_keeps_game_keyboard_focus): the game is still taking input, so a pointer warp
|
||||
+ // reaches it as a sudden mouse move - the camera of a mouse-look game spun as the Quick
|
||||
+ // Access Menu opened and as it closed. The overlay is driven by the controller and the
|
||||
+ // cursor is hidden, so leave the pointer where it is. Closing, the overlay has already
|
||||
+ // dropped its STEAM_INPUT_FOCUS, so whether the opening kept focus is remembered.
|
||||
+ static bool s_bOverlayKeptGameFocus = false;
|
||||
+ bool bOverlayKeepsGameFocus = window_keeps_game_keyboard_focus( inputFocus ) &&
|
||||
+ ctx->focus.focusWindow && !window_is_steam( ctx->focus.focusWindow );
|
||||
+ bool bSkipPointerReset = bOverlayKeepsGameFocus || s_bOverlayKeptGameFocus;
|
||||
+ s_bOverlayKeptGameFocus = bOverlayKeepsGameFocus;
|
||||
+
|
||||
// If the window doesn't want focus when hidden, move it away
|
||||
// as we are going to hide it straight after.
|
||||
// otherwise, if we switch from wanting it to not
|
||||
// (steam -> game)
|
||||
// put us back in the centre of the screen.
|
||||
- if (window_wants_no_focus_when_mouse_hidden(inputFocus))
|
||||
+ if ( bSkipPointerReset )
|
||||
+ ;
|
||||
+ else if (window_wants_no_focus_when_mouse_hidden(inputFocus))
|
||||
ctx->focus.bResetToCorner = true;
|
||||
else if ( window_wants_no_focus_when_mouse_hidden(inputFocus) != window_wants_no_focus_when_mouse_hidden(ctx->focus.inputFocusWindow) )
|
||||
ctx->focus.bResetToCenter = true;
|
||||
@@ -5340,7 +5370,7 @@ determine_and_apply_focus( global_focus_t *pFocus )
|
||||
pFocus->inputFocusMode = pFocus->inputFocusWindow->inputFocusMode;
|
||||
|
||||
if ( gamescope::VirtualConnectorIsSingleOutput() &&
|
||||
- pFocus->inputFocusMode == 2 )
|
||||
+ window_keeps_game_keyboard_focus( pFocus->inputFocusWindow ) )
|
||||
{
|
||||
pFocus->keyboardFocusWindow = pFocus->overrideWindow
|
||||
? pFocus->overrideWindow
|
||||
@@ -6485,7 +6515,7 @@ handle_wl_surface_id(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, uint32_t surfac
|
||||
steamcompmgr_win_t *keyboardFocusWindow = pCurrentFocus->inputFocusWindow;
|
||||
|
||||
if ( gamescope::VirtualConnectorIsSingleOutput() &&
|
||||
- keyboardFocusWindow && keyboardFocusWindow->inputFocusMode == 2 )
|
||||
+ window_keeps_game_keyboard_focus( keyboardFocusWindow ) )
|
||||
keyboardFocusWindow = pCurrentFocus->focusWindow;
|
||||
|
||||
if ( w == keyboardFocusWindow )
|
||||
@@ -1,2 +1,2 @@
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p4
|
||||
GAMESCOPE_SHA256=e750fb34864bc2eb1160aeed29593e402bf9c8153fafd9fd18c77450bdd50574
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p5
|
||||
GAMESCOPE_SHA256=188144e5564e020d0bc485a1f4f90e1c5fd61d0d1d6dd29db21e8b019036a5c6
|
||||
@@ -24,6 +24,12 @@ fi
|
||||
# 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
|
||||
# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and
|
||||
# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads.
|
||||
if [ -s /etc/ld.so.preload ]; then
|
||||
preload=$(tr -s '\n' ':' < /etc/ld.so.preload)
|
||||
export LD_PRELOAD="${preload%:}${LD_PRELOAD:+:$LD_PRELOAD}" SHARUN_ALLOW_LD_PRELOAD=1
|
||||
fi
|
||||
flags=()
|
||||
if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then
|
||||
flags+=(--no-sandbox)
|
||||
|
||||
@@ -1044,7 +1044,56 @@ def retroarch():
|
||||
print(f"bannerlator-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})")
|
||||
|
||||
|
||||
for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch):
|
||||
# yuzu and its forks: Player 1 in [Controls] of <name>/qt-config.ini, which each writes at its first
|
||||
# start with the keyboard bound. Bindings are input_common ParamPackages, laid out by position as the
|
||||
# frontend's own auto-map does (sdl_driver.cpp GetDefaultButtonBinding); the guid is SDL's with the
|
||||
# name CRC cleared (GetGUID). A key is only read while its \default is false. Done once per emulator,
|
||||
# and only while Player 1 is still on the keyboard.
|
||||
SWITCH_PAD = "engine:sdl,port:0,guid:030000005e0400008e02000010010000,"
|
||||
SWITCH_PLAYER1 = {
|
||||
"button_a": "button:1", "button_b": "button:0", "button_x": "button:3", "button_y": "button:2",
|
||||
"button_lstick": "button:9", "button_rstick": "button:10", "button_l": "button:4", "button_r": "button:5",
|
||||
"button_zl": "axis:2,threshold:0.5,invert:+", "button_zr": "axis:5,threshold:0.5,invert:+",
|
||||
"button_plus": "button:7", "button_minus": "button:6", "button_home": "button:8",
|
||||
"button_dleft": "hat:0,direction:left", "button_dup": "hat:0,direction:up",
|
||||
"button_dright": "hat:0,direction:right", "button_ddown": "hat:0,direction:down",
|
||||
"lstick": "axis_x:0,axis_y:1,offset_x:0,offset_y:0,invert_x:+,invert_y:+",
|
||||
"rstick": "axis_x:3,axis_y:4,offset_x:0,offset_y:0,invert_x:+,invert_y:+",
|
||||
}
|
||||
|
||||
|
||||
def switch_emulators():
|
||||
for name in sorted(os.listdir(CONFIG)) if os.path.isdir(CONFIG) else ():
|
||||
cfg = os.path.join(CONFIG, name, "qt-config.ini")
|
||||
marker = os.path.join(CONFIG, name, ".droiddeck-pad")
|
||||
if os.path.exists(marker) or not os.path.isfile(cfg):
|
||||
continue
|
||||
with open(cfg, encoding="utf-8", errors="replace") as f:
|
||||
lines = f.read().splitlines()
|
||||
span = ini_section(lines, "Controls")
|
||||
if not span:
|
||||
continue
|
||||
current = {l.split("=", 1)[0]: l.split("=", 1)[1] for l in lines[span[0]:span[1]] if "=" in l}
|
||||
if "engine:keyboard" not in current.get("player_0_button_a", ""):
|
||||
continue
|
||||
wanted = {"player_0_connected": "true", "player_0_type": "0"}
|
||||
for key, binding in SWITCH_PLAYER1.items():
|
||||
wanted[f"player_0_{key}"] = f'"{SWITCH_PAD}{binding}"'
|
||||
body = [l for l in lines[span[0]:span[1]]
|
||||
if l.split("=", 1)[0].removesuffix("\\default") not in wanted]
|
||||
while body and not body[-1].strip():
|
||||
body.pop()
|
||||
for key, value in wanted.items():
|
||||
body += [f"{key}\\default=false", f"{key}={value}"]
|
||||
lines[span[0]:span[1]] = body + [""]
|
||||
with open(cfg + ".tmp", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
os.replace(cfg + ".tmp", cfg)
|
||||
open(marker, "w").close()
|
||||
print(f"bannerlator-pad-defaults: {name}: Player 1 on the controller ({cfg})")
|
||||
|
||||
|
||||
for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch, switch_emulators):
|
||||
try:
|
||||
step()
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# A script added on the Desktop page (UserApps), run from its own folder:
|
||||
# bannerlator-script-run /opt/droiddeck-apps/<id> [args...]
|
||||
set -u
|
||||
dir=${1:?usage: bannerlator-script-run <added app directory> [args...]}
|
||||
shift
|
||||
entry=$(cat "$dir/entry" 2>/dev/null) || { echo "bannerlator-script-run: $dir has no entry" >&2; exit 127; }
|
||||
cd -P "$dir/files" 2>/dev/null || { echo "bannerlator-script-run: $dir/files is missing; add the script again" >&2; exit 127; }
|
||||
folder=$PWD
|
||||
# Shared storage is noexec, so the script is handed to its interpreter rather than executed.
|
||||
first=
|
||||
IFS= read -r first < "$entry"
|
||||
first=${first%$'\r'}
|
||||
interp=(/bin/bash)
|
||||
case $first in '#!'*) read -r -a interp <<< "${first#'#!'}" ;; esac
|
||||
echo "== script $entry in $folder (${interp[*]})"
|
||||
case ${DBUS_SESSION_BUS_ADDRESS:-} in
|
||||
unix:*) "${interp[@]}" "./$entry" "$@" ;;
|
||||
*) unset DBUS_SESSION_BUS_ADDRESS
|
||||
if command -v dbus-run-session >/dev/null 2>&1; then
|
||||
dbus-run-session -- "${interp[@]}" "./$entry" "$@"
|
||||
else
|
||||
"${interp[@]}" "./$entry" "$@"
|
||||
fi ;;
|
||||
esac
|
||||
rc=$?
|
||||
# A launcher script often starts the game and returns; the session lasts while it runs.
|
||||
running() {
|
||||
local p exe
|
||||
for p in /proc/[0-9]*; do
|
||||
exe=$(readlink "$p/exe" 2>/dev/null) || continue
|
||||
case $exe in "$folder"/*) return 0 ;; esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
while running; do sleep 2; done
|
||||
exit $rc
|
||||
@@ -172,6 +172,8 @@ if [ -z "${BL_INSIDE:-}" ]; then
|
||||
# HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
|
||||
# gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
|
||||
[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
|
||||
# Extra gamescope arguments for an experiment (--debug-focus, say), from Download/droiddeck-env.
|
||||
[ -n "${BL_GAMESCOPE_ARGS:-}" ] && read -r -a _bl_extra <<<"$BL_GAMESCOPE_ARGS" && args+=("${_bl_extra[@]}")
|
||||
# Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by
|
||||
# mangoapp, which gamescope starts beside the client; the client sets the level by rewriting
|
||||
# MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
|
||||
|
||||
@@ -254,7 +254,8 @@ fi
|
||||
depot=
|
||||
best=0
|
||||
for name in %s; do
|
||||
candidate="$steam_root/steamapps/common/$name"
|
||||
for library in "$steam_root" /mnt/droiddeck-sd; do
|
||||
candidate="$library/steamapps/common/$name"
|
||||
[ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue
|
||||
build=0
|
||||
read -r build _ 2>/dev/null < "$candidate/version"
|
||||
@@ -263,6 +264,7 @@ for name in %s; do
|
||||
depot=$candidate
|
||||
best=$build
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ -n "$depot" ]; then
|
||||
bl_components "$depot"
|
||||
@@ -595,10 +597,10 @@ def depot_build(depot):
|
||||
|
||||
def find_source(steam_root):
|
||||
"""The newest ARM64 Proton depot the client has downloaded (the one the launcher runs), or None."""
|
||||
common = os.path.join(steam_root, "steamapps", "common")
|
||||
# bin-arm64 is what makes it the native build rather than the x86_64 one.
|
||||
found = [os.path.join(common, name) for name in SOURCES
|
||||
if os.path.isdir(os.path.join(common, name, "files", "bin-arm64"))]
|
||||
found = [os.path.join(root, "steamapps", "common", name)
|
||||
for root in (steam_root, "/mnt/droiddeck-sd") for name in SOURCES
|
||||
if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))]
|
||||
return max(found, key=depot_build) if found else None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// libtracefs.so.1 for mangoapp: the GPU memory Valve's overlay reads, without the kernel's tracing.
|
||||
//
|
||||
// Valve's mangoapp (msm.cpp, ftrace_gpumem.c) takes the GPU's memory in use - the VRAM and PVRAM
|
||||
// lines - from the gpu_mem_total tracepoint (pid 0, the device's total) through libtracefs. An
|
||||
// Android app may not use tracefs at all: the instance could not be made, and both lines read 0.
|
||||
// This stands in for the libtracefs calls mangoapp makes, and hands its record walker one
|
||||
// gpu_mem_total record carrying what the app writes to /run/droiddeck-hud/gpu-mem (bytes; the
|
||||
// session's GpuMemComponent, from KGSL's own accounting). Only mangoapp loads this copy.
|
||||
//
|
||||
// The walker reads libtraceevent's structures directly, so the ones below are libtraceevent
|
||||
// 1.8.2's (event-parse.h), the version tools/mangoapp/packages.txt pins; tep_find_event_by_record
|
||||
// and tep_read_number are answered here too, ahead of the real libtraceevent in mangoapp's lookup.
|
||||
|
||||
#include <sched.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
|
||||
struct tep_handle;
|
||||
struct tep_event;
|
||||
struct tep_print_arg;
|
||||
struct tep_print_parse;
|
||||
|
||||
struct tep_format_field {
|
||||
struct tep_format_field *next;
|
||||
struct tep_event *event;
|
||||
char *type;
|
||||
char *name;
|
||||
char *alias;
|
||||
int offset;
|
||||
int size;
|
||||
unsigned int arraylen;
|
||||
unsigned int elementsize;
|
||||
unsigned long flags;
|
||||
};
|
||||
|
||||
struct tep_format {
|
||||
int nr_common;
|
||||
int nr_fields;
|
||||
struct tep_format_field *common_fields;
|
||||
struct tep_format_field *fields;
|
||||
};
|
||||
|
||||
struct tep_print_fmt {
|
||||
char *format;
|
||||
struct tep_print_arg *args;
|
||||
struct tep_print_parse *print_cache;
|
||||
};
|
||||
|
||||
struct tep_event {
|
||||
struct tep_handle *tep;
|
||||
char *name;
|
||||
int id;
|
||||
int flags;
|
||||
struct tep_format format;
|
||||
struct tep_print_fmt print_fmt;
|
||||
char *system;
|
||||
void *handler;
|
||||
void *context;
|
||||
};
|
||||
|
||||
struct tep_record {
|
||||
unsigned long long ts;
|
||||
unsigned long long offset;
|
||||
long long missed_events;
|
||||
int record_size;
|
||||
int size;
|
||||
void *data;
|
||||
int cpu;
|
||||
int ref_count;
|
||||
int locked;
|
||||
void *priv;
|
||||
};
|
||||
|
||||
// gpu_mem_total's record: gpu_id, pid, size - as the kernel lays it out after the common fields.
|
||||
struct gpu_mem_total {
|
||||
unsigned int gpu_id;
|
||||
unsigned int pid;
|
||||
unsigned long long size;
|
||||
};
|
||||
|
||||
static struct tep_format_field size_field = {
|
||||
.name = "size", .offset = offsetof(struct gpu_mem_total, size), .size = 8};
|
||||
static struct tep_format_field pid_field = {
|
||||
.next = &size_field, .name = "pid", .offset = offsetof(struct gpu_mem_total, pid), .size = 4};
|
||||
static struct tep_event gpu_mem_event = {
|
||||
.name = "gpu_mem_total", .system = "gpu_mem", .format = {.nr_fields = 2, .fields = &pid_field}};
|
||||
|
||||
// Never dereferenced by mangoapp: they only have to be non-null.
|
||||
static char handle;
|
||||
static char instance;
|
||||
|
||||
static unsigned long long gpu_mem_bytes(void) {
|
||||
unsigned long long bytes = 0;
|
||||
FILE *f = fopen("/run/droiddeck-hud/gpu-mem", "r");
|
||||
if (f) {
|
||||
if (fscanf(f, "%llu", &bytes) != 1) bytes = 0;
|
||||
fclose(f);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
EXPORT void *tracefs_instance_create(const char *name) { (void)name; return &instance; }
|
||||
EXPORT char *tracefs_instance_get_dir(void *inst) { (void)inst; return strdup("/run/droiddeck-hud"); }
|
||||
EXPORT void tracefs_put_tracing_file(char *name) { free(name); }
|
||||
EXPORT struct tep_handle *tracefs_local_events(const char *dir) { (void)dir; return (struct tep_handle *)&handle; }
|
||||
EXPORT int tracefs_event_enable(void *inst, const char *system, const char *event) { (void)inst; (void)system; (void)event; return 0; }
|
||||
EXPORT int tracefs_event_disable(void *inst, const char *system, const char *event) { (void)inst; (void)system; (void)event; return 0; }
|
||||
EXPORT bool tracefs_instance_is_new(void *inst) { (void)inst; return false; }
|
||||
EXPORT int tracefs_instance_destroy(void *inst) { (void)inst; return 0; }
|
||||
EXPORT void tracefs_instance_free(void *inst) { (void)inst; }
|
||||
|
||||
EXPORT int tracefs_iterate_raw_events(struct tep_handle *tep, void *inst, cpu_set_t *cpus, int cpu_size,
|
||||
int (*callback)(struct tep_event *, struct tep_record *, int, void *),
|
||||
void *context) {
|
||||
(void)tep; (void)inst; (void)cpus; (void)cpu_size;
|
||||
if (!callback) return -1;
|
||||
struct gpu_mem_total data = {.gpu_id = 0, .pid = 0, .size = gpu_mem_bytes()};
|
||||
struct tep_record record = {.size = sizeof(data), .record_size = sizeof(data), .data = &data};
|
||||
callback(&gpu_mem_event, &record, 0, context);
|
||||
return 0;
|
||||
}
|
||||
|
||||
EXPORT struct tep_event *tep_find_event_by_record(struct tep_handle *tep, struct tep_record *record) {
|
||||
(void)tep; (void)record;
|
||||
return &gpu_mem_event;
|
||||
}
|
||||
|
||||
EXPORT unsigned long long tep_read_number(struct tep_handle *tep, const void *ptr, int size) {
|
||||
(void)tep;
|
||||
switch (size) {
|
||||
case 1: return *(const unsigned char *)ptr;
|
||||
case 2: return *(const unsigned short *)ptr;
|
||||
case 4: return *(const unsigned int *)ptr;
|
||||
case 8: return *(const unsigned long long *)ptr;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
EXPORT void tep_free(struct tep_handle *tep) { (void)tep; }
|
||||
@@ -1,4 +1,4 @@
|
||||
# Valve's aarch64 mangoapp (the Deck performance overlay) and the five libraries it needs that the
|
||||
# Valve's aarch64 mangoapp (the Deck performance overlay) and the libraries it needs that the
|
||||
# runtime does not have, at the versions it was built against (its .BUILDINFO: fmt 10.2.0,
|
||||
# spdlog 1.13.0). CI fetches each, checks the sha256 and stages the files named below into the apk
|
||||
# under usr/local/lib/mangoapp; SessionFiles copies them into the runtime at every launch.
|
||||
@@ -7,5 +7,6 @@
|
||||
ef7411229ea4e9b3ab4e36ac356a85b5072f362ee1de68b28d09c43f0b0ecc44 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/fmt-10.2.0-1-aarch64.pkg.tar.zst
|
||||
c9bfd2c3f15071329d381b9c2061d07503c4787ceb7001b9dfbf72a23c85cb04 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/spdlog-1.13.0-1-aarch64.pkg.tar.zst
|
||||
f9fae6c84a0382188f1cb20761424051593fd09b1456191cc250bd16e0a1d7a5 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/glfw-3.4-2-aarch64.pkg.tar.zst
|
||||
# libtracefs is ours (libtracefs-shim.c), and reads libtraceevent's structures as 1.8.2 lays them
|
||||
# out: moving libtraceevent off 1.8.2 means checking event-parse.h against the shim.
|
||||
0cccdc9c92cb3cae1e37ebeb0ed20b93bb3b5000c8f955b63d0e07bd878c7018 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtraceevent-1%3A1.8.2-2-aarch64.pkg.tar.zst
|
||||
47ffd6a953959d6a0df44539bef30a67d0b8c95cce0053273908d9d70ef7abc4 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtracefs-1.8.0-1-aarch64.pkg.tar.zst
|
||||
@@ -80,3 +80,15 @@ Added by DroidDeck:
|
||||
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).
|
||||
|
||||
Prototype (inert unless `PROOT_FASTPATH` is set; see `docs/development/proot-performance.md`):
|
||||
|
||||
- `0014-fastpath-trampoline.patch` - with `PROOT_FASTPATH`, every `SECCOMP_RET_TRACE` in the filter
|
||||
is preceded by a check of the caller's address, and a syscall made from the fast path's trampoline
|
||||
page (`fastpath/fastpath.c`, `0xffff00000`) runs without a stop: the tracee already translated it.
|
||||
The check comes after the syscall-number dispatch, so untraced syscalls stay constant-ALLOW and keep
|
||||
the kernel's seccomp action cache (checking first cost every syscall the full filter: +0.5 ms an
|
||||
exec). `chdir`/`fchdir`, still emulated, also move the kernel's cwd to the host directory, and the
|
||||
first tracee starts at `-w`'s, so relative lookups inside the tracee resolve where proot would.
|
||||
SM8850 (adb shell): `stat` 25.3 -> 0.6 us, `open+close` 25.3 -> 0.9 us, ENOENT 22.9 -> 1.6 us,
|
||||
8 threads `stat`ing 89k -> 3.6M/s; equivalence suite (`bench/equiv.py`) byte-identical to proot.
|
||||
@@ -0,0 +1,32 @@
|
||||
import os, sys
|
||||
paths = ["/etc/os-release", "/root/abs-link", "/root/to-bind", "/root/to-bind/inbind.txt", "/root/rel-to-bind",
|
||||
"/root/rel-to-bind/inbind.txt", "/root/dotdot", "/root/dotdot/libc.so.6", "/root/dangling", "/root/loop",
|
||||
"/usr/lib/../lib/libc.so.6", "/usr//lib/./libc.so.6", "/lib", "/lib/libc.so.6", "/bin/env", "/nope", "/nope/x",
|
||||
"/etc/os-release/x", "/mnt/droiddeck-sd", "/mnt/droiddeck-sd/inbind.txt", "/mnt/droiddeck-sd/../bannerlator",
|
||||
"/dev/null", "/dev/shm", "/dev/shm/inbind.txt", "/dev/random", "/proc/self/exe", "/proc/version", "/proc",
|
||||
"/sys/class/thermal/thermal_zone28/temp", "/sdcard", "/", "/tmp", "/root/in-root-abs-dir/os-release",
|
||||
"/data/local/tmp/dd/tmp/inbind.txt", "/data/local/tmp/dd/mini/etc/os-release", "/root/to-bind/missing/x", "/root/to-bind/inbind.txt/x", "/usr/lib/nope/deeper/x.so", "/mnt/droiddeck-sd/nope/x", "/root/dangling/x", "/root/abs-link/x", "/nope/a/b/c", "/sdcard/nope/x"]
|
||||
rels = ["os-release", "inbind.txt", "../etc/os-release", "abs-link", "to-bind/inbind.txt", "rel-to-bind",
|
||||
"shm/inbind.txt", "null", "lib/libc.so.6", "mnt/droiddeck-sd/inbind.txt", "proc/version", "nope", "."]
|
||||
def probe(p):
|
||||
out = []
|
||||
for name, fn in [("stat", lambda: (lambda s: (oct(s.st_mode), s.st_size if not os.path.isdir(p) else 0))(os.stat(p))),
|
||||
("lstat", lambda: oct(os.lstat(p).st_mode)),
|
||||
("readlink", lambda: os.readlink(p)),
|
||||
("read", lambda: open(p, "rb").read(12)),
|
||||
("list", lambda: sorted(x for x in os.listdir(p) if not x.startswith("proot-"))[:6]),
|
||||
("access", lambda: (os.access(p, os.R_OK), os.access(p, os.W_OK), os.access(p, os.X_OK)))]:
|
||||
try: out.append(f"{name}={fn()!r}")
|
||||
except OSError as e: out.append(f"{name}=E{e.errno}")
|
||||
return " ".join(out)
|
||||
for p in paths: print(p, probe(p))
|
||||
for cwd in ["/", "/root", "/etc", "/mnt/droiddeck-sd", "/dev", "/usr/lib", "/data/local/tmp/dd/tmp", "/root/to-bind", "/root/dotdot"]:
|
||||
try: os.chdir(cwd)
|
||||
except OSError as e: print("chdir", cwd, e.errno); continue
|
||||
print("== cwd", cwd, os.getcwd())
|
||||
for r in rels: print(" ", r, probe(r))
|
||||
d = os.open(".", os.O_RDONLY)
|
||||
for r in rels[:6]:
|
||||
try: print(" at", r, oct(os.stat(r, dir_fd=d).st_mode))
|
||||
except OSError as e: print(" at", r, "E", e.errno)
|
||||
os.close(d)
|
||||
@@ -0,0 +1,274 @@
|
||||
/* Cost of the syscall-interception mechanisms available to an Android app, measured on the
|
||||
* trapped call getppid() (the tracer/handler does no work, so this is pure mechanism cost).
|
||||
*
|
||||
* mechbench filter <n_linear> allowed syscall through a filter of n JEQs (linear, proot-style)
|
||||
* mechbench tree allowed syscall through a binary-search filter (~150 entries)
|
||||
* mechbench ptrace [spin] [regs] seccomp RET_TRACE + ptrace tracer (proot's mechanism)
|
||||
* mechbench sigsys seccomp RET_TRAP + in-process SIGSYS handler
|
||||
* mechbench notif [sync] [spin] seccomp RET_USER_NOTIF + supervisor thread
|
||||
* mechbench openat2 <root> <path> openat2(RESOLVE_IN_ROOT) vs openat on the host path
|
||||
* Env CPU_T / CPU_S pin the trapped thread / the tracer-supervisor to a cpu.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/openat2.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/ptrace.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/uio.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <ucontext.h>
|
||||
#include <unistd.h>
|
||||
#include <linux/elf.h>
|
||||
|
||||
#ifndef SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP
|
||||
#define SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP (1UL << 0)
|
||||
#endif
|
||||
#ifndef SECCOMP_IOCTL_NOTIF_SET_FLAGS
|
||||
#define SECCOMP_IOCTL_NOTIF_SET_FLAGS SECCOMP_IOW(4, __u64)
|
||||
#endif
|
||||
|
||||
#define TRAPPED SYS_getppid
|
||||
static int N = 100000;
|
||||
|
||||
static double now(void) {
|
||||
struct timespec t;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
return t.tv_sec * 1e9 + t.tv_nsec;
|
||||
}
|
||||
|
||||
static void pin(const char *env) {
|
||||
const char *v = getenv(env);
|
||||
if (!v) return;
|
||||
cpu_set_t s;
|
||||
CPU_ZERO(&s);
|
||||
CPU_SET(atoi(v), &s);
|
||||
sched_setaffinity(0, sizeof s, &s);
|
||||
}
|
||||
|
||||
static double time_trapped(void) {
|
||||
for (int i = 0; i < N / 10; i++) syscall(TRAPPED);
|
||||
double t0 = now();
|
||||
for (int i = 0; i < N; i++) syscall(TRAPPED);
|
||||
return (now() - t0) / N;
|
||||
}
|
||||
|
||||
/* A filter: arch check, then `n` JEQs on numbers that never match (like proot's list of ~150
|
||||
* traced syscalls seen by an allowed syscall), then `action` for TRAPPED, else ALLOW. */
|
||||
static int install(int n_linear, uint32_t action, unsigned flags) {
|
||||
struct sock_filter *f = calloc(n_linear + 16, sizeof *f);
|
||||
int k = 0;
|
||||
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch));
|
||||
f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_AARCH64, 1, 0);
|
||||
f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
|
||||
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr));
|
||||
for (int i = 0; i < n_linear; i++) /* numbers far above any real syscall */
|
||||
f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 5000 + i, 0, 0);
|
||||
f[k++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, TRAPPED, 0, 1);
|
||||
f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, action);
|
||||
f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
|
||||
struct sock_fprog p = {.len = k, .filter = f};
|
||||
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
|
||||
return syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, flags, &p);
|
||||
}
|
||||
|
||||
/* ~150 traced numbers as a balanced binary search (what libseccomp's optimize=2 emits). */
|
||||
static int emit_tree(struct sock_filter *f, int *k, int lo, int hi) {
|
||||
if (lo > hi) { f[(*k)++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); return 0; }
|
||||
int mid = (lo + hi) / 2;
|
||||
f[(*k)++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 5000 + mid, 0, 1);
|
||||
f[(*k)++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE);
|
||||
f[(*k)++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JGT | BPF_K, 5000 + mid, 0, 1);
|
||||
int ja = (*k)++;
|
||||
int left = *k;
|
||||
emit_tree(f, k, lo, mid - 1);
|
||||
f[ja] = (struct sock_filter)BPF_STMT(BPF_JMP | BPF_JA, *k - left);
|
||||
emit_tree(f, k, mid + 1, hi);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *sigsys_unused;
|
||||
static void on_sigsys(int sig, siginfo_t *si, void *ctx) {
|
||||
(void)sig; (void)si;
|
||||
ucontext_t *uc = ctx;
|
||||
uc->uc_mcontext.regs[0] = 4242; /* the emulated result */
|
||||
}
|
||||
|
||||
struct notif_arg { int fd; int spin; };
|
||||
static void *supervisor(void *a) {
|
||||
struct notif_arg *na = a;
|
||||
pin("CPU_S");
|
||||
struct seccomp_notif *req = calloc(1, 256);
|
||||
struct seccomp_notif_resp *resp = calloc(1, 256);
|
||||
for (;;) {
|
||||
memset(req, 0, sizeof *req);
|
||||
if (ioctl(na->fd, SECCOMP_IOCTL_NOTIF_RECV, req) < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return NULL;
|
||||
}
|
||||
resp->id = req->id;
|
||||
resp->val = 4242;
|
||||
resp->error = 0;
|
||||
resp->flags = 0;
|
||||
ioctl(na->fd, SECCOMP_IOCTL_NOTIF_SEND, resp);
|
||||
}
|
||||
}
|
||||
|
||||
static int notif_fd;
|
||||
static int notif_flags_sync;
|
||||
static void *notified_worker(void *a) {
|
||||
(void)a;
|
||||
pin("CPU_T");
|
||||
notif_fd = install(0, SECCOMP_RET_USER_NOTIF, SECCOMP_FILTER_FLAG_NEW_LISTENER);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (getenv("N")) N = atoi(getenv("N"));
|
||||
const char *mode = argc > 1 ? argv[1] : "filter";
|
||||
if (!strcmp(mode, "filter")) {
|
||||
pin("CPU_T");
|
||||
printf("no filter getppid %6.0f ns\n", time_trapped());
|
||||
int n = argc > 2 ? atoi(argv[2]) : 150;
|
||||
if (install(n, SECCOMP_RET_ALLOW, 0) < 0) { perror("seccomp"); return 1; }
|
||||
printf("linear %4d JEQs getppid %6.0f ns\n", n, time_trapped());
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(mode, "tree")) {
|
||||
pin("CPU_T");
|
||||
printf("no filter getppid %6.0f ns\n", time_trapped());
|
||||
struct sock_filter f[2048];
|
||||
int k = 0;
|
||||
f[k++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr));
|
||||
emit_tree(f, &k, 0, 149);
|
||||
struct sock_fprog p = {.len = k, .filter = f};
|
||||
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
|
||||
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &p) < 0) { perror("seccomp"); return 1; }
|
||||
printf("tree of 150 (%d insns) getppid %6.0f ns\n", k, time_trapped());
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(mode, "sigsys")) {
|
||||
pin("CPU_T");
|
||||
struct sigaction sa = {0};
|
||||
sa.sa_sigaction = on_sigsys;
|
||||
sa.sa_flags = SA_SIGINFO;
|
||||
sigaction(SIGSYS, &sa, NULL);
|
||||
if (install(0, SECCOMP_RET_TRAP, 0) < 0) { perror("seccomp"); return 1; }
|
||||
long r = syscall(TRAPPED);
|
||||
printf("sigsys trap (result %ld) %6.0f ns\n", r, time_trapped());
|
||||
(void)sigsys_unused;
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(mode, "notif")) {
|
||||
int sync = argc > 2 && strstr(argv[2], "sync");
|
||||
pthread_t w, s;
|
||||
pthread_create(&w, NULL, notified_worker, NULL);
|
||||
pthread_join(w, NULL);
|
||||
if (notif_fd < 0) { perror("NEW_LISTENER"); return 1; }
|
||||
if (sync) {
|
||||
if (ioctl(notif_fd, SECCOMP_IOCTL_NOTIF_SET_FLAGS, SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP) < 0) perror("SYNC_WAKE_UP");
|
||||
else printf("(sync wake-up on)\n");
|
||||
}
|
||||
/* The filter lives on thread w, which has exited; install again on a fresh worker that
|
||||
* does the timing. */
|
||||
struct notif_arg na = {.fd = -1};
|
||||
(void)na;
|
||||
printf("note: user-notif is measured in 'notif2'\n");
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(mode, "notif2")) {
|
||||
/* Supervisor = child thread (unfiltered), timed thread = main after installing. */
|
||||
int sync = argc > 2 && !strcmp(argv[2], "sync");
|
||||
pin("CPU_T");
|
||||
int fd = install(0, SECCOMP_RET_USER_NOTIF, SECCOMP_FILTER_FLAG_NEW_LISTENER);
|
||||
if (fd < 0) { perror("NEW_LISTENER"); return 1; }
|
||||
if (sync && ioctl(fd, SECCOMP_IOCTL_NOTIF_SET_FLAGS, SECCOMP_USER_NOTIF_FD_SYNC_WAKE_UP, 0) < 0) perror("SYNC_WAKE_UP");
|
||||
/* The supervisor thread is created AFTER the filter, so it inherits it - but it only
|
||||
* makes ioctl calls, which the filter allows. */
|
||||
static struct notif_arg na;
|
||||
na.fd = fd;
|
||||
pthread_t s;
|
||||
pthread_create(&s, NULL, supervisor, &na);
|
||||
long r = syscall(TRAPPED);
|
||||
printf("user_notif%s (result %ld) %6.0f ns\n", sync ? "+sync" : "", r, time_trapped());
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(mode, "ptrace")) {
|
||||
int spin = 0, regs = 0;
|
||||
for (int i = 2; i < argc; i++) { if (!strcmp(argv[i], "spin")) spin = 1; if (!strcmp(argv[i], "regs")) regs = 1; }
|
||||
pid_t c = fork();
|
||||
if (c == 0) {
|
||||
pin("CPU_T");
|
||||
ptrace(PTRACE_TRACEME, 0, 0, 0);
|
||||
raise(SIGSTOP);
|
||||
if (install(0, SECCOMP_RET_TRACE, 0) < 0) { perror("seccomp"); _exit(1); }
|
||||
printf("ptrace+seccomp%s%s %6.0f ns\n", spin ? " spin" : "", regs ? " +getregs+setregs" : "", time_trapped());
|
||||
fflush(stdout);
|
||||
_exit(0);
|
||||
}
|
||||
pin("CPU_S");
|
||||
int st;
|
||||
waitpid(c, &st, 0);
|
||||
ptrace(PTRACE_SETOPTIONS, c, 0, PTRACE_O_TRACESECCOMP | PTRACE_O_EXITKILL);
|
||||
ptrace(PTRACE_CONT, c, 0, 0);
|
||||
for (;;) {
|
||||
pid_t p;
|
||||
if (spin) { do p = waitpid(c, &st, WNOHANG | __WALL); while (p == 0); }
|
||||
else p = waitpid(c, &st, __WALL);
|
||||
if (p < 0 || WIFEXITED(st) || WIFSIGNALED(st)) break;
|
||||
int sig = 0;
|
||||
if ((st >> 8) == (SIGTRAP | (PTRACE_EVENT_SECCOMP << 8))) {
|
||||
if (regs) {
|
||||
struct user_regs { uint64_t r[31], sp, pc, pstate; } ur;
|
||||
struct iovec io = {&ur, sizeof ur};
|
||||
ptrace(PTRACE_GETREGSET, c, NT_PRSTATUS, &io);
|
||||
ptrace(PTRACE_SETREGSET, c, NT_PRSTATUS, &io);
|
||||
}
|
||||
} else if (WIFSTOPPED(st) && WSTOPSIG(st) != SIGTRAP) sig = WSTOPSIG(st);
|
||||
ptrace(PTRACE_CONT, c, 0, sig);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(mode, "openat2")) {
|
||||
const char *root = argv[2], *path = argv[3];
|
||||
char host[4096];
|
||||
snprintf(host, sizeof host, "%s%s", root, path);
|
||||
int rfd = open(root, O_PATH | O_DIRECTORY);
|
||||
struct open_how how = {.flags = O_RDONLY | O_CLOEXEC, .resolve = RESOLVE_IN_ROOT};
|
||||
long fd = syscall(SYS_openat2, rfd, path, &how, sizeof how);
|
||||
if (fd < 0) { perror("openat2 RESOLVE_IN_ROOT"); return 1; }
|
||||
close(fd);
|
||||
double t0 = now();
|
||||
for (int i = 0; i < N; i++) close(open(host, O_RDONLY | O_CLOEXEC));
|
||||
printf("openat host path %6.0f ns\n", (now() - t0) / N);
|
||||
t0 = now();
|
||||
for (int i = 0; i < N; i++) close(syscall(SYS_openat2, rfd, path, &how, sizeof how));
|
||||
printf("openat2 RESOLVE_IN_ROOT %6.0f ns\n", (now() - t0) / N);
|
||||
struct stat st;
|
||||
t0 = now();
|
||||
for (int i = 0; i < N; i++) stat(host, &st);
|
||||
printf("stat host path %6.0f ns\n", (now() - t0) / N);
|
||||
struct open_how ph = {.flags = O_PATH | O_CLOEXEC, .resolve = RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS};
|
||||
t0 = now();
|
||||
for (int i = 0; i < N; i++) { int f = syscall(SYS_openat2, rfd, path, &ph, sizeof ph); fstat(f, &st); close(f); }
|
||||
printf("openat2(O_PATH)+fstat+close %6.0f ns\n", (now() - t0) / N);
|
||||
return 0;
|
||||
}
|
||||
fprintf(stderr, "unknown mode\n");
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* T threads each stat()ing a path for D seconds: aggregate trapped-syscall throughput. */
|
||||
#define _GNU_SOURCE
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
static const char *path; static volatile int stop; static long counts[64];
|
||||
static void *run(void *a) { long i = (long)a, n = 0; struct stat st; while (!stop) { stat(path, &st); n++; } counts[i] = n; return 0; }
|
||||
int main(int c, char **v) {
|
||||
path = v[1]; int T = atoi(v[2]); double D = c > 3 ? atof(v[3]) : 2;
|
||||
pthread_t t[64]; for (long i = 0; i < T; i++) pthread_create(&t[i], 0, run, (void *)i);
|
||||
struct timespec ts = {(time_t)D, (long)((D - (long)D) * 1e9)}; nanosleep(&ts, 0); stop = 1;
|
||||
long tot = 0; for (int i = 0; i < T; i++) { pthread_join(t[i], 0); tot += counts[i]; }
|
||||
printf("threads=%d %8.0f stat/s total %6.1f us/stat per thread\n", T, tot / D, T * D * 1e6 / tot);
|
||||
return 0; }
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/system/bin/sh
|
||||
# Runs a guest command on an adb-connected device the way a DroidDeck session runs proot (same
|
||||
# options, a similar number of binds), as the shell user from /data/local/tmp/dd. Layout there:
|
||||
# mini/ a glibc rootfs subset (glibc, bash, coreutils, python) + the bench binaries
|
||||
# proot, loader this repo's proot (tools/proot/build.sh) -> variant "patched"
|
||||
# proot-vanilla termux proot at source.env with no patches -> variant "vanilla"
|
||||
# proot-fp this repo's proot incl. 0014 -> "fastpath" / "fpoff"
|
||||
# mini/usr/lib/libfastpath.so tools/proot/fastpath/fastpath.c
|
||||
# NOTE: adb shell is NOT under the app seccomp policy; see docs/development/proot-performance.md.
|
||||
#
|
||||
# run-device.sh <patched|vanilla|noseccomp|bare|fastpath|fpoff|direct> cmd...
|
||||
D=/data/local/tmp/dd
|
||||
V=$1; shift
|
||||
U=$(id -u)
|
||||
REL="\\Linux\\DroidDeck\\$(uname -r)\\$(uname -v)\\aarch64\\localdomain\\-1\\"
|
||||
BINDS="-b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -b /proc/self/fd:/dev/fd -b /proc/self/fd/0:/dev/stdin -b /proc/self/fd/1:/dev/stdout -b /proc/self/fd/2:/dev/stderr -b $D/tmp:/dev/shm -b $D -b /sdcard -b /data/local/tmp/dd/mini/etc/os-release:/proc/version -b $D/mini/etc/os-release:/proc/loadavg -b $D/mini/etc/os-release:/proc/uptime -b /sys/class/thermal/thermal_zone0/temp:/sys/class/thermal/thermal_zone28/temp -b $D/tmp:/mnt/droiddeck-sd -b $D/tmp:/mnt/bannerlator"
|
||||
export PROOT_TMP_DIR=$D/tmp
|
||||
case $V in
|
||||
patched) export PROOT_LOADER=$D/loader; exec $D/proot --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;;
|
||||
noseccomp) export PROOT_LOADER=$D/loader PROOT_NO_SECCOMP=1; exec $D/proot --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;;
|
||||
vanilla) export PROOT_LOADER=$D/loader-vanilla; exec $D/proot-vanilla --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;;
|
||||
bare) export PROOT_LOADER=$D/loader; exec $D/proot --kill-on-exit -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;;
|
||||
fastpath) export PROOT_LOADER=$D/loader-fp PROOT_FASTPATH=bench; FPB=$(echo "$BINDS" | sed 's/-b //g; s/^ *//; s/ *$//; s/ */|/g'); exec $D/proot-fp --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 PROOT_FP_ROOT=$D/mini "PROOT_FP_BINDS=$FPB" PROOT_FP_KEY=bench LD_PRELOAD=/usr/lib/libfastpath.so $FPEXTRA "$@" ;;
|
||||
fpoff) export PROOT_LOADER=$D/loader-fp; exec $D/proot-fp --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;;
|
||||
direct) cd $D/mini/root; exec $D/mini/usr/lib/ld-linux-aarch64.so.1 --library-path $D/mini/usr/lib "$@" ;;
|
||||
esac
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Per-syscall latency under whatever runs this program (native, proot, ...).
|
||||
* Usage: sysbench <deep-file> [iterations]
|
||||
* Prints "name ns/op" lines. */
|
||||
#define _GNU_SOURCE
|
||||
#include <fcntl.h>
|
||||
#include <linux/futex.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static double now(void) {
|
||||
struct timespec t;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t);
|
||||
return t.tv_sec * 1e9 + t.tv_nsec;
|
||||
}
|
||||
|
||||
#define BENCH(name, n, body) \
|
||||
do { \
|
||||
for (int _w = 0; _w < (n) / 10 + 1; _w++) { body; } \
|
||||
double _t0 = now(); \
|
||||
for (int _i = 0; _i < (n); _i++) { body; } \
|
||||
printf("%-28s %10.0f ns\n", name, (now() - _t0) / (n)); \
|
||||
fflush(stdout); \
|
||||
} while (0)
|
||||
|
||||
static void *thread_fn(void *a) { return a; }
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *deep = argc > 1 ? argv[1] : "/usr/lib/python3.14/os.py";
|
||||
int n = argc > 2 ? atoi(argv[2]) : 20000;
|
||||
struct stat st;
|
||||
char buf[4096];
|
||||
int pfd[2];
|
||||
pipe(pfd);
|
||||
int fd = open(deep, O_RDONLY);
|
||||
if (fd < 0) { perror(deep); return 1; }
|
||||
int futexword = 0;
|
||||
char *slash = strrchr(deep, '/');
|
||||
char dir[1024];
|
||||
snprintf(dir, sizeof dir, "%.*s", (int)(slash - deep), deep);
|
||||
int dfd = open(dir, O_RDONLY | O_DIRECTORY);
|
||||
|
||||
BENCH("getppid (untraced)", n, syscall(SYS_getppid));
|
||||
BENCH("futex wake (untraced)", n, syscall(SYS_futex, &futexword, FUTEX_WAKE_PRIVATE, 1, 0, 0, 0));
|
||||
BENCH("clock_gettime (vdso)", n, { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); });
|
||||
BENCH("fstat fd (untraced)", n, fstat(fd, &st));
|
||||
BENCH("pread 4k (untraced)", n, pread(fd, buf, sizeof buf, 0));
|
||||
BENCH("ioctl FIONREAD (untraced*)", n, { int v; ioctl(pfd[0], FIONREAD, &v); });
|
||||
BENCH("fstatat relative dirfd", n, fstatat(dfd, slash + 1, &st, 0));
|
||||
BENCH("stat deep abs path", n, stat(deep, &st));
|
||||
BENCH("lstat deep abs path", n, lstat(deep, &st));
|
||||
BENCH("statx deep abs path", n, { struct statx sx; statx(AT_FDCWD, deep, 0, STATX_BASIC_STATS, &sx); });
|
||||
BENCH("access deep abs path", n, access(deep, R_OK));
|
||||
BENCH("open+close deep abs", n, close(open(deep, O_RDONLY)));
|
||||
BENCH("stat ENOENT", n, stat("/usr/lib/does/not/exist.so", &st));
|
||||
BENCH("readlink /proc/self/exe", n, readlink("/proc/self/exe", buf, sizeof buf));
|
||||
BENCH("getcwd", n, getcwd(buf, sizeof buf));
|
||||
BENCH("uname", n, { struct utsname_dummy { char b[65 * 6]; } u; syscall(SYS_uname, &u); });
|
||||
BENCH("memfd_create+close", n / 4, close(memfd_create("x", MFD_CLOEXEC)));
|
||||
BENCH("brk(0) query", n, syscall(SYS_brk, 0));
|
||||
BENCH("mmap+munmap 64k", n, munmap(mmap(0, 65536, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0), 65536));
|
||||
BENCH("pthread create+join", n / 20, { pthread_t t; pthread_create(&t, 0, thread_fn, 0); pthread_join(t, 0); });
|
||||
BENCH("fork+_exit+wait", n / 100, { pid_t p = fork(); if (!p) _exit(0); waitpid(p, 0, 0); });
|
||||
if (argc > 3) {
|
||||
char *const av[] = {argv[3], NULL};
|
||||
BENCH("fork+execve true+wait", n / 200, { pid_t p = fork(); if (!p) { execv(argv[3], av); _exit(127); } waitpid(p, 0, 0); });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/system/bin/sh
|
||||
# workloads: direct glibc (patched PT_INTERP) vs proot variants
|
||||
D=/data/local/tmp/dd; cd $D
|
||||
t() { local s=$(date +%s%N); "$@" >/dev/null 2>&1; local e=$(date +%s%N); echo $(( (e - s) / 1000000 )); }
|
||||
base() { t ./run.sh $1 /usr/bin/true; }
|
||||
W1='import json,email.parser,http.client,asyncio,decimal,sqlite3,argparse,subprocess,logging,xml.etree.ElementTree'
|
||||
for v in direct vanilla fpoff fastpath; do
|
||||
input keyevent KEYCODE_WAKEUP
|
||||
for rep in 1 2 3; do
|
||||
if [ $v = direct ]; then
|
||||
export LD_LIBRARY_PATH=$D/mini/usr/lib PYTHONHOME=$D/mini/usr PYTHONDONTWRITEBYTECODE=1
|
||||
a=$(t $D/dbin/python3.14 -c "$W1")
|
||||
b=$(t $D/dbin/find $D/mini/usr/lib/python3.14 -type f)
|
||||
c=$(t $D/dbin/bash -c "for i in \$($D/dbin/seq 200); do $D/dbin/true; done")
|
||||
d=$(t $D/dbin/tar cf /dev/null $D/mini/usr/lib/python3.14)
|
||||
unset LD_LIBRARY_PATH PYTHONHOME
|
||||
else
|
||||
a=$(t ./run.sh $v /usr/bin/env PYTHONDONTWRITEBYTECODE=1 /usr/bin/python3.14 -c "$W1")
|
||||
b=$(t ./run.sh $v /usr/bin/find /usr/lib/python3.14 -type f)
|
||||
c=$(t ./run.sh $v /usr/bin/bash -c 'for i in $(seq 200); do /usr/bin/true; done')
|
||||
d=$(t ./run.sh $v /usr/bin/tar cf /dev/null /usr/lib/python3.14)
|
||||
fi
|
||||
[ $v = direct ] && z=0 || z=$(base $v)
|
||||
echo "$v rep$rep: proot-start=${z}ms python-imports=${a}ms find-8k-files=${b}ms 200x-exec=${c}ms tar-80MB=${d}ms"
|
||||
done
|
||||
done
|
||||