Merge main into PR #30

This commit is contained in:
xXJsonDeruloXx committed 2026-09-29 18:28:48 -04:00
commit a67c5cd3b2
162 files changed
+13185 -3775

No files matched your search

+128 -5
View File
@@ -22,11 +22,13 @@ permissions:
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Replace older builds only for the same PR. Each push/dispatch gets its own group so one main
# build cannot cancel another before it signs and publishes.
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v1
PRELOAD_CACHE_VERSION: v2
jobs:
build:
@@ -34,12 +36,18 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
outputs:
variants: ${{ steps.variants.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
- name: No private key in the tree
run: |
set -euo pipefail
@@ -160,7 +168,7 @@ jobs:
D=app/src/main/assets/linuxfs
mkdir -p "$D"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
@@ -270,6 +278,12 @@ jobs:
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
done
# The static C++ runtime stays private: every session process preloads this library,
# and an exported personality routine takes over other programs' unwinding (shadPS4
# aborted in pthread_exit).
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
fi
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt statfs statvfs; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
@@ -295,6 +309,12 @@ jobs:
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
- name: Check the native dependency closure
run: |
set -euo pipefail
@@ -418,5 +438,108 @@ jobs:
uses: ./.github/workflows/sign.yml
with:
environment: signing-main
variants: '["standard"]'
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
# Discord's builds channel linking to it. The release is made with a token of the org's
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
announce:
needs: [build, sign]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
env:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
steps:
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-signed-*
merge-multiple: true
path: signed
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Publish to DroidDeck-CI
id: publish
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
run: |
set -euo pipefail
tag=main-${GITHUB_SHA::7}
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing.
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
\`\`\`
$(cat signed/SHA256SUMS.txt)
\`\`\`"
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
# Keep the newest main builds by publication time. GitHub App releases can share createdAt.
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
while IFS= read -r old; do
# Never remove this run's release, even if the API returns an unexpected ordering.
if [ "$old" = "$tag" ]; then
continue
fi
# Another main publish may have removed the same old release concurrently.
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
echo "::error::Could not delete old release $old"
exit 1
fi
fi
done
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
echo "release=$url" >> "$GITHUB_OUTPUT"
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
echo "Published $url"
- name: Post to Discord
env:
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
RELEASE: ${{ steps.publish.outputs.release }}
APK: ${{ steps.publish.outputs.apk }}
run: |
set -euo pipefail
if [ -z "$WEBHOOK" ]; then
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
exit 0
fi
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
# A merge commit's subject is "Merge pull request #N from ..."; its body's first line is
# the PR's title, which says more.
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
if [ -n "$pr" ]; then
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
else
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
fi
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
+13 -8
View File
@@ -79,8 +79,9 @@ jobs:
}
stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk")
candidate=$(package_line "${apks[0]}")
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" == "$stable" ]] || {
echo "::error::PR package/versionCode '$candidate' differs from stable '$stable'"; exit 1;
[[ "$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;
}
current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
@@ -119,18 +120,22 @@ jobs:
with:
name: droiddeck-pr-signed-standard
path: signed
- name: Publish to DroidDeck-CI when configured
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Publish to DroidDeck-CI
env:
GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }}
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ inputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
LABEL: ${{ needs.authorize.outputs.label }}
run: |
set -euo pipefail
if [[ -z "$GH_TOKEN" ]]; then
echo '::warning::No CI token; download the signed APK from this run'
exit 0
fi
repo=Droid-Deck/DroidDeck-CI
tag="pr-$PR_NUMBER"
apk="DroidDeck-$LABEL.apk"
+4
View File
@@ -35,6 +35,10 @@ jobs:
run: |
set -euo pipefail
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; }
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
echo "::error::version '$VERSION' does not match versionName '$name' in app/build.gradle: bump versionName and versionCode first"; exit 1;
}
runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \
--status success --json databaseId,event,headSha -L 20)
id=$(jq -r --arg sha "$GITHUB_SHA" \
+4
View File
@@ -28,3 +28,7 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
!keystore/testkey.p12
droiddeck-release-key/
password.txt
__pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
+9 -2
View File
@@ -1,6 +1,13 @@
# DroidDeck
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="artwork/droiddeck-banner-dark.svg">
<img alt="DroidDeck" src="artwork/droiddeck-banner-light.svg" width="100%">
</picture>
</p>
Run Valve's native ARM64 Steam client on an Adreno Android device. Steam runs in a Linux runtime under proot, with gamescope and an in-app Vulkan compositor. Windows games use Valve's ARM64 Proton and FEX. A desktop with LXQt, Firefox, and emulators is also available.
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
## Requirements and install
+11 -3
View File
@@ -63,8 +63,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 8
versionName "0.1.7"
versionCode 9
versionName "0.2.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
ndk {
@@ -140,6 +140,11 @@ android {
buildConfig true
}
testOptions {
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
unitTests.returnDefaultValues = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -152,7 +157,7 @@ android {
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.20'
implementation 'org.apache.commons:commons-compress:1.27.1'
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
@@ -169,4 +174,7 @@ dependencies {
implementation 'com.flyfishxu:kadb-android:1.2.1'
// Android terminal view/emulator used by the in-session secondary-display Linux shell.
implementation 'com.termux.termux-app:terminal-view:0.118.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
}
+9 -1
View File
@@ -48,7 +48,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorLandscape">
android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -143,6 +144,13 @@
android:process=":externalDisplay"
android:exported="false"
android:stopWithTask="false" />
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -13,11 +13,13 @@ add_library(bannerwayland SHARED
src/ahb_swapchain.c
src/waylandcomp_jni.c
src/banner_ext.c
src/wl_dmabuf.c
src/wl_clipboard.c
src/wl_text_input.c
src/wl_toplevel_icon.c
src/wl_color_mgmt.c
src/hdr_compose.c
src/blend_pass.c
generated/color-management-v1-protocol.c
generated/xdg-shell-protocol.c
generated/linux-dmabuf-v1-protocol.c
@@ -34,6 +36,9 @@ add_library(bannerwayland SHARED
src/framegen_bridge.c
src/framegen_jni.c
)
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
target_compile_options(bannerwayland PRIVATE
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
target_include_directories(bannerwayland PRIVATE
generated
prebuilt/include
@@ -0,0 +1,9 @@
#version 450
// Wayland buffers carry premultiplied alpha; the pipeline blends ONE, ONE_MINUS_SRC_ALPHA.
layout(binding = 0) uniform sampler2D Surface;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
void main() {
outColor = texture(Surface, uv);
}
@@ -0,0 +1,10 @@
#version 450
// One surface as a quad: dst = the target rectangle in NDC, src = the buffer rectangle in UV.
layout(push_constant) uniform PC { vec4 dst; vec4 src; } pc;
layout(location = 0) out vec2 uv;
void main() {
vec2 t = vec2(gl_VertexIndex & 1, gl_VertexIndex >> 1);
gl_Position = vec4(mix(pc.dst.xy, pc.dst.zw, t), 0.0, 1.0);
uv = mix(pc.src.xy, pc.src.zw, t);
}
@@ -0,0 +1,22 @@
// 1115.1.0
#pragma once
const uint32_t blend_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x00000014,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x00000011,0x00030010,
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
0x00000000,0x00050005,0x00000009,0x4374756f,0x726f6c6f,0x00000000,0x00040005,0x0000000d,
0x66727553,0x00656361,0x00030005,0x00000011,0x00007675,0x00040047,0x00000009,0x0000001e,
0x00000000,0x00040047,0x0000000d,0x00000021,0x00000000,0x00040047,0x0000000d,0x00000022,
0x00000000,0x00040047,0x00000011,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,
0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,
0x00000003,0x00090019,0x0000000a,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
0x00000001,0x00000000,0x0003001b,0x0000000b,0x0000000a,0x00040020,0x0000000c,0x00000000,
0x0000000b,0x0004003b,0x0000000c,0x0000000d,0x00000000,0x00040017,0x0000000f,0x00000006,
0x00000002,0x00040020,0x00000010,0x00000001,0x0000000f,0x0004003b,0x00000010,0x00000011,
0x00000001,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
0x0004003d,0x0000000b,0x0000000e,0x0000000d,0x0004003d,0x0000000f,0x00000012,0x00000011,
0x00050057,0x00000007,0x00000013,0x0000000e,0x00000012,0x0003003e,0x00000009,0x00000013,
0x000100fd,0x00010038
};
@@ -0,0 +1,315 @@
/* Alpha composition: one surface drawn source-over onto the scene - see blend_pass.h. Compositor thread. */
#define _POSIX_C_SOURCE 200809L
#include "blend_pass.h"
#include "vk_loader.h"
#include "vk_present.h" /* banner_log, vkp_gpu_name */
#include <string.h>
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
* and committed next to the GLSL: the NDK build compiles no shaders. */
#include "blend_vert.h"
#include "blend_frag.h"
struct pc_blend { float dst[4]; float src[4]; };
static VkDevice g_dev;
static int g_ready; /* 0 = not built, 1 = ok, -1 = unavailable (said once) */
static VkSampler g_sampler;
static VkDescriptorSetLayout g_dsl;
static VkPipelineLayout g_pl;
static VkDescriptorPool g_dpool;
static VkShaderModule g_vert, g_frag;
static unsigned g_frame = 1;
/* One render pass + pipeline per target format: the screen swapchain's, the scene image's, the HDR
* composition's. */
#define NFMT 4
static struct { VkFormat fmt; VkRenderPass rp; VkPipeline pipe; } g_out[NFMT];
#define NSRC 32
static struct { VkImage img; VkImageView view; VkDescriptorSet ds; unsigned frame; } g_src[NSRC];
#define NDST 12
static struct { VkImage img; VkFormat fmt; int w, h; VkImageView view; VkFramebuffer fb; unsigned frame; } g_dst[NDST];
void blendp_bind_device(VkDevice dev) { g_dev = dev; }
void blendp_begin_frame(void) { g_frame++; }
static void src_release(int i) {
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
memset(&g_src[i], 0, sizeof(g_src[i]));
}
static void dst_release(int i) {
if (g_dst[i].fb) g_vk.DestroyFramebuffer(g_dev, g_dst[i].fb, NULL);
if (g_dst[i].view) g_vk.DestroyImageView(g_dev, g_dst[i].view, NULL);
memset(&g_dst[i], 0, sizeof(g_dst[i]));
}
void blendp_forget_image(VkImage img) {
if (!g_dev || !img) return;
for (int i = 0; i < NSRC; i++) if (g_src[i].img == img) src_release(i);
for (int i = 0; i < NDST; i++) if (g_dst[i].img == img) dst_release(i);
}
static VkShaderModule shader(const uint32_t *code, size_t size) {
VkShaderModule m = VK_NULL_HANDLE;
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
return m;
}
/* The target keeps what the blits put there (LOAD) and stays in TRANSFER_DST_OPTIMAL for the blits
* that follow. The dependencies order the pass between them. */
static VkRenderPass make_rp(VkFormat fmt) {
VkAttachmentDescription att = {.format = fmt, .samples = VK_SAMPLE_COUNT_1_BIT,
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL};
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
.pColorAttachments = &ref};
VkSubpassDependency deps[2] = {
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT},
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT |
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT}};
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
.dependencyCount = 2, .pDependencies = deps};
VkRenderPass rp = VK_NULL_HANDLE;
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &rp) != VK_SUCCESS) return VK_NULL_HANDLE;
return rp;
}
static VkPipeline make_pipe(VkRenderPass rp) {
VkPipelineShaderStageCreateInfo st[2] = {
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT,
.module = g_vert, .pName = "main"},
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
.module = g_frag, .pName = "main"}};
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
.dynamicStateCount = 2, .pDynamicStates = dyn};
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
.viewportCount = 1, .scissorCount = 1};
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
/* The target's alpha is left alone: the screen is opaque whatever is drawn onto it. */
VkPipelineColorBlendAttachmentState ba = {
.blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD,
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
.alphaBlendOp = VK_BLEND_OP_ADD,
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT};
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
.attachmentCount = 1, .pAttachments = &ba};
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
.renderPass = rp, .subpass = 0};
VkPipeline p = VK_NULL_HANDLE;
if (g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p) != VK_SUCCESS) return VK_NULL_HANDLE;
return p;
}
static void objects_destroy(void) {
if (g_vert) g_vk.DestroyShaderModule(g_dev, g_vert, NULL);
if (g_frag) g_vk.DestroyShaderModule(g_dev, g_frag, NULL);
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
g_vert = g_frag = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE;
}
static int objects_ensure(void) {
if (g_ready) return g_ready == 1 ? 0 : -1;
g_ready = -1;
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.bindingCount = 1, .pBindings = &b};
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .offset = 0, .size = sizeof(struct pc_blend)};
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, NSRC};
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, .maxSets = NSRC,
.poolSizeCount = 1, .pPoolSizes = &ps};
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
g_ready = 1;
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
vkp_gpu_name());
return 0;
fail:
objects_destroy();
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
"are drawn opaque this session");
return -1;
}
static int out_slot(VkFormat fmt) {
int i;
for (i = 0; i < NFMT && g_out[i].pipe; i++) if (g_out[i].fmt == fmt) return i;
if (i == NFMT) return -1;
VkRenderPass rp = make_rp(fmt);
VkPipeline pipe = rp ? make_pipe(rp) : VK_NULL_HANDLE;
if (!pipe) {
if (rp) g_vk.DestroyRenderPass(g_dev, rp, NULL);
return -1;
}
g_out[i].fmt = fmt; g_out[i].rp = rp; g_out[i].pipe = pipe;
return i;
}
static VkImageView make_view(VkImage img, VkFormat fmt) {
VkImageView v = VK_NULL_HANDLE;
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = fmt,
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
return v;
}
/* The slot to recycle: a free one, else the one unused for longest - never one this command buffer
* already refers to. -1 = every slot is in this frame. */
#define OLDEST(arr, n, out) do { \
unsigned age_ = 0; \
(out) = -1; \
for (int k_ = 0; k_ < (n); k_++) { \
if (!(arr)[k_].img) { (out) = k_; break; } \
if ((arr)[k_].frame != g_frame && g_frame - (arr)[k_].frame > age_) { age_ = g_frame - (arr)[k_].frame; (out) = k_; } \
} \
} while (0)
static VkDescriptorSet src_ensure(VkImage img, VkFormat fmt, VkImageLayout layout) {
int i;
for (i = 0; i < NSRC; i++)
if (g_src[i].img == img) { g_src[i].frame = g_frame; return g_src[i].ds; }
OLDEST(g_src, NSRC, i);
if (i < 0) return VK_NULL_HANDLE;
src_release(i);
if (!(g_src[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &g_src[i].ds) != VK_SUCCESS) {
g_src[i].ds = VK_NULL_HANDLE;
src_release(i);
return VK_NULL_HANDLE;
}
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = g_src[i].view, .imageLayout = layout};
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = g_src[i].ds, .dstBinding = 0,
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.pImageInfo = &ii};
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
g_src[i].img = img; g_src[i].frame = g_frame;
return g_src[i].ds;
}
static VkFramebuffer dst_ensure(VkImage img, VkFormat fmt, int w, int h, VkRenderPass rp) {
int i;
for (i = 0; i < NDST; i++) {
if (g_dst[i].img != img) continue;
if (g_dst[i].fmt == fmt && g_dst[i].w == w && g_dst[i].h == h) { g_dst[i].frame = g_frame; return g_dst[i].fb; }
if (g_dst[i].frame == g_frame) return VK_NULL_HANDLE;
break;
}
if (i == NDST) OLDEST(g_dst, NDST, i);
if (i < 0) return VK_NULL_HANDLE;
dst_release(i);
if (!(g_dst[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = rp,
.attachmentCount = 1, .pAttachments = &g_dst[i].view, .width = (uint32_t)w,
.height = (uint32_t)h, .layers = 1};
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_dst[i].fb) != VK_SUCCESS) {
g_dst[i].fb = VK_NULL_HANDLE;
dst_release(i);
return VK_NULL_HANDLE;
}
g_dst[i].img = img; g_dst[i].fmt = fmt; g_dst[i].w = w; g_dst[i].h = h; g_dst[i].frame = g_frame;
return g_dst[i].fb;
}
static void src_layout(VkCommandBuffer cmd, VkImage img, VkImageLayout from, VkImageLayout to) {
int sampling = to == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkImageMemoryBarrier b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = from, .newLayout = to,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = img, .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1},
.srcAccessMask = sampling ? VK_ACCESS_TRANSFER_READ_BIT : VK_ACCESS_SHADER_READ_BIT,
.dstAccessMask = sampling ? VK_ACCESS_SHADER_READ_BIT : VK_ACCESS_TRANSFER_READ_BIT};
g_vk.CmdPipelineBarrier(cmd, sampling ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
sampling ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, NULL, 0, NULL, 1, &b);
}
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit) {
if (!src || !dst || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0 || objects_ensure() != 0) return -1;
int slot = out_slot(dst_fmt);
if (slot < 0) return -1;
VkImageLayout sampled = src_general ? VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorSet ds = src_ensure(src, src_fmt, sampled);
if (!ds) return -1;
VkFramebuffer fb = dst_ensure(dst, dst_fmt, dst_w, dst_h, g_out[slot].rp);
if (!fb) return -1;
const VkOffset3D *d = blit->dstOffsets, *s = blit->srcOffsets;
int x0 = d[0].x < d[1].x ? d[0].x : d[1].x, x1 = d[0].x < d[1].x ? d[1].x : d[0].x;
int y0 = d[0].y < d[1].y ? d[0].y : d[1].y, y1 = d[0].y < d[1].y ? d[1].y : d[0].y;
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > dst_w) x1 = dst_w;
if (y1 > dst_h) y1 = dst_h;
if (x1 <= x0 || y1 <= y0) return 0;
struct pc_blend pc = {
.dst = {2.0f * d[0].x / dst_w - 1.0f, 2.0f * d[0].y / dst_h - 1.0f,
2.0f * d[1].x / dst_w - 1.0f, 2.0f * d[1].y / dst_h - 1.0f},
.src = {(float)s[0].x / src_w, (float)s[0].y / src_h, (float)s[1].x / src_w, (float)s[1].y / src_h}};
if (!src_general) src_layout(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sampled);
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_out[slot].rp,
.framebuffer = fb,
.renderArea = {{x0, y0}, {(uint32_t)(x1 - x0), (uint32_t)(y1 - y0)}}};
VkViewport vp = {0, 0, (float)dst_w, (float)dst_h, 0, 1};
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
g_vk.CmdSetScissor(cmd, 0, 1, &rpi.renderArea);
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_out[slot].pipe);
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
g_vk.CmdEndRenderPass(cmd);
if (!src_general) src_layout(cmd, src, sampled, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
return 0;
}
@@ -0,0 +1,28 @@
#ifndef BLEND_PASS_H
#define BLEND_PASS_H
/*
* Alpha composition. The scene is composed with blits, which replace what is under them; a surface
* with translucent pixels (gamescope's overlay and notification planes over a game, a toolkit's
* shadowed popup) is drawn through this pass instead, source-over with premultiplied alpha.
*
* Compositor thread. Objects are built on the first use; if the driver refuses them the pass is
* unavailable for the session (logged once) and the caller keeps blitting.
*/
#define VK_USE_PLATFORM_ANDROID_KHR
#include <vulkan/vulkan.h>
/* Called from dev_init (device up). */
void blendp_bind_device(VkDevice dev);
/* A source or target image is about to be destroyed: drop what is cached for it. Frames are fenced,
* so nothing submitted still uses it. */
void blendp_forget_image(VkImage img);
/* A new command buffer is being recorded: what the previous one used may be recycled again. */
void blendp_begin_frame(void);
/* Record one surface over `dst`. `dst` is in TRANSFER_DST_OPTIMAL before and after. `src` was created
* with SAMPLED usage and is in GENERAL (src_general) or TRANSFER_SRC_OPTIMAL, and is left there.
* `blit` is the draw as the blit path maps it. 0 = recorded, -1 = unavailable (nothing recorded). */
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit);
#endif
@@ -0,0 +1,55 @@
// 1115.1.0
#pragma once
const uint32_t blend_vert_code[] = {
0x07230203,0x00010000,0x0008000b,0x0000003a,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0008000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x0000001b,0x00000031,
0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,0x00000000,0x00030005,
0x00000009,0x00000074,0x00060005,0x0000000c,0x565f6c67,0x65747265,0x646e4978,0x00007865,
0x00060005,0x00000019,0x505f6c67,0x65567265,0x78657472,0x00000000,0x00060006,0x00000019,
0x00000000,0x505f6c67,0x7469736f,0x006e6f69,0x00070006,0x00000019,0x00000001,0x505f6c67,
0x746e696f,0x657a6953,0x00000000,0x00070006,0x00000019,0x00000002,0x435f6c67,0x4470696c,
0x61747369,0x0065636e,0x00070006,0x00000019,0x00000003,0x435f6c67,0x446c6c75,0x61747369,
0x0065636e,0x00030005,0x0000001b,0x00000000,0x00030005,0x0000001d,0x00004350,0x00040006,
0x0000001d,0x00000000,0x00747364,0x00040006,0x0000001d,0x00000001,0x00637273,0x00030005,
0x0000001f,0x00006370,0x00030005,0x00000031,0x00007675,0x00040047,0x0000000c,0x0000000b,
0x0000002a,0x00030047,0x00000019,0x00000002,0x00050048,0x00000019,0x00000000,0x0000000b,
0x00000000,0x00050048,0x00000019,0x00000001,0x0000000b,0x00000001,0x00050048,0x00000019,
0x00000002,0x0000000b,0x00000003,0x00050048,0x00000019,0x00000003,0x0000000b,0x00000004,
0x00030047,0x0000001d,0x00000002,0x00050048,0x0000001d,0x00000000,0x00000023,0x00000000,
0x00050048,0x0000001d,0x00000001,0x00000023,0x00000010,0x00040047,0x00000031,0x0000001e,
0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,
0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,
0x00000007,0x00040015,0x0000000a,0x00000020,0x00000001,0x00040020,0x0000000b,0x00000001,
0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x0004002b,0x0000000a,0x0000000e,
0x00000001,0x00040017,0x00000015,0x00000006,0x00000004,0x00040015,0x00000016,0x00000020,
0x00000000,0x0004002b,0x00000016,0x00000017,0x00000001,0x0004001c,0x00000018,0x00000006,
0x00000017,0x0006001e,0x00000019,0x00000015,0x00000006,0x00000018,0x00000018,0x00040020,
0x0000001a,0x00000003,0x00000019,0x0004003b,0x0000001a,0x0000001b,0x00000003,0x0004002b,
0x0000000a,0x0000001c,0x00000000,0x0004001e,0x0000001d,0x00000015,0x00000015,0x00040020,
0x0000001e,0x00000009,0x0000001d,0x0004003b,0x0000001e,0x0000001f,0x00000009,0x00040020,
0x00000020,0x00000009,0x00000015,0x0004002b,0x00000006,0x00000029,0x00000000,0x0004002b,
0x00000006,0x0000002a,0x3f800000,0x00040020,0x0000002e,0x00000003,0x00000015,0x00040020,
0x00000030,0x00000003,0x00000007,0x0004003b,0x00000030,0x00000031,0x00000003,0x00050036,
0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000008,
0x00000009,0x00000007,0x0004003d,0x0000000a,0x0000000d,0x0000000c,0x000500c7,0x0000000a,
0x0000000f,0x0000000d,0x0000000e,0x0004006f,0x00000006,0x00000010,0x0000000f,0x0004003d,
0x0000000a,0x00000011,0x0000000c,0x000500c3,0x0000000a,0x00000012,0x00000011,0x0000000e,
0x0004006f,0x00000006,0x00000013,0x00000012,0x00050050,0x00000007,0x00000014,0x00000010,
0x00000013,0x0003003e,0x00000009,0x00000014,0x00050041,0x00000020,0x00000021,0x0000001f,
0x0000001c,0x0004003d,0x00000015,0x00000022,0x00000021,0x0007004f,0x00000007,0x00000023,
0x00000022,0x00000022,0x00000000,0x00000001,0x00050041,0x00000020,0x00000024,0x0000001f,
0x0000001c,0x0004003d,0x00000015,0x00000025,0x00000024,0x0007004f,0x00000007,0x00000026,
0x00000025,0x00000025,0x00000002,0x00000003,0x0004003d,0x00000007,0x00000027,0x00000009,
0x0008000c,0x00000007,0x00000028,0x00000001,0x0000002e,0x00000023,0x00000026,0x00000027,
0x00050051,0x00000006,0x0000002b,0x00000028,0x00000000,0x00050051,0x00000006,0x0000002c,
0x00000028,0x00000001,0x00070050,0x00000015,0x0000002d,0x0000002b,0x0000002c,0x00000029,
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000001b,0x0000001c,0x0003003e,0x0000002f,
0x0000002d,0x00050041,0x00000020,0x00000032,0x0000001f,0x0000000e,0x0004003d,0x00000015,
0x00000033,0x00000032,0x0007004f,0x00000007,0x00000034,0x00000033,0x00000033,0x00000000,
0x00000001,0x00050041,0x00000020,0x00000035,0x0000001f,0x0000000e,0x0004003d,0x00000015,
0x00000036,0x00000035,0x0007004f,0x00000007,0x00000037,0x00000036,0x00000036,0x00000002,
0x00000003,0x0004003d,0x00000007,0x00000038,0x00000009,0x0008000c,0x00000007,0x00000039,
0x00000001,0x0000002e,0x00000034,0x00000037,0x00000038,0x0003003e,0x00000031,0x00000039,
0x000100fd,0x00010038
};
+202 -393
View File
@@ -30,6 +30,7 @@
#include <time.h>
#include <stdarg.h>
#include <errno.h>
#include <poll.h>
#include <sys/timerfd.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
@@ -52,6 +53,7 @@
#include "effects_chain.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "compositor_internal.h"
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
@@ -162,21 +164,9 @@ static int log_budget(void) {
static struct wl_display *g_display;
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
struct client_info {
struct wl_client *client;
struct wl_listener destroy;
pid_t pid;
char name[64];
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
static struct client_info *g_clients;
static struct client_info *client_info_of(struct wl_client *client) {
struct client_info *client_info_of(struct wl_client *client) {
for (struct client_info *ci = g_clients; ci; ci = ci->next)
if (ci->client == client) return ci;
return NULL;
@@ -249,6 +239,8 @@ struct surface {
struct dmabuf_buffer *dmabuf_buf; /* its imported image, kept until replaced or the surface goes */
struct wl_listener dmabuf_destroy;
int buf_w, buf_h, has_content;
int buf_alpha;
int opaque[4], pending_opaque[4], pending_opaque_set; /* x, y, w, h; w = 0: none */
int src_set, dst_set;
float src[4];
int dst[2];
@@ -259,6 +251,10 @@ struct surface {
int releases_pending; /* FPS limiter: buffers of this surface still to be released */
enum surface_role role;
/* A role-less surface's last buffer, held unreleased: it may become the pointer's image
* (wl_pointer.set_cursor after the commit, which is wlroots' order). */
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -473,6 +469,12 @@ static struct seat_pointer g_ptrs[MAX_PTRS];
static int g_nptrs;
static struct seat_keyboard g_kbs[MAX_PTRS];
static int g_nkbs;
/* Modifier state in the keymap's real-modifier bits (Shift=0x1, Lock=0x2, Control=0x4, Mod1=0x8,
* Mod4=0x40). A client of the wayland backend - gamescope, labwc - takes modifiers only from
* wl_keyboard.modifiers and ignores what a Shift key event does to its xkb state, so without this
* every capital arrived lowercase. */
static uint32_t g_mods_depressed, g_mods_locked;
static uint32_t g_mod_keys_held; /* one bit per modifier key below, so a repeat press is harmless */
static struct seat_touch g_touches[MAX_PTRS];
static int g_ntouches;
@@ -487,6 +489,7 @@ static int g_ntouches;
#define CURSOR_MAX_PX (256 * 256)
static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER;
static struct surface *g_cursor_surface; /* compositor thread only */
static struct surface *g_cursor_shown; /* compositor thread: whose image g_cursor_px holds */
static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */
static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */
static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */
@@ -494,6 +497,14 @@ static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */
static int g_cursor_hidden = 1; /* the client asked for no pointer */
static int g_cursor_serial; /* bumped on every change; the app polls it */
/* The last image again, after a hide: the same surface set as the pointer once more with nothing
* newly committed to it (wlroots re-sets its cursor surface each time the pointer enters). */
static void cursor_publish_shown(void) {
pthread_mutex_lock(&g_cursor_lock);
if (g_cursor_w > 0) { g_cursor_hidden = 0; g_cursor_serial++; } /* serial: the hotspot may be new */
pthread_mutex_unlock(&g_cursor_lock);
}
static void cursor_publish_hidden(void) {
pthread_mutex_lock(&g_cursor_lock);
if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; }
@@ -501,7 +512,7 @@ static void cursor_publish_hidden(void) {
}
static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) {
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
pthread_mutex_lock(&g_cursor_lock);
for (int y = 0; y < h; y++)
memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4);
@@ -514,10 +525,13 @@ static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t strid
static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
int32_t stride = wl_shm_buffer_get_stride(shm);
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
/* libwayland only promises stride >= width; each row copied here is width * 4 bytes, so a
* shorter stride would read past the end of the client's pool. */
if ((int64_t)stride < (int64_t)w * 4) return;
wl_shm_buffer_begin_access(shm);
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h,
(size_t)wl_shm_buffer_get_stride(shm), hx, hy);
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, (size_t)stride, hx, hy);
wl_shm_buffer_end_access(shm);
}
@@ -558,40 +572,7 @@ static void constraints_focus_entered(struct wl_resource *target, struct wl_clie
/* ------------------------------------------------------------------ dmabuf buffers */
#define FOURCC(a, b, c, d) \
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
#define MOD_LINEAR VKP_MOD_LINEAR
#define MOD_INVALID VKP_MOD_INVALID
#define MAX_PLANES 4
struct dmabuf_params {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
uint64_t modifier[MAX_PLANES];
int n_planes;
};
struct dmabuf_buffer {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
int n_planes;
int32_t width, height;
uint32_t format;
uint64_t modifier;
struct vkp_image *img; /* imported once, reused for every frame */
int import_failed;
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
* until the surface commits something newer, so the picture never blinks to black. */
int refs;
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
};
static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
if (!b || --b->refs > 0) return;
vkp_image_destroy(b->img);
for (int i = 0; i < b->n_planes; i++)
@@ -602,7 +583,7 @@ static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
static void dbuf_buffer_destroy_req(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct wl_buffer_interface dbuf_buffer_impl = {
const struct wl_buffer_interface dbuf_buffer_impl = {
.destroy = dbuf_buffer_destroy_req,
};
@@ -744,6 +725,7 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
wl_buffer_send_release(buffer);
s->buf_w = w;
s->buf_h = h;
s->buf_alpha = wl_shm_buffer_get_format(shm) == WL_SHM_FORMAT_ARGB8888;
s->has_content = s->shm_img != NULL;
g_stat_shm++;
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
@@ -812,6 +794,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
}
s->buf_w = b->width;
s->buf_h = b->height;
s->buf_alpha = (b->format & 0xff) == 'A';
s->has_content = b->img != NULL;
g_stat_dmabuf++;
const int announced_now = !s->announced_vulkan;
@@ -983,13 +966,94 @@ static void surface_frame(struct wl_client *c, struct wl_resource *r, uint32_t c
wl_resource_set_implementation(callback, NULL, NULL, frame_callback_destroy);
wl_list_insert(s->pending_frames.prev, wl_resource_get_link(callback));
}
/* The bounding box serves pointer confinement. The exact flag also tells opaque-region handling
* whether the bounding box itself is the whole region. */
struct region { int set, exact; int x, y, w, h; };
/* A wl_surface's opaque region is pending until its next commit. An exact region that covers
* the surface lets the blit path keep handling an otherwise alpha-capable buffer. */
static void surface_set_opaque(struct wl_client *c, struct wl_resource *r,
struct wl_resource *region) {}
struct wl_resource *region) {
struct surface *s = wl_resource_get_user_data(r);
struct region *rg = region ? wl_resource_get_user_data(region) : NULL;
memset(s->pending_opaque, 0, sizeof(s->pending_opaque));
s->pending_opaque_set = 1;
if (rg && rg->set && rg->exact) {
s->pending_opaque[0] = rg->x;
s->pending_opaque[1] = rg->y;
s->pending_opaque[2] = rg->w;
s->pending_opaque[3] = rg->h;
}
struct client_info *ci = client_info_of(c);
if (ci) ci->declares_opaque = 1;
}
static void surface_set_input(struct wl_client *c, struct wl_resource *r,
struct wl_resource *region) {}
static void send_toplevel_configure(struct surface *s);
/* The pointer image from a cursor surface's buffer: wl_shm is copied, a dma-buf (labwc on a GPU
* renderer) is read back once the client's render into it is done - its implicit fence, as the
* zero-copy path waits for it (ahb_swapchain_present). */
static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) {
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
if (!db->img) db->import_failed = 1;
}
if (!db->img) return;
struct pollfd p = {.fd = db->fd[0], .events = POLLIN};
int r;
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
if (vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) != 0) return;
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
} else {
return;
}
g_cursor_shown = s;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
}
/* Only what could be a pointer image is held; anything larger goes straight back. */
static void surface_hold_idle(struct surface *s, struct wl_resource *buffer) {
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
int64_t px = db ? (int64_t)db->width * db->height
: shm ? (int64_t)wl_shm_buffer_get_width(shm) * wl_shm_buffer_get_height(shm) : 0;
if (px <= 0 || px > CURSOR_MAX_PX) { wl_buffer_send_release(buffer); return; }
s->idle_buffer = buffer;
s->idle_buffer_destroy.notify = on_idle_buffer_destroyed;
wl_resource_add_destroy_listener(buffer, &s->idle_buffer_destroy);
}
/* Lets go of the held buffer; release = give it back to the client (not while it is going away). */
static struct wl_resource *surface_take_idle(struct surface *s) {
struct wl_resource *buffer = s->idle_buffer;
if (!buffer) return NULL;
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
return buffer;
}
static void surface_drop_idle(struct surface *s) {
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
static void surface_commit(struct wl_client *c, struct wl_resource *r) {
struct surface *s = wl_resource_get_user_data(r);
struct surface *child;
@@ -1013,6 +1077,10 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
memcpy(s->dst, s->pending_dst, sizeof(s->dst));
s->pending_dst_set = 0;
}
if (s->pending_opaque_set) {
memcpy(s->opaque, s->pending_opaque, sizeof(s->opaque));
s->pending_opaque_set = 0;
}
if (s->pending_attach) {
struct wl_resource *buffer = s->pending_buffer;
@@ -1031,29 +1099,21 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
s->pending_buffer = NULL;
s->pending_attach = 0;
surface_drop_idle(s); /* replaced, whatever the surface is now */
if (s->role == ROLE_CURSOR) {
/* The client's pointer image, copied out for the app's overlay (see cursor_publish_*)
* rather than composited, so it survives the zero-copy and HDR layer paths. */
if (!buffer) {
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
} else if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (has_single_pixel) {
cursor_publish_pixels((const uint8_t *)&pixel, 1, 1, 4, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
if (!db->img) db->import_failed = 1;
}
if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0)
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
} else {
cursor_publish_buffer(s, buffer);
}
if (buffer) wl_buffer_send_release(buffer);
} else if (s->role == ROLE_NONE) {
/* Role-less surface: never drawn. */
if (buffer) wl_buffer_send_release(buffer);
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
if (buffer) surface_hold_idle(s, buffer);
} else if (db) {
take_dmabuf(s, db, buffer);
} else if (shm) {
@@ -1128,6 +1188,8 @@ static void surface_resource_destroy(struct wl_resource *r) {
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL;
touch_cancel_surface(s);
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -1164,24 +1226,29 @@ static void surface_resource_destroy(struct wl_resource *r) {
/* ------------------------------------------------------------------ wl_region */
/* A region is kept as the bounding box of its rectangles: enough for pointer confinement,
* where winewayland sends one rectangle (the ClipCursor area). Subtractions are ignored. */
struct region { int set; int x, y, w, h; };
static void region_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void region_add(struct wl_client *c, struct wl_resource *r,
int32_t x, int32_t y, int32_t w, int32_t h) {
struct region *rg = wl_resource_get_user_data(r);
if (!rg || w <= 0 || h <= 0) return;
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = 1; return; }
int x2 = rg->x + rg->w > x + w ? rg->x + rg->w : x + w;
int y2 = rg->y + rg->h > y + h ? rg->y + rg->h : y + h;
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = rg->exact = 1; return; }
long long x2 = (long long)rg->x + rg->w, y2 = (long long)rg->y + rg->h;
long long nx2 = (long long)x + w, ny2 = (long long)y + h;
int inside = x >= rg->x && y >= rg->y && nx2 <= x2 && ny2 <= y2;
int around = x <= rg->x && y <= rg->y && nx2 >= x2 && ny2 >= y2;
if (!inside && !around) rg->exact = 0;
if (nx2 > x2) x2 = nx2;
if (ny2 > y2) y2 = ny2;
if (x < rg->x) rg->x = x;
if (y < rg->y) rg->y = y;
rg->w = x2 - rg->x; rg->h = y2 - rg->y;
rg->w = (int)(x2 - rg->x > INT32_MAX ? INT32_MAX : x2 - rg->x);
rg->h = (int)(y2 - rg->y > INT32_MAX ? INT32_MAX : y2 - rg->y);
}
static void region_subtract(struct wl_client *c, struct wl_resource *r,
int32_t x, int32_t y, int32_t w, int32_t h) {}
int32_t x, int32_t y, int32_t w, int32_t h) {
struct region *rg = wl_resource_get_user_data(r);
if (rg && w > 0 && h > 0) rg->exact = 0;
}
static void region_resource_destroy(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
static const struct wl_region_interface region_impl = {
.destroy = region_destroy,
@@ -1204,6 +1271,7 @@ static void compositor_create_surface(struct wl_client *c, struct wl_resource *r
wl_list_init(&s->child_link);
wl_list_init(&s->toplevel_link);
wl_list_init(&s->pending_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
wl_list_insert(&g_surfaces, &s->link);
wl_resource_set_implementation(s->resource, &surface_impl, s, surface_resource_destroy);
}
@@ -1669,319 +1737,6 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
wl_resource_set_implementation(r, &desktop_impl, NULL, NULL);
}
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
dmabuf_buffer_unref(wl_resource_get_user_data(r));
}
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (plane >= MAX_PLANES) { close(fd); return; }
if (p->fd[plane] >= 0) close(p->fd[plane]);
p->fd[plane] = fd;
p->offset[plane] = offset;
p->stride[plane] = stride;
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
}
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
if (!b) return NULL;
b->n_planes = p->n_planes;
b->refs = 1; /* the wl_buffer resource's */
b->width = w; b->height = h; b->format = format;
b->modifier = p->modifier[0];
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
for (int i = 0; i < p->n_planes; i++) {
b->fd[i] = p->fd[i];
b->offset[i] = p->offset[i];
b->stride[i] = p->stride[i];
p->fd[i] = -1; /* ownership moves to the buffer */
}
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!buf) {
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
free(b);
wl_client_post_no_memory(c);
return NULL;
}
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
struct client_info *ci = client_info_of(c);
if (ci) ci->dmabuf_buffers++;
return buf;
}
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
uint32_t format, uint32_t flags) {
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
else zwp_linux_buffer_params_v1_send_failed(r);
}
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
params_do_create(c, r, buffer_id, w, h, format, flags);
}
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
.destroy = params_destroy,
.add = params_add,
.create = params_create,
.create_immed = params_create_immed,
};
static void params_resource_destroy(struct wl_resource *r) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (!p) return;
for (int i = 0; i < MAX_PLANES; i++)
if (p->fd[i] >= 0) close(p->fd[i]);
free(p);
}
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct dmabuf_params *p = calloc(1, sizeof(*p));
if (!p) { wl_client_post_no_memory(c); return; }
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
wl_resource_get_version(r), id);
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(pr, &params_impl, p, params_resource_destroy);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface);
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
.destroy = dmabuf_destroy,
.create_params = dmabuf_create_params,
.get_default_feedback = dmabuf_get_default_feedback,
.get_surface_feedback = dmabuf_get_surface_feedback,
};
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
* takes the first format it sees - gets exactly the table it always had. */
#define DMABUF_NFMT_MAX 6
#define DMABUF_NFMT_SDR 4
#define DMABUF_NMOD 4
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
static int g_dmabuf_fmts_ready;
static void dmabuf_build_feedback(void);
static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
* we advertised before this table existed. */
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
if (got[i] == MOD_LINEAR) continue;
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
g_dmabuf_fmts[f].mods[k++] = got[i];
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
}
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
g_dmabuf_fmts[f].n = k;
uint32_t fmt = g_dmabuf_fmts[f].fmt;
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
}
dmabuf_build_feedback();
}
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
*
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
*
* What a client needs from us is one tranche describing "everything the compositor can import":
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
#ifndef F_ADD_SEALS
#define F_ADD_SEALS 1033
#define F_SEAL_SEAL 0x0001
#define F_SEAL_SHRINK 0x0002
#define F_SEAL_GROW 0x0004
#define F_SEAL_WRITE 0x0008
#endif
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
static size_t g_fmt_table_size;
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
static dev_t g_main_device; /* the render node clients should allocate on */
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
static dev_t dmabuf_render_node(void) {
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
struct stat st;
if (g_no_render_node) return 0;
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
return 0;
}
static void dmabuf_build_feedback(void) {
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
int n = 0;
g_main_device = dmabuf_render_node();
for (int f = 0; f < g_dmabuf_nfmt; f++)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
entries[n].format = g_dmabuf_fmts[f].fmt;
entries[n].pad = 0;
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
n++;
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
if (write(fd, entries, size) != (ssize_t)size) {
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
close(fd);
return;
}
/* The client mmaps this read-only and trusts it not to change under it. */
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
/* One tranche: our device, every pair in the table, no scanout flag. */
static void dmabuf_feedback_send(struct wl_resource *fb) {
struct wl_array dev, idx;
uint16_t *ind;
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
wl_array_init(&dev);
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
wl_array_release(&dev);
wl_array_init(&idx);
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
wl_array_release(&idx);
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
zwp_linux_dmabuf_feedback_v1_send_done(fb);
}
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
.destroy = dmabuf_feedback_destroy,
};
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
wl_resource_get_version(parent), id);
if (!fb) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
dmabuf_feedback_send(fb);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct client_info *ci = client_info_of(c);
if (ci) ci->asked_feedback = 1;
dmabuf_new_feedback(c, r, id);
}
/* Per-surface feedback would let us hint a different tranche for a window on its own display
* layer; we have nothing better to say per surface, so it is the default one. */
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface) {
dmabuf_new_feedback(c, r, id);
}
static void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
/* From version 4 the format and modifier events are gone: the client asks for feedback
* instead, and sending both would only confuse it about which list is authoritative. */
if (ver >= 4) return;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
if (ver >= 3)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
}
}
/* ------------------------------------------------------------------ wl_output */
/* wl_output v3+ has a request (release); without an implementation the first client to send it
@@ -2041,6 +1796,17 @@ static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, i
g_hdr_unimported_below = dl->n;
}
/* A client that describes opaque regions can intentionally leave part of an alpha buffer see-through.
* Gamescope uses this for its full-size Steam notification and overlay planes. */
static int surface_translucent(const struct surface *s, int w, int h) {
if (!s->buf_alpha) return 0;
const struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
if (!ci || !ci->declares_opaque) return 0;
const int *o = s->opaque;
return !(o[2] > 0 && o[0] <= 0 && o[1] <= 0 &&
(long long)o[0] + o[2] >= w && (long long)o[1] + o[3] >= h);
}
static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy) {
struct vkp_image *img = surface_image(s);
float sx = 0, sy = 0, sw = (float)s->buf_w, sh = (float)s->buf_h;
@@ -2060,7 +1826,8 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
dl->d = d;
dl->cap = cap;
}
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh};
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh,
surface_translucent(s, dw, dh)};
s->drawn = 1;
}
@@ -2131,6 +1898,7 @@ static int on_frame_timer(void *data) {
static int layer_candidate(const struct draw_list *dl, int scene_w, int scene_h) {
for (int i = dl->n - 1; i >= 0 && i >= dl->n - 2; i--) {
const struct vkp_draw *d = &dl->d[i];
if (d->blend) continue; /* the translucent plane must not replace the opaque game layer */
if (!vkp_image_is_dmabuf(d->img)) continue;
if (d->dx != 0 || d->dy != 0 || d->dw != scene_w || d->dh != scene_h) continue;
if (d->sx != 0 || d->sy != 0 || (int)d->sw != vkp_image_width(d->img) ||
@@ -2469,7 +2237,7 @@ static void render_scene(void) {
* its own layer, cropped and placed by the display. */
int go[8], ov = 0;
if (over == 1 && li >= 0 && vkp_map_draw(&dl.d[li + 1], go))
ov = sc_layer_present_overlay(dl.d[li + 1].img, go) == 0 ? 1 : -1;
ov = sc_layer_present_overlay(dl.d[li + 1].img, go, dl.d[li + 1].blend) == 0 ? 1 : -1;
if (ov <= 0) sc_layer_hide_overlay();
if (ov < 0) { /* the overlay layer refused it: draw the whole scene the old way */
sc_layer_hide();
@@ -2595,6 +2363,16 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
g_cursor_surface = s;
g_cursor_hx = hx;
g_cursor_hy = hy;
/* Its content may already be committed: wlroots' Wayland backend attaches and commits the
* cursor surface first and sets it as the pointer after, and sets it again with nothing new
* committed each time the pointer re-enters. */
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) {
cursor_publish_buffer(s, buffer);
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_pointer_interface pointer_impl = {
@@ -2742,8 +2520,8 @@ static void keyboard_focus(struct wl_resource *target) {
if (sk->focus == target) continue;
sk->focus = target;
wl_keyboard_send_enter(sk->kb, wl_display_next_serial(g_display), target, &keys);
/* Baseline modifiers = none; Shift/Ctrl arrive as their own key events. */
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display), 0, 0, 0, 0);
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_array_release(&keys);
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
@@ -3440,7 +3218,34 @@ static void scroll_event(int steps) {
wl_display_flush_clients(g_display);
}
/* Folds a key into the modifier state; returns whether the state changed. */
static int update_modifiers(uint32_t evdev, int pressed) {
static const struct { uint32_t evdev, mask; } mod_keys[] = {
{ 42, 0x1 }, { 54, 0x1 }, /* Shift L/R */
{ 29, 0x4 }, { 97, 0x4 }, /* Control L/R */
{ 56, 0x8 }, { 100, 0x8 }, /* Alt L/R, both Mod1 in this keymap */
{ 125, 0x40 }, { 126, 0x40 }, /* Super L/R */
};
uint32_t depressed = g_mods_depressed, locked = g_mods_locked;
if (evdev == 58) { /* Caps Lock toggles on press */
if (pressed) locked ^= 0x2;
} else {
int i, n = (int)(sizeof(mod_keys) / sizeof(mod_keys[0]));
for (i = 0; i < n && mod_keys[i].evdev != evdev; i++) {}
if (i == n) return 0;
if (pressed) g_mod_keys_held |= 1u << i; else g_mod_keys_held &= ~(1u << i);
depressed = 0;
for (int j = 0; j < n; j++) if (g_mod_keys_held & (1u << j)) depressed |= mod_keys[j].mask;
}
if (depressed == g_mods_depressed && locked == g_mods_locked) return 0;
g_mods_depressed = depressed;
g_mods_locked = locked;
return 1;
}
static void key_event(uint32_t evdev, int pressed) {
/* Tracked even with nothing to deliver to, so a Shift released unseen is not left held. */
int mods_changed = update_modifiers(evdev, pressed);
/* Keys go to the program window the user last clicked (else the topmost non-shell window),
* never to Wine's desktop surface: winewayland hands each key to the hwnd of the surface that
* holds keyboard focus, and a key handed to explorer's desktop hwnd is queued to explorer's
@@ -3460,9 +3265,13 @@ static void key_event(uint32_t evdev, int pressed) {
struct seat_keyboard *sk;
if (!keyboard_for(client)) return;
keyboard_focus(target->resource);
for_each_keyboard_of(client, sk)
for_each_keyboard_of(client, sk) {
wl_keyboard_send_key(sk->kb, wl_display_next_serial(g_display), now_ms(), evdev,
pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
if (mods_changed)
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_display_flush_clients(g_display);
}
@@ -0,0 +1,79 @@
/*
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
*/
#pragma once
#include <stdint.h>
#include <sys/types.h>
#include <android/log.h>
#include <wayland-server.h>
#include "vk_present.h"
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
#ifndef WLOGE
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
#endif
/* ------------------------------------------------------------------ dmabuf buffers */
#define FOURCC(a, b, c, d) \
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
#define MOD_LINEAR VKP_MOD_LINEAR
#define MOD_INVALID VKP_MOD_INVALID
#define MAX_PLANES 4
struct dmabuf_params {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
uint64_t modifier[MAX_PLANES];
int n_planes;
};
struct dmabuf_buffer {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
int n_planes;
int32_t width, height;
uint32_t format;
uint64_t modifier;
struct vkp_image *img; /* imported once, reused for every frame */
int import_failed;
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
* until the surface commits something newer, so the picture never blinks to black. */
int refs;
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
};
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
struct client_info {
struct wl_client *client;
struct wl_listener destroy;
pid_t pid;
char name[64];
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
/* Clients that describe opaque regions can use alpha outside those regions. Wine windows
* never describe them, and their unused alpha channel must not make them translucent. */
unsigned declares_opaque : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
/* ---- compositor.c */
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
/* ---- wl_dmabuf.c */
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
COMPOSITOR_INTERNAL void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id);
+10 -9
View File
@@ -85,6 +85,7 @@ static struct {
#define ASC_VISIBILITY_HIDE 0
#define ASC_VISIBILITY_SHOW 1
#define ASC_TRANSPARENCY_TRANSLUCENT 1
#define ASC_TRANSPARENCY_OPAQUE 2
static int load_api(void) {
@@ -766,7 +767,8 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
/* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no
* description). 0 = applied. */
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color) {
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color,
int translucent) {
struct slot *s = &l->slots[idx];
ASurfaceTransaction *tx = api.txCreate();
if (!tx) return -1;
@@ -774,9 +776,8 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
if (old) api.setZOrder(tx, l->sc, l->z);
/* The blit was waited for on the CPU, so no acquire fence is needed (-1). */
api.setBuffer(tx, l->sc, s->ahb, -1);
/* The compositor composes with blits, which overwrite: nothing is ever alpha-blended on the
* copy path either, so every layer is opaque and the picture matches it pixel for pixel. */
api.setBufferTransparency(tx, l->sc, ASC_TRANSPARENCY_OPAQUE);
api.setBufferTransparency(tx, l->sc,
translucent ? ASC_TRANSPARENCY_TRANSLUCENT : ASC_TRANSPARENCY_OPAQUE);
apply_geometry(tx, l, r);
apply_frame_rate(tx, l);
apply_colour(tx, l, color);
@@ -883,7 +884,7 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
int idx = take_free_slot(l, sw, sh, AHB_RGBA8, &wait_fd);
if (idx < 0) { log_drop(l); return 0; }
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, color) != 0) return -1;
if (present_slot(l, idx, r, color, 0) != 0) return -1;
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
if (!l->first_logged) {
l->first_logged = 1;
@@ -908,7 +909,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
int idx = take_free_slot(l, rw, rh, AHB_RGBA8, &wait_fd);
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, NULL) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (!l->first_logged) {
l->first_logged = 1;
banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
@@ -950,7 +951,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
}
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, tm ? NULL : color) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (color && color->dataspace)
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
static int said = -1;
@@ -1031,7 +1032,7 @@ int sc_layer_overlay_affordable(void) {
return ok;
}
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent) {
struct layer *l = layer_of(SC_LAYER_OVERLAY);
if (!src || !geo) return -1;
/* The caller decides with sc_layer_overlay_affordable() BEFORE it commits the game to a layer;
@@ -1046,7 +1047,7 @@ int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
/* The window is copied into the layer buffer 1:1; `geo` crops it and places it, so the layer
* is exactly the window's rectangle on screen and nothing else is blended anywhere. */
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, geo, NULL) != 0) return -1;
if (present_slot(l, idx, geo, NULL, translucent) != 0) return -1;
if (!l->first_logged) {
l->first_logged = 1;
log_layer_count();
+2 -1
View File
@@ -150,8 +150,9 @@ int sc_layer_overlay_affordable(void);
/* OVERLAY layer: show `src` (one window's imported frame) above the game layer, at the placement
* `geo` = {src x0,y0,x1,y1 in image pixels, dst x0,y0,x1,y1 in output pixels} from vkp_map_draw.
* `translucent` preserves a client's alpha over the game for Steam notifications and overlays.
* 0 = shown or dropped, -1 = unavailable (the caller must fall back to the copy path). */
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]);
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
/* The scene is not a single fullscreen window this frame: hide every layer that is up. */
void sc_layer_hide(void);
+65 -23
View File
@@ -7,6 +7,7 @@
#include "effects_chain.h"
#include "framegen_bridge.h"
#include "hdr_compose.h"
#include "blend_pass.h"
#include "banner_color.h"
#include <stdio.h>
#include <stdlib.h>
@@ -32,6 +33,8 @@ struct vkp_image {
void *map; /* shm images: persistently mapped linear memory */
VkDeviceSize offset, row_pitch;
int in_general; /* shm images: moved from PREINITIALIZED to GENERAL */
VkFormat fmt;
int sampled; /* created with SAMPLED usage: the alpha pass can read it */
};
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
@@ -230,6 +233,7 @@ static void destroy_swapchain(void) {
if (g_dev_state != 1) return;
g_vk.DeviceWaitIdle(g_dev);
reset_sync();
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
g_swapchain = VK_NULL_HANDLE;
g_swap_md_identity = 0; /* a new swapchain gets the metadata again */
@@ -525,6 +529,7 @@ static int dev_init(void) {
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
hdrc_bind_device(g_dev, &g_memprops);
blendp_bind_device(g_dev);
g_dev_state = 1;
reset_sync();
init_black_image();
@@ -873,9 +878,8 @@ const char *vkp_modifier_name(uint64_t modifier) {
return other;
}
/* Can the driver create the image vkp_image_import_dmabuf() creates for this format+modifier
* (2D, DRM_FORMAT_MODIFIER tiling, TRANSFER_SRC, dma-buf memory) - and import it? */
static int modifier_importable(VkFormat fmt, uint64_t modifier) {
/* Can the driver create and import this format/modifier with the requested image usage? */
static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlags usage) {
if (!g_vk.GetPhysicalDeviceImageFormatProperties2) return 1; /* can't ask; the create will tell */
VkPhysicalDeviceExternalImageFormatInfo ext = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO,
@@ -886,7 +890,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier) {
VkPhysicalDeviceImageFormatInfo2 info = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, .pNext = &mod,
.format = fmt, .type = VK_IMAGE_TYPE_2D, .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT};
.usage = usage};
VkExternalImageFormatProperties extp = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES};
VkImageFormatProperties2 props = {.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, .pNext = &extp};
if (g_vk.GetPhysicalDeviceImageFormatProperties2(g_pd, &info, &props) != VK_SUCCESS) return 0;
@@ -914,7 +918,7 @@ int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
if (m != VKP_MOD_LINEAR && m != VKP_MOD_QCOM_COMPRESSED) why = "unknown layout";
else if (props[i].drmFormatModifierPlaneCount != 1) why = "not single-plane";
else if (!(props[i].drmFormatModifierTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) why = "no blit source";
else if (!modifier_importable(fmt, m)) why = "not importable as a dma-buf";
else if (!modifier_importable(fmt, m, VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) why = "not importable as a dma-buf";
if (why) {
LOGI("dmabuf: %c%c%c%c %s reported by the driver but not advertised: %s",
drm_format & 0xff, (drm_format >> 8) & 0xff, (drm_format >> 16) & 0xff,
@@ -942,6 +946,10 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = w; img->h = h; img->dmabuf = 1; img->blit_dst = as_blit_dst ? 1 : 0;
img->fmt = drm_to_vk(drm_format);
const VkImageUsageFlags sampled_usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
img->sampled = !as_blit_dst && g_vk.GetPhysicalDeviceImageFormatProperties2 &&
modifier_importable(img->fmt, modifier, sampled_usage);
VkSubresourceLayout plane = {.offset = offset, .rowPitch = stride};
VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {
@@ -952,10 +960,11 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT};
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &extImg,
.imageType = VK_IMAGE_TYPE_2D, .format = drm_to_vk(drm_format), .extent = {w, h, 1},
.imageType = VK_IMAGE_TYPE_2D, .format = img->fmt, .extent = {w, h, 1},
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT
: img->sampled ? sampled_usage : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
if (cr != VK_SUCCESS) {
@@ -1008,18 +1017,35 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
return img;
}
/* Linear wl_shm images can use the blend pass only if the driver samples them with a linear filter. */
static int shm_sampled(void) {
static int known;
if (!known) {
const VkFormatFeatureFlags need = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT |
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
VkFormatProperties2 fp = {.sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2};
if (g_vk.GetPhysicalDeviceFormatProperties2)
g_vk.GetPhysicalDeviceFormatProperties2(g_pd, VK_FORMAT_B8G8R8A8_UNORM, &fp);
known = (fp.formatProperties.linearTilingFeatures & need) == need ? 1 : -1;
}
return known == 1;
}
struct vkp_image *vkp_image_create_shm(int w, int h) {
if (w <= 0 || h <= 0 || dev_init() != 0) return NULL;
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = w; img->h = h;
img->fmt = VK_FORMAT_B8G8R8A8_UNORM;
img->sampled = shm_sampled();
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
.format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
.format = img->fmt, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | (img->sampled ? VK_IMAGE_USAGE_SAMPLED_BIT : 0),
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_PREINITIALIZED};
if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return NULL; }
@@ -1062,6 +1088,7 @@ int vkp_image_height(const struct vkp_image *img) { return img ? img->h : 0; }
void vkp_image_destroy(struct vkp_image *img) {
if (!img) return;
if (g_dev) {
blendp_forget_image(img->image);
if (img->map) g_vk.UnmapMemory(g_dev, img->mem);
if (img->image) g_vk.DestroyImage(g_dev, img->image, NULL);
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
@@ -1111,8 +1138,21 @@ static int draw_to_scene_blit(const struct vkp_draw *d, int scene_w, int scene_h
return map_draw(d, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, (float)scene_w, (float)scene_h, blit);
}
/* A translucent draw needs source-over blending; every other draw keeps the existing blit path. */
static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkImageBlit *blit, VkImage target,
VkFormat target_fmt, int target_w, int target_h, VkFilter filter) {
const struct vkp_image *im = d->img;
if (d->blend && im->sampled &&
blendp_draw(cmd, im->image, im->fmt, im->w, im->h, !im->dmabuf, target, target_fmt, target_w, target_h,
blit) == 0)
return;
g_vk.CmdBlitImage(cmd, im->image, im->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, blit, filter);
}
static void destroy_scene_image(void) {
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
if (g_scene.img) blendp_forget_image(g_scene.img);
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
if (g_scene.mem) g_vk.FreeMemory(g_dev, g_scene.mem, NULL);
memset(&g_scene, 0, sizeof(g_scene));
@@ -1150,6 +1190,7 @@ static int ensure_scene_image(int w, int h) {
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
if (s->img) hdrc_forget_image(s->img);
if (s->img) blendp_forget_image(s->img);
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
memset(s, 0, sizeof(*s));
@@ -1190,7 +1231,8 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
int scene_w, int scene_h, VkImage out, VkFormat out_fmt, enum hdrc_mode mode) {
if (!hf || !hf->is_hdr || n <= 0) return -1;
if (ensure_img(&g_mixed, scene_w, scene_h, HDR_FMT,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
"HDR composition (mixed)") != 0)
return -1;
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
@@ -1214,9 +1256,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
VkImageBlit blit;
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
/* A blit converts UNORM to UNORM by value: 8-bit sRGB and 10-bit PQ both land unchanged. */
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
g_mixed.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
record_draw(cmd, &draws[i], &blit, g_mixed.img, HDR_FMT, scene_w, scene_h, VK_FILTER_LINEAR);
if (hf->is_hdr[i] && lowest_hdr < 0) lowest_hdr = i;
}
static int truncated_said;
@@ -1249,6 +1289,7 @@ static void device_lost(const char *where) {
vkp_framegen_device_lost();
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
"restart the session", where);
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
g_swapchain = VK_NULL_HANDLE;
if (g_surface) g_vk.DestroySurfaceKHR(g_inst, g_surface, NULL);
@@ -1547,6 +1588,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
g_vk.BeginCommandBuffer(cmd, &bi);
blendp_begin_frame();
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Source images: take dmabufs from the client's queue family, move shm images to
@@ -1589,8 +1631,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
free(bars);
/* Clear the composition target to black (the letterbox on the direct path, the desktop's
* background on the pass) before the draws land on it. A draw that covers the whole scene
* overwrites every pixel of its rectangle (blits never blend), so then only what lies outside it
* background on the pass) before the draws land on it. An opaque draw that covers the whole scene
* overwrites every pixel of its rectangle, so then only what lies outside it
* needs black: the letterbox bars on the direct path, nothing at all on the pass (the scene image
* is exactly the scene). Anything else - no such draw, the HDR composition - clears the whole
* target as before. */
@@ -1600,7 +1642,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
if (!(hf && pass)) {
for (int i = n - 1; i >= 0 && !covered; i--) {
const struct vkp_draw *d = &draws[i];
if (!d->img || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w || d->dy + d->dh < scene_h) continue;
if (!d->img || d->blend || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w ||
d->dy + d->dh < scene_h) continue;
covered = pass ? draw_to_scene_blit(d, scene_w, scene_h, &cover_blit) : draw_to_blit(d, &cover_blit);
}
}
@@ -1633,10 +1676,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageBlit blit;
if (!draws[i].img) continue;
if (pass ? !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit) : !draw_to_blit(&draws[i], &blit)) continue;
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit,
pass ? VK_FILTER_LINEAR : blit_filter);
record_draw(cmd, &draws[i], &blit, target, pass ? scene_fmt : g_swap_fmt,
pass ? scene_w : (int)g_extent.width, pass ? scene_h : (int)g_extent.height,
pass ? VK_FILTER_LINEAR : blit_filter);
drawn++;
}
@@ -2073,6 +2115,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
g_vk.BeginCommandBuffer(cmd, &bi);
blendp_begin_frame();
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Same acquisition as vkp_render's compositor pass: the sources come from the client's queue
@@ -2132,9 +2175,8 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
for (int i = 0; i < n; i++) {
VkImageBlit blit;
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
g_scene.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
record_draw(cmd, &draws[i], &blit, g_scene.img, SCENE_FMT, scene_w, scene_h,
VK_FILTER_LINEAR);
}
if (vkp_effects_active()) vkp_effects_set_formats(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM);
}
@@ -79,6 +79,7 @@ struct vkp_draw {
struct vkp_image *img;
float sx, sy, sw, sh;
int dx, dy, dw, dh;
int blend; /* translucent: composed over what is under it (blend_pass.h) */
};
// The GPU the renderer runs on ("Adreno (TM) 750"), empty before the device is up.
@@ -0,0 +1,334 @@
/*
* zwp_linux_dmabuf_v1: the buffer params a client builds a dma-buf wl_buffer from, the format and
* modifier table the renderer can import, and dma-buf feedback (version 4). Split out of
* compositor.c; the buffer records themselves are in compositor_internal.h.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <wayland-server.h>
#include "linux-dmabuf-v1-server-protocol.h"
#include "vk_present.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "compositor_internal.h"
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
dmabuf_buffer_unref(wl_resource_get_user_data(r));
}
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (plane >= MAX_PLANES) { close(fd); return; }
if (p->fd[plane] >= 0) close(p->fd[plane]);
p->fd[plane] = fd;
p->offset[plane] = offset;
p->stride[plane] = stride;
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
}
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
if (!b) return NULL;
b->n_planes = p->n_planes;
b->refs = 1; /* the wl_buffer resource's */
b->width = w; b->height = h; b->format = format;
b->modifier = p->modifier[0];
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
for (int i = 0; i < p->n_planes; i++) {
b->fd[i] = p->fd[i];
b->offset[i] = p->offset[i];
b->stride[i] = p->stride[i];
p->fd[i] = -1; /* ownership moves to the buffer */
}
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!buf) {
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
free(b);
wl_client_post_no_memory(c);
return NULL;
}
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
struct client_info *ci = client_info_of(c);
if (ci) ci->dmabuf_buffers++;
return buf;
}
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
uint32_t format, uint32_t flags) {
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
else zwp_linux_buffer_params_v1_send_failed(r);
}
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
params_do_create(c, r, buffer_id, w, h, format, flags);
}
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
.destroy = params_destroy,
.add = params_add,
.create = params_create,
.create_immed = params_create_immed,
};
static void params_resource_destroy(struct wl_resource *r) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (!p) return;
for (int i = 0; i < MAX_PLANES; i++)
if (p->fd[i] >= 0) close(p->fd[i]);
free(p);
}
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct dmabuf_params *p = calloc(1, sizeof(*p));
if (!p) { wl_client_post_no_memory(c); return; }
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
wl_resource_get_version(r), id);
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(pr, &params_impl, p, params_resource_destroy);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface);
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
.destroy = dmabuf_destroy,
.create_params = dmabuf_create_params,
.get_default_feedback = dmabuf_get_default_feedback,
.get_surface_feedback = dmabuf_get_surface_feedback,
};
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
* takes the first format it sees - gets exactly the table it always had. */
#define DMABUF_NFMT_MAX 6
#define DMABUF_NFMT_SDR 4
#define DMABUF_NMOD 4
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
static int g_dmabuf_fmts_ready;
static void dmabuf_build_feedback(void);
static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
* we advertised before this table existed. */
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
if (got[i] == MOD_LINEAR) continue;
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
g_dmabuf_fmts[f].mods[k++] = got[i];
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
}
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
g_dmabuf_fmts[f].n = k;
uint32_t fmt = g_dmabuf_fmts[f].fmt;
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
}
dmabuf_build_feedback();
}
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
*
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
*
* What a client needs from us is one tranche describing "everything the compositor can import":
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
#ifndef F_ADD_SEALS
#define F_ADD_SEALS 1033
#define F_SEAL_SEAL 0x0001
#define F_SEAL_SHRINK 0x0002
#define F_SEAL_GROW 0x0004
#define F_SEAL_WRITE 0x0008
#endif
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
static size_t g_fmt_table_size;
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
dev_t g_main_device; /* the render node clients should allocate on */
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
static dev_t dmabuf_render_node(void) {
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
struct stat st;
if (g_no_render_node) return 0;
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
return 0;
}
static void dmabuf_build_feedback(void) {
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
int n = 0;
g_main_device = dmabuf_render_node();
for (int f = 0; f < g_dmabuf_nfmt; f++)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
entries[n].format = g_dmabuf_fmts[f].fmt;
entries[n].pad = 0;
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
n++;
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
if (write(fd, entries, size) != (ssize_t)size) {
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
close(fd);
return;
}
/* The client mmaps this read-only and trusts it not to change under it. */
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
/* One tranche: our device, every pair in the table, no scanout flag. */
static void dmabuf_feedback_send(struct wl_resource *fb) {
struct wl_array dev, idx;
uint16_t *ind;
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
wl_array_init(&dev);
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
wl_array_release(&dev);
wl_array_init(&idx);
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
wl_array_release(&idx);
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
zwp_linux_dmabuf_feedback_v1_send_done(fb);
}
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
.destroy = dmabuf_feedback_destroy,
};
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
wl_resource_get_version(parent), id);
if (!fb) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
dmabuf_feedback_send(fb);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct client_info *ci = client_info_of(c);
if (ci) ci->asked_feedback = 1;
dmabuf_new_feedback(c, r, id);
}
/* Per-surface feedback would let us hint a different tranche for a window on its own display
* layer; we have nothing better to say per surface, so it is the default one. */
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface) {
dmabuf_new_feedback(c, r, id);
}
void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
/* From version 4 the format and modifier events are gone: the client asks for feedback
* instead, and sending both would only confuse it about which list is authoritative. */
if (ver >= 4) return;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
if (ver >= 3)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
}
}
@@ -0,0 +1,84 @@
package com.droiddeck.launcher
import android.os.Handler
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentsManager
/**
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
*/
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
var compComp by mutableStateOf("dxvk")
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
fun refreshComponents(snapshotFirst: Boolean = false) {
Thread({
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(activity) }
runCatching { ComponentsManager.applyQueued(activity) }
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
}
}, "components").start()
}
fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compBusy = null
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-action").start()
}
fun refreshComponentCatalog() {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
}
fun downloadComponent(item: ComponentsManager.CatalogItem) {
if (compDownloads.containsKey(item.file)) return
compDownloads = compDownloads + (item.file to -1)
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
}, "components-download").start()
}
private companion object {
const val TAG = "MainActivity"
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionState
/**
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
* in progress. The launcher screen keeps one and shows it.
*/
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
var deckySupervisor by mutableStateOf(false)
var deckyReleaseRequest = 0
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
// Use the newest compatible stable build, or the newest compatible
// prerelease when the fork has not published a stable ARM64 asset.
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-decky").start()
}
}
@@ -0,0 +1,217 @@
package com.droiddeck.launcher
import android.app.Activity
import android.content.Context
import java.io.File
import android.net.Uri
import android.provider.OpenableColumns
import android.os.Handler
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.DriverRow
/**
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
* rows to the mode settings dialog.
*/
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
var releaseChecking by mutableStateOf(false)
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
var linuxSteam by mutableStateOf("")
var linuxDesktop by mutableStateOf("")
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
).joinToString(" · "),
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
val td = TurnipDriver(activity)
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(activity)
refreshReleaseRows()
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
*/
fun importDriver(uri: Uri, linux: Boolean) {
val name = activity.displayNameOf(uri)
Thread({
val problem = try {
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
else TurnipDriver(activity).installFromZip(uri, name)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
}
ui.post {
android.widget.Toast.makeText(
activity, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
fun deleteDriver(id: String, linux: Boolean) {
if (linux) {
LinuxVulkanDriverManager(activity).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
}
} else {
val td = TurnipDriver(activity)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
}
TurnipReleases.forget(activity, id)
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
/** The download entries and the refresh line, from what the last check found. */
fun refreshReleaseRows() {
val check = TurnipReleases.cached(activity)
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
}
}
/** Only when the user taps refresh: nothing goes online on its own. */
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
}
ui.post {
releaseChecking = false
refreshReleaseRows()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
}
/** Download one release driver and import it through the same importer a picked zip uses. */
fun downloadReleaseDriver(assetName: String) {
val asset = TurnipReleases.cached(activity)?.assets?.firstOrNull { it.name == assetName } ?: return
if (releaseProgress.containsKey(assetName)) return
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(activity, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else TurnipDriver(activity).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(activity, asset, id)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
}
}, "download-turnip").start()
}
private companion object {
const val TAG = "MainActivity"
}
}
internal fun Context.displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
} catch (e: Exception) {
null
}
@@ -97,13 +97,11 @@ object HomeApp {
val intent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(ComponentName(app.packageName, app.className))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (displayId == null) {
context.startActivity(intent)
return
}
// Keep the target app's task separate from DroidDeck. Without this, Android can put the
// app on top of the caller's task and move the entire task to the chosen display.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
context.startActivity(intent, options.toBundle())
}
@@ -9,12 +9,13 @@ import java.io.File
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.net.Uri
import android.provider.OpenableColumns
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -23,11 +24,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
@@ -37,20 +36,21 @@ import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.ConfirmDialog
import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
@@ -78,9 +78,20 @@ import com.droiddeck.launcher.input.SecondScreenDisplays
*/
class MainActivity : ComponentActivity() {
private val ui = Handler(Looper.getMainLooper())
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val protons = ProtonMenu(this, ui)
// The screen's state. Compose redraws whatever reads these when they change.
private var installed by mutableStateOf<String?>(null)
/** A session launch waiting on its button to fill the page (LaunchFlood). */
private var flood by mutableStateOf<PendingFlood?>(null)
private var floodProgress by androidx.compose.runtime.mutableFloatStateOf(0f)
private class PendingFlood(val from: androidx.compose.ui.geometry.Rect, val intent: Intent)
/** A session stopped behind a flood: its blue, drawn back into the button it started from (FloodReturn). */
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
@@ -95,13 +106,9 @@ class MainActivity : ComponentActivity() {
private var steamDeckMode by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
private var deckyInstalled by mutableStateOf<String?>(null)
private var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
private var deckyChecking by mutableStateOf(false)
private var deckyStage by mutableStateOf<String?>(null)
private var deckyPercent by mutableIntStateOf(-1)
private var deckySupervisor by mutableStateOf(false)
private var deckyReleaseRequest = 0
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var showMapping by mutableStateOf(false)
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
@@ -115,10 +122,6 @@ class MainActivity : ComponentActivity() {
private var desktopEnvironment by mutableStateOf(SessionPrefs.DESKTOP_ENV_LXQT)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
private var protonBusyId by mutableStateOf<String?>(null)
private var protonStage by mutableStateOf<String?>(null)
private var protonPercent by mutableIntStateOf(-1)
private var showPerformance by mutableStateOf(false)
private var clientOverride by mutableStateOf(false)
private var clientCores by mutableStateOf<Set<Int>>(emptySet())
@@ -127,34 +130,26 @@ class MainActivity : ComponentActivity() {
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var clientDirectAudio by mutableStateOf(false)
private var forceFullscreen by mutableStateOf(true)
private var launcherFullscreen by mutableStateOf(true)
private var mic by mutableStateOf(false)
private var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
private var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
private var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
private var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
private var releaseChecking by mutableStateOf(false)
private var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
private var linuxSteam by mutableStateOf("")
private var linuxDesktop by mutableStateOf("")
private var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
private var androidSelected by mutableStateOf("")
// The app's own picker (files/), once per kind of pick: the two driver lists validate
// differently, and the reason a zip is refused names the list it belongs in.
private val pickComponent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) }
}
private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = true) }
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = true) }
}
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = false) }
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
@@ -191,7 +186,8 @@ class MainActivity : ComponentActivity() {
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var theme by mutableStateOf("paper")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var hdrReason by mutableStateOf<String?>(null)
@@ -231,7 +227,7 @@ class MainActivity : ComponentActivity() {
override fun startActivity(intent: Intent?) {
if (intent?.component?.className == SessionActivity::class.java.name) {
when {
protonBusyId != null || ProtonExtras.installInProgress -> {
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
return
}
@@ -241,16 +237,36 @@ class MainActivity : ComponentActivity() {
}
}
}
if (intent?.component?.className == SessionActivity::class.java.name) {
if (flood != null) return
// Started by a blue button: it floods the page first. A running session is re-joined
// as before, rising over the front end.
val from = com.droiddeck.launcher.ui.LaunchOrigin.take()
if (from != null && !SessionState.running && com.droiddeck.launcher.ui.Motion.scale != 0f) {
flood = PendingFlood(from, intent)
return
}
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
super.startActivity(intent)
if (intent?.component?.className == SessionActivity::class.java.name) overridePendingTransition(R.anim.session_rise, R.anim.session_hold)
}
/** The page is all blue: the session opens on the same blue, with no animation of its own. */
private fun launchFlooded(f: PendingFlood) {
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
overridePendingTransition(0, 0)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
applyLauncherFullscreen()
setContent {
DroidDeckTheme(theme) {
val sm = settingsMode
@@ -258,9 +274,11 @@ class MainActivity : ComponentActivity() {
sm != null -> { { ModeSettingsHost(sm) } }
showPerformance -> { { PerformanceHost() } }
showProtons -> { { ProtonHost() } }
showComponents -> { { ComponentsHost() } }
showMapping -> { { MappingHost() } }
else -> null
}
com.droiddeck.launcher.ui.FloodBehind({ floodProgress }) {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version,
@@ -271,7 +289,7 @@ class MainActivity : ComponentActivity() {
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showMapping) "controller-mapping" else null,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
@@ -290,9 +308,10 @@ class MainActivity : ComponentActivity() {
controller = controllerSettings,
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
),
FrontEndActions(
onPlay = { startSession(Intent(this, SessionActivity::class.java), steamSession = true) },
onPlay = { startSteamSession() },
// Steam's desktop client as a window on the desktop: under gamescope the
// client puts itself into Big Picture whatever it is started with.
onPlayDesktopUi = {
@@ -328,9 +347,14 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
},
onProtons = { openProtons() },
onPerformance = { refreshCores(); showProtons = false; showMapping = false; showPerformance = true },
onComponents = { focusContent -> openComponents(focusContent) },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)
.putExtra(com.droiddeck.launcher.files.FileManagerActivity.EXTRA_START_DIR, dir.absolutePath))
},
onLogs = {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
@@ -349,8 +373,13 @@ class MainActivity : ComponentActivity() {
offline = OfflineMode.enabled(this)
},
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = false },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
applyLauncherFullscreen()
},
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -371,7 +400,7 @@ class MainActivity : ComponentActivity() {
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@MainActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB pairing failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging pairing failed" }
ui.post { complete(error) }
}, "wireless-adb-pair").start()
},
@@ -384,7 +413,7 @@ class MainActivity : ComponentActivity() {
onWirelessAdbApply = { host, port, enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@MainActivity, host, port, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB command failed" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging command failed" }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -394,7 +423,7 @@ class MainActivity : ComponentActivity() {
onSetPhantomProcessLimit = { enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@MainActivity, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless ADB is unavailable" }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: "Wireless debugging is unavailable" }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
@@ -408,6 +437,12 @@ class MainActivity : ComponentActivity() {
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
@@ -417,12 +452,13 @@ class MainActivity : ComponentActivity() {
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showMapping = true },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
page = page,
)
}
if (showRoms) RomsDialog(
path = romsDir,
onChoose = {
@@ -449,9 +485,19 @@ class MainActivity : ComponentActivity() {
onDismiss = { showRemove = false },
)
if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
}
}
}
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
// The session's logs land in Downloads so a failed run can be handed over as a folder
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
// grants exactly that.
@@ -474,17 +520,77 @@ class MainActivity : ComponentActivity() {
}, "finish-abandoned").start()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
startSteamAtStartupIfEnabled()
}
private fun startSteamAtStartupIfEnabled() {
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
private fun startSteamSession() {
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) applyLauncherFullscreen()
}
private fun applyLauncherFullscreen() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = if (launcherFullscreen) {
(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
} else {
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}
}
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
}
return true
}
return super.dispatchKeyEvent(event)
}
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (showComponents) components.refreshComponents()
}
}, "components-queue").start()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
refreshSecondScreenDisplays()
refresh()
deckyInstalled = DeckyManager.installed(this)
decky.deckyInstalled = DeckyManager.installed(this)
DeckyManager.syncCefMarker(this)
deckySupervisor = DeckyManager.supervisorEnabled(this)
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
// Added games' art (a store lookup for what the folders lack) starts here, not only when
// the cog opens.
refreshAddedGames()
@@ -500,6 +606,11 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
super.onStop()
}
@@ -550,40 +661,10 @@ class MainActivity : ComponentActivity() {
private fun openProtons() {
settingsMode = null
showPerformance = false
showComponents = false
showMapping = false
showProtons = true
refreshProtons()
}
private fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(this) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
// Use the newest compatible stable build, or the newest compatible
// prerelease when the fork has not published a stable ARM64 asset.
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
private fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(this, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(this)
if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-decky").start()
protons.refreshProtons()
}
private fun refreshController() {
@@ -601,57 +682,65 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true) {
focusComponentsContent = focusContent
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
components.refreshComponents(snapshotFirst = true)
}
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction("Importing") {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
}
}
@Composable
private fun ProtonHost() {
ProtonPage(
rows = protonRows,
busyId = protonBusyId,
stage = protonStage,
percent = protonPercent,
runtimeReady = ready,
sessionRunning = SessionState.running,
onInstall = { id -> installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; refreshProtons() },
onRemove = { id -> removeProton(id) },
onBack = { showProtons = false },
private fun ComponentsHost() {
ComponentsPage(
snapshot = components.compSnapshot,
catalog = components.compCatalog,
catalogAt = components.compCatalogAt,
protonId = components.compProton,
comp = components.compComp,
checking = components.compChecking,
busy = components.compBusy,
downloads = components.compDownloads,
requestInitialFocus = focusComponentsContent,
onProton = { components.compProton = it },
onComp = { components.compComp = it },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> components.componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
)
}
private fun installProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(this, tool)
protonBusyId = id
protonStage = "Starting…"
protonPercent = -1
Thread({
val problem = ProtonExtras.install(this, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-proton-$id").start()
}
private fun removeProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonPercent = -1
Thread({
ProtonExtras.remove(this, tool)
ui.post {
protonBusyId = null
protonStage = null
refreshProtons()
}
}, "remove-proton-$id").start()
@Composable
private fun ProtonHost() {
ProtonPage(
rows = protons.protonRows,
busyId = protons.protonBusyId,
stage = protons.protonStage,
percent = protons.protonPercent,
runtimeReady = ready,
sessionRunning = SessionState.running,
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id -> protons.removeProton(id) },
onBack = { showProtons = false },
)
}
private fun refreshPackages() {
@@ -702,9 +791,9 @@ class MainActivity : ComponentActivity() {
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
linuxRows = linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop,
androidRows = androidRows, androidSelected = androidSelected,
linuxRows = drivers.linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
touchMode = touchMode,
suspendPolicy = suspendPolicy,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
@@ -720,30 +809,32 @@ class MainActivity : ComponentActivity() {
storageOptions = storageOptions,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus,
releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) deckyReleases.firstOrNull() else null,
deckyChecking = deckyChecking, deckyStage = deckyStage, deckyPercent = deckyPercent,
deckyEnabled = deckySupervisor, deckySessionRunning = SessionState.running,
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
onRemoveLinux = { id -> deleteDriver(id, linux = true) },
onRefreshReleases = { checkLatestTurnip() },
onDownloadDriver = { name -> downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); refreshDrivers() },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRefreshReleases = { drivers.checkLatestTurnip() },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> deleteDriver(id, linux = false) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -774,6 +865,15 @@ class MainActivity : ComponentActivity() {
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose a folder of your own games", addedGamesDirs.lastOrNull())) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
@@ -782,13 +882,13 @@ class MainActivity : ComponentActivity() {
pendingAddedGame = folder
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
},
onDeckyInstall = { release -> installDecky(release) },
onDeckyCheck = { refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); deckySupervisor = enabled },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
deckyInstalled = null
deckySupervisor = false
decky.deckyInstalled = null
decky.deckySupervisor = false
},
onDismiss = { settingsMode = null },
),
@@ -800,16 +900,16 @@ class MainActivity : ComponentActivity() {
PerformancePage(
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, steamDeckMode = steamDeckMode, noXalia = noXalia,
prootNoSeccomp = prootNoSeccomp, phantomWarning = phantomWarning,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
prootNoSeccomp = prootNoSeccomp, 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 },
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on); steamDeckMode = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
SessionPrefs.setClientCpus(this, CpuCores.format(clientCores))
@@ -845,12 +945,14 @@ class MainActivity : ComponentActivity() {
private fun openModeSettings(mode: String) {
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
refreshDrivers()
drivers.refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
shapeMode = SessionPrefs.shapeMode(this)
@@ -868,9 +970,9 @@ class MainActivity : ComponentActivity() {
gameStorage = SessionPrefs.gameStorage(this)
storageOptions = GameStorage.options(this).map { it.label to it.path }
if (mode == SessionService.MODE_STEAM) {
deckyInstalled = DeckyManager.installed(this)
deckySupervisor = DeckyManager.supervisorEnabled(this)
refreshDecky()
decky.deckyInstalled = DeckyManager.installed(this)
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
decky.refreshDecky()
}
settingsMode = mode
}
@@ -888,178 +990,6 @@ class MainActivity : ComponentActivity() {
gameStorage = path
}
private fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(this)
fun origin(id: String) = if (TurnipReleases.isDownloaded(this, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
linuxRows = LinuxVulkanDriver.optionValues(this).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
).joinToString(" · "),
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(this, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(this, SessionService.MODE_DESKTOP)
val td = TurnipDriver(this)
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(this)
refreshReleaseRows()
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
*/
private fun importDriver(uri: Uri, linux: Boolean) {
val name = displayNameOf(uri)
Thread({
val problem = try {
if (linux) LinuxVulkanDriverManager(this).installDriver(uri, name)
else TurnipDriver(this).installFromZip(uri, name)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
}
ui.post {
android.widget.Toast.makeText(
this, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
private fun deleteDriver(id: String, linux: Boolean) {
if (linux) {
LinuxVulkanDriverManager(this).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(this, mode) == id) SessionPrefs.setLinuxDriver(this, mode, "")
}
} else {
val td = TurnipDriver(this)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
if (SessionPrefs.androidDriver(this) == id) SessionPrefs.setAndroidDriver(this, TurnipDriver.AUTO)
}
TurnipReleases.forget(this, id)
android.widget.Toast.makeText(this, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
/** The download entries and the refresh line, from what the last check found. */
private fun refreshReleaseRows() {
val check = TurnipReleases.cached(this)
val lm = LinuxVulkanDriverManager(this)
val td = TurnipDriver(this)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(this, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
}
}
/** Only when the user taps refresh: nothing goes online on its own. */
private fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
Thread({
val problem = try { TurnipReleases.refresh(this); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
}
ui.post {
releaseChecking = false
refreshReleaseRows()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
}
/** Download one release driver and import it through the same importer a picked zip uses. */
private fun downloadReleaseDriver(assetName: String) {
val asset = TurnipReleases.cached(this)?.assets?.firstOrNull { it.name == assetName } ?: return
if (releaseProgress.containsKey(assetName)) return
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(this, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(this).installDriver(uri, asset.name)
else TurnipDriver(this).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(this, asset, id)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
android.widget.Toast.makeText(
this, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
}
}, "download-turnip").start()
}
private fun displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
} catch (e: Exception) {
null
}
/** The two masks as the dialog shows them; an empty stored list shows as every core ticked. */
private fun refreshCores() {
clientOverride = SessionPrefs.clientCpusOverride(this)
@@ -1072,14 +1002,12 @@ class MainActivity : ComponentActivity() {
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
}
private fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(this, it), ProtonExtras.queued(this, it)) }
}
private fun refresh() {
if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
plasmaMobileInstalled = DesktopCatalog.plasmaMobileInstalled(this)
desktopEnvironment = SessionPrefs.desktopEnvironment(this)
@@ -1098,10 +1026,16 @@ class MainActivity : ComponentActivity() {
// The libraries, off the main thread: manifests and a folder scan.
Thread({
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art -> Library.SteamGame(g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId) }
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
Library.SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
)
}
} else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { steamGames = games; emulatorList = emus }
ui.post { steamGames = games.distinctBy { it.gameId }; 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 })) {
@@ -1143,15 +1077,18 @@ class MainActivity : ComponentActivity() {
}
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(contentResolver)
phantomProcessStatus = PhantomProcessLimit.read(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.ADB_COMMAND}"
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
} else null
}
private fun openDeveloperOptions(displayId: Int?) {
val highlight = if (PhantomProcessLimit.hasDeveloperToggle() &&
WirelessAdbPairingService.stage.value == WirelessAdbPairingService.Stage.Idle) null else "toggle_adb_wireless"
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.apply { if (highlight != null) putExtra(":settings:fragment_args_key", highlight) }
try {
if (displayId == null) startActivity(intent)
else startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
@@ -1167,17 +1104,28 @@ class MainActivity : ComponentActivity() {
}
private fun install(release: LinuxRuntimeInstaller.Release) {
// The service keeps the process alive if the user switches away; this screen joins the
// same install (or starts it, if it gets there first) to show the progress.
com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release)
followInstall { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
}
/**
* Shows an install's progress until it ends. [run] either starts one or joins the one already
* running (null: nothing was), which is how a launcher rebuilt mid-install picks it back up.
*/
private fun followInstall(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = "Starting…"
percent = -1
Thread({
val ok = LinuxRuntimeInstaller.install(this, release) { s, p ->
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
ui.post { stage = s; percent = p }
}
})
ui.post {
busy = false
failed = !ok
if (ok != null) failed = !ok
refresh()
}
}, "install").start()
@@ -1193,5 +1141,6 @@ class MainActivity : ComponentActivity() {
private const val TAG = "MainActivity"
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
}
}
@@ -0,0 +1,62 @@
package com.droiddeck.launcher
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.session.SessionState
/**
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
* removal in progress. The launcher screen keeps one and shows it.
*/
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(null)
var protonPercent by mutableIntStateOf(-1)
fun installProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = "Starting…"
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-proton-$id").start()
}
fun removeProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonPercent = -1
Thread({
ProtonExtras.remove(activity, tool)
ui.post {
protonBusyId = null
protonStage = null
refreshProtons()
}
}, "remove-proton-$id").start()
}
fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
}
}
@@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -70,6 +72,8 @@ import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import kotlin.math.abs
/**
@@ -96,6 +100,8 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
private var cursorVisible by mutableStateOf(false)
/** The blue the front end flooded to on the way in; the loading screen opens on it (LaunchFlood). */
private var floodColor by mutableStateOf<Int?>(null)
private val cursorHide = Runnable { cursorVisible = false }
/** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */
private var lastPadInputMs = 0L
@@ -141,6 +147,12 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
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
/** 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)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
@@ -229,7 +241,7 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
root.pointerIcon = noCursor
surfaceView.pointerIcon = noCursor
loading = LoadingState(this)
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val requestedMode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
@@ -241,10 +253,12 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.desktopEnvironment(this) == SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE)
val plasmaInstalled = com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this)
val needPlasma = wantsPlasma && !plasmaInstalled && requestedMode == SessionService.MODE_DESKTOP
val needProton = requestedMode == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (requestedMode == SessionService.MODE_PLASMA_MOBILE && (!plasmaInstalled || needDesktop || needRuntime)) {
loading.showEnded("Open Desktop with KDE Plasma Mobile once before selecting it for the second screen.")
} else if (needRuntime || needDesktop || needPlasma) {
installThenStart(needRuntime, needDesktop, needPlasma)
} else if (needRuntime || needDesktop || needPlasma || needProton) {
installThenStart(needRuntime, needDesktop, needPlasma, needProton)
}
}
hud = PerfHud(this)
@@ -258,124 +272,18 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
readPrefs()
// One Compose layer for every menu and overlay. Touches nothing in it consumes fall
// through to the pad and the game underneath.
sessionOverlay = ComposeView(this).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
} else {
releaseDrawerDirection()
}
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
directTouchOnly = SessionProcess.isExternalDisplay(this@SessionActivity),
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
plasmaMobileInstalled = com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this@SessionActivity) &&
com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this@SessionActivity),
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app, displayId ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
))
if (SessionState.suspended) SessionPausedOverlay {
SessionService.resume(this@SessionActivity)
}
}
}
intent.getIntExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, 0).takeIf { it != 0 && loading.visible }?.let {
floodColor = it
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
}
}
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
setContentView(root)
handleHomeGuideIntent(intent)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
WaylandCompositor.setFirstFrameListener(firstFrameListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
@@ -384,6 +292,7 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun handleOnBackPressed() {
// The PC keyboard closes first (B on a controller), then Back is the drawer again.
when {
::loading.isInitialized && loading.ended -> finish()
drawerOpen -> drawerOpen = false
pcKeyboardOpen -> pcKeyboardOpen = false
else -> routeBackAction()
@@ -393,6 +302,151 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
watchSession()
}
/**
* One Compose layer for every menu and overlay. Touches nothing in it consumes fall through to
* the pad and the game underneath.
*/
private fun createSessionOverlay(): ComposeView = ComposeView(this).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
onFlooded = {
floodColor = null
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
} else {
releaseDrawerDirection()
}
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
directTouchOnly = SessionProcess.isExternalDisplay(this@SessionActivity),
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
plasmaMobileInstalled = com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this@SessionActivity) &&
com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this@SessionActivity),
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app, displayId ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onStopFrom = { onScreen ->
drawerOpen = false
if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) {
if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() }
} else {
val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) }
quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat())
}
},
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
))
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
quitFrom?.let { from ->
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
com.droiddeck.launcher.ui.LaunchFlood(
from, onProgress = {},
fromColors = error to androidx.compose.ui.graphics.lerp(error, androidx.compose.ui.graphics.Color.Black, 0.12f),
cornerAtRest = 20.dp,
) { finishFlooded() }
}
}
}
}
private fun refreshHomeApp() {
isHomeApp = HomeApp.isDefault(this)
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
@@ -423,7 +477,8 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
performBackAction(double = false)
}
pendingBackAction = pending
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
}
private fun performBackAction(double: Boolean) {
@@ -439,6 +494,62 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
}
/** Re-reads the Protons for the drawer's Components tab, first running swaps a closed game was holding back. */
private fun refreshDrawerComponents() {
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
uiHandler.post {
drawerComponents = snap
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
}
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
Thread({
val message = runCatching {
val snap = ComponentsManager.snapshot(this)
val queued = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.queued
val active = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.activeFile
val build = snap.protons.firstOrNull { it.proton.id == protonId }?.proton?.version?.let(ComponentsManager::safeName)
val current = active ?: "orig:$build"
if (queued != null && value == current) {
// Picking what is already in place again cancels the swap waiting for the game.
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
else ComponentsManager.swap(this, protonId, value)
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
}
}, "drawer-components-swap").start()
}
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
private fun loadingMode(): String =
if (SessionState.running) SessionState.mode
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
private fun pausedTitle(): String = when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "The desktop is paused"
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
else -> "Steam is paused"
}
private fun loadingTitle(): String = when (loadingMode()) {
SessionService.MODE_DESKTOP ->
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
else "Starting the desktop"
SessionService.MODE_RUN -> {
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
}
else -> "Starting Steam"
}
private fun shareCurrentSessionLogs() {
val folder = SessionPaths.current()
if (folder == null || !folder.isDirectory) {
@@ -487,28 +598,31 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* First Play (or Desktop) on a fresh install: the runtime, and for the desktop its package,
* are downloaded and unpacked here, on the loading screen's own line and bar, and the session
* starts when they are in. Nothing else changes.
*
* The first Steam session also gets Valve's ARM64 Proton here, before the client has ever
* started, so the compatibility tool is installed and the default at sign-in with no install
* dialog, download or client restart. Not being able to fetch it stops nothing: the session
* then asks the client for it after sign-in, as it always has.
*/
private fun installThenStart(runtime: Boolean, desktop: Boolean, plasmaMobile: Boolean) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, plasmaMobile: Boolean, proton: Boolean) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = when {
runtime -> "runtime"
desktop -> "desktop"
else -> "plasma-mobile"
plasmaMobile -> "plasma-mobile"
else -> "proton"
}
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
when (SessionState.installing) {
"runtime" -> "runtime.installing"
"desktop" -> "desktop.installing"
else -> "plasma-mobile.installing"
},
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
loading.step = when (SessionState.installing) {
"runtime" -> "downloading the Linux runtime"
"desktop" -> "downloading the desktop"
else -> "installing KDE Plasma Mobile"
loading.step = when {
runtime -> "downloading the Linux runtime"
desktop -> "downloading the desktop"
plasmaMobile -> "installing KDE Plasma Mobile"
else -> "downloading Proton Experimental (ARM64)"
}
loading.percent = -1
Thread({
@@ -530,6 +644,14 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installPlasmaMobile()
if (problem == null) SessionEvents.record("plasma-mobile.ready") else failedComponent = "plasma-mobile"
}
if (problem == null && proton && !SessionState.stopRequested) {
SessionState.installing = "proton"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton"))
val protonProblem = installProton()
if (protonProblem == null) SessionEvents.record("proton.ready")
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
}
}
uiHandler.post {
installingRuntime = false
SessionState.installing = null
@@ -549,6 +671,7 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
focusEndedScreen()
return@post
}
loading.percent = -1
@@ -610,6 +733,16 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return problem?.let { "KDE Plasma Mobile did not install: $it. Press Desktop again to retry." }
}
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
private fun installProton(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
?: return "catalog unreachable"
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
}
}
override fun surfaceCreated(holder: SurfaceHolder) {
if (installingRuntime || SessionState.stopRequested) return
if (!SessionState.running) {
@@ -848,19 +981,30 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun showEnded(status: Int, hint: String?) {
run {
if (status != 0) {
loading.showEnded(
"The session ended ($status)\n${SessionState.logFile?.path ?: "-"}" +
(if (hint != null) "\n\n$hint" else "")
)
// A moment on screen, so a failure is readable rather than a flash of black;
// longer when there is advice to read.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (hint != null) 9000 else 4000)
} else {
finish()
}
if (status == 0) {
finish()
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
loading.showEnded(
hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
}
/** The ended screen's buttons take the pad: it is no longer driving a game. */
private fun focusEndedScreen() {
drawerOpen = false
padBridge?.releaseAll()
uiHandler.post { sessionOverlay.requestFocus() }
}
/** The same start again, in a fresh activity: the ended one keeps no session to reuse. */
private fun retrySession() {
val again = Intent(intent)
finish()
startActivity(again)
}
/**
@@ -908,10 +1052,26 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
}
// While the session starts, B is the loading screen's Cancel, as its corner says.
if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
SessionService.stop(this)
finish()
}
return true
}
// The ended screen is a page of buttons: the pad drives them, and B leaves.
if (::loading.isInitialized && loading.ended) {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B || event.keyCode == KeyEvent.KEYCODE_BACK) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) finish()
return true
}
return super.dispatchKeyEvent(event)
}
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else 2) % 3
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
releaseDrawerDirection()
}
return true
@@ -1439,6 +1599,7 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
@@ -1453,6 +1614,10 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onPause() {
// Do not carry transient session UI across an app/display transition. In particular, the
// drawer's dim layer can otherwise remain over Steam when this activity returns.
drawerOpen = false
pcKeyboardOpen = false
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
@@ -1492,6 +1657,13 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Ours, so a finishing activity never unhooks the one that replaced it. */
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
private val firstFrameListener = Runnable {
SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
override fun onDestroy() {
// Deliberately does NOT end the session: this activity can be destroyed while the user is
@@ -1504,20 +1676,31 @@ open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.setFirstFrameListener(null)
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
super.onDestroy()
}
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
private fun finishFlooded() {
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
SessionService.stop(this)
finish()
}
/** Leaving the session sinks its surface back down onto the front end. */
override fun finish() {
super.finish()
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
companion object {
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 BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
private const val CURSOR_PAD_HOLD_MS = 1200L
private const val DRAWER_HAT_THRESHOLD = 0.5f
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
@@ -0,0 +1,25 @@
package com.droiddeck.launcher.core;
import java.io.File;
import java.io.IOException;
/**
* Where an archive entry may land. An entry name ("../x", "/etc/x") or a symlink already written
* by an earlier entry can point outside the directory being filled; the canonical path follows
* both, so anything that does not resolve inside it is refused.
*/
public final class ArchivePaths {
private ArchivePaths() {}
/**
* The file for {@code name} under {@code destination}, or null when it would resolve outside
* it. The destination itself counts as inside (a "./" entry).
*/
public static File inside(File destination, String name) throws IOException {
String base = destination.getCanonicalPath();
File file = new File(destination, name);
String path = file.getCanonicalPath();
if (path.equals(base) || path.startsWith(base + File.separator)) return file;
return null;
}
}
@@ -152,6 +152,7 @@ object DeviceReport {
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
@@ -166,7 +167,7 @@ object DeviceReport {
})
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context.contentResolver)))
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
@@ -1,42 +1,33 @@
package com.droiddeck.launcher.core
/**
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
* session's environment; the games under FEX inherit them from the client. The default is FEX's
* own defaults - no variables at all - which is what every session has run on so far.
*
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
*/
import com.droiddeck.launcher.R
object FexPreset {
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
)
val all: List<Preset> = listOf(
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
)
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
/** Game-launch variables in KEY=VALUE form. */
fun env(id: String): List<String> = byId(id).env
}
@@ -0,0 +1,38 @@
package com.droiddeck.launcher.core
object GameEnvironment {
data class Config(
val shared: Map<String, String?> = emptyMap(),
val games: Map<String, Map<String, String?>> = emptyMap(),
) {
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
fun withEntries(scope: String, entries: Map<String, String?>): Config =
if (scope.isEmpty()) copy(shared = entries.toMap())
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
}
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val unused = setOf(
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
)
fun validName(name: String) = namePattern.matches(name)
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -0,0 +1,43 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.R
object GameEnvironmentOptions {
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
val all = listOf(
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
)
fun find(name: String): Option? = all.firstOrNull { it.name == name }
fun toggle(value: String, choice: String): String {
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
if (!selected.remove(choice)) selected.add(choice)
return selected.joinToString(",")
}
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.core
import java.io.File
import java.security.MessageDigest
/** File checksums as lowercase hex, and GitHub's release-asset digests, in one place. */
object Hashes {
private val GITHUB_SHA256 = Regex("(?i)sha256:[0-9a-f]{64}")
@JvmStatic
fun sha256(file: File): String = hex(file, "SHA-256")
@JvmStatic
fun sha512(file: File): String = hex(file, "SHA-512")
/** True when [digest] is GitHub's asset digest form, "sha256:" and 64 hex digits. */
@JvmStatic
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
/** The hex of a GitHub asset digest ("sha256:<hex>"), or null when [digest] is not one. */
@JvmStatic
fun githubSha256(digest: String?): String? = digest?.takeIf { isGithubSha256(it) }?.substringAfter(':')
private fun hex(file: File, algorithm: String): String {
val digest = MessageDigest.getInstance(algorithm)
file.inputStream().buffered(1 shl 16).use { input ->
val buffer = ByteArray(1 shl 20)
while (true) {
val n = input.read(buffer)
if (n < 0) break
digest.update(buffer, 0, n)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
}
@@ -1,6 +1,6 @@
package com.droiddeck.launcher.core
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.provider.Settings
@@ -14,27 +14,87 @@ enum class PhantomProcessStatus {
}
object PhantomProcessLimit {
const val ADB_COMMAND = "adb shell settings put global settings_enable_monitor_phantom_procs false"
const val SHELL_COMMAND = "settings put global settings_enable_monitor_phantom_procs false"
private const val SETTING = "settings_enable_monitor_phantom_procs"
fun adbCommand(enabled: Boolean): String =
"adb shell settings put global $SETTING ${if (enabled) "true" else "false"}"
private const val MAX_PHANTOM = "2147483647"
private const val PREFS = "phantom-process-limit"
private const val PREF_ANDROID_12_OFF = "android12-off"
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
usesDeviceConfig(sdk) && enabled -> listOf(
"device_config delete activity_manager max_phantom_processes",
"device_config set_sync_disabled_for_tests none",
)
usesDeviceConfig(sdk) -> listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
)
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
}
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
else "settings get global $SETTING"
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
val value = output.trim()
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
else value == if (enabled) "true" else "false"
}
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
fun adbCommand(): String = adbCommand(false)
fun rememberAndroid12(context: Context, off: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
}
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
val value = try {
Settings.Global.getString(resolver, SETTING)
if (usesDeviceConfig(sdk)) {
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
}
val global = try {
Settings.Global.getString(context.contentResolver, SETTING)
} catch (_: Exception) {
return PhantomProcessStatus.UNREADABLE
}
return when (value?.trim()?.lowercase()) {
return status(global, systemProperty(OVERRIDE_PROPERTY))
}
/**
* The value Android itself acts on, in FeatureFlagUtils.isEnabled's order: the global setting
* when it is set, otherwise the feature-flag override property. Developer options' "Disable
* child process restrictions" writes only the property, so a device switched off there has no
* global setting at all and must not be reported as unset.
*/
fun status(global: String?, override: String?): PhantomProcessStatus {
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
return when (value) {
"false", "0" -> PhantomProcessStatus.DISABLED
null, "" -> PhantomProcessStatus.UNSET
null -> PhantomProcessStatus.UNSET
else -> PhantomProcessStatus.ENABLED
}
}
private const val OVERRIDE_PROPERTY = "persist.sys.fflag.override.$SETTING"
private fun systemProperty(name: String): String? = try {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java)
.invoke(null, name) as? String
} catch (_: Exception) {
null
}
fun hasDeveloperToggle(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
@@ -47,20 +107,20 @@ object PhantomProcessLimit {
}
fun instructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.UNSET -> "This setting is unset, so the ROM default applies and may enforce the limit. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not read the setting. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
}
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.UNSET -> "The ROM default may enforce this limit. Explicitly turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not check this setting. Turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
else -> instructions(status)
}
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
fun reportValue(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
@@ -37,11 +37,10 @@ public final class TarZst {
destination.mkdirs();
try (InputStream in = new ZstdCompressorInputStream(new BufferedInputStream(source, 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
String base = destination.getCanonicalPath() + File.separator;
TarArchiveEntry entry;
while ((entry = tar.getNextTarEntry()) != null) {
File file = new File(destination, entry.getName());
if (!file.getCanonicalPath().startsWith(base)) continue;
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) continue;
if (entry.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
file.mkdirs();
@@ -6,7 +6,9 @@ import android.net.nsd.NsdServiceInfo
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -19,6 +21,7 @@ object WirelessAdbFix {
private const val KEY_FILE = "wireless-adb-key.pem"
private const val HOST_FILE = "wireless-adb-host.txt"
private const val CONNECTION_FILE = "wireless-adb-connection.txt"
const val LOOPBACK = "127.0.0.1"
private data class SavedConnection(val host: String, val port: Int?)
@@ -76,9 +79,27 @@ object WirelessAdbFix {
}
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
fun isLocalAddress(address: InetAddress): Boolean = runCatching {
if (address.isLoopbackAddress) return true
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { network ->
network.inetAddresses.toList().any { it.address.contentEquals(address.address) }
}
}.getOrDefault(address is Inet4Address)
fun localConnectPort(context: Context): Int? =
tlsPortProperty()
?: findConnectPort(context, LOOPBACK)
private fun tlsPortProperty(): Int? = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
.invoke(null, "service.adb.tls.port") as? String
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
val expectedAddress = runCatching { InetAddress.getByName(pairedHost).address }.getOrNull() ?: return null
val paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
val matches: (InetAddress) -> Boolean =
if (paired.isLoopbackAddress) ::isLocalAddress else { address -> address.address.contentEquals(paired.address) }
val resolvedPort = AtomicInteger(-1)
val finished = AtomicBoolean(false)
val latch = CountDownLatch(1)
@@ -101,8 +122,8 @@ object WirelessAdbFix {
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host?.address
if (address != null && address.contentEquals(expectedAddress) && finished.compareAndSet(false, true)) {
val address = serviceInfo.host
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
resolvedPort.set(serviceInfo.port)
latch.countDown()
} else {
@@ -148,14 +169,16 @@ object WirelessAdbFix {
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
loadOrCreateIdentity(context.applicationContext)
Kadb.create(host, port).use { adb ->
val value = if (enabled) "true" else "false"
val change = adb.shell("settings put global settings_enable_monitor_phantom_procs $value")
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
val result = adb.shell("settings get global settings_enable_monitor_phantom_procs")
check(result.exitCode == 0 && result.output.trim() == value) {
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
val change = adb.shell(command)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
}
val result = adb.shell(PhantomProcessLimit.verifyCommand())
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
}
}
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
saveConnection(context, host, port)
}
@@ -0,0 +1,270 @@
package com.droiddeck.launcher.core
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.RemoteInput
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class WirelessAdbPairingService : Service() {
sealed interface Stage {
data object Idle : Stage
data object Waiting : Stage
data class CodeNeeded(val error: String? = null) : Stage
data class Working(val step: String) : Stage
data object Done : Stage
data class Failed(val error: String) : Stage
}
private val main = Handler(Looper.getMainLooper())
private var nsd: NsdManager? = null
private var discovery: NsdManager.DiscoveryListener? = null
private var pairingHost: String? = null
private var pairingPort: Int? = null
@Volatile private var working = false
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CODE -> {
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
?.filter(Char::isDigit)?.toString().orEmpty()
onCode(code)
}
ACTION_CANCEL -> finish(Stage.Idle)
else -> begin()
}
return START_NOT_STICKY
}
private fun begin() {
pairingHost = null
pairingPort = null
working = false
publish(Stage.Waiting)
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
startDiscovery()
main.removeCallbacks(timeout)
main.postDelayed(timeout, TIMEOUT_MS)
}
private fun startDiscovery() {
stopDiscovery()
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
.onSuccess { nsd = manager; discovery = listener }
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
}
private fun resolveListener() = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host ?: return
if (!WirelessAdbFix.isLocalAddress(address)) return
main.post {
if (working || current.value == Stage.Done) return@post
pairingHost = address.hostAddress
pairingPort = serviceInfo.port
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
}
}
}
private fun onCode(code: String) {
val host = pairingHost
val port = pairingPort
if (working) return
if (host == null || port == null) {
show(Stage.Waiting)
return
}
if (code.length != 6) {
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
return
}
working = true
show(Stage.Working("Pairing…"))
Thread({
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
if (paired.isFailure) {
main.post {
working = false
pairingPort = null
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
}
return@Thread
}
main.post {
stopDiscovery()
show(Stage.Working("Paired. Connecting…"))
}
val result = runCatching {
val connectPort = WirelessAdbFix.localConnectPort(this)
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
main.post { show(Stage.Working("Applying the setting…")) }
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
}
main.post {
working = false
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
}
}, "wireless-adb-pairing").start()
}
private fun show(stage: Stage) {
publish(stage)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
}
private fun finish(stage: Stage) {
main.removeCallbacks(timeout)
stopDiscovery()
publish(stage)
val manager = getSystemService(NotificationManager::class.java)
if (stage is Stage.Done || stage is Stage.Failed) {
manager?.notify(NOTIFICATION_ID, notification(stage))
stopForeground(STOP_FOREGROUND_DETACH)
} else {
stopForeground(STOP_FOREGROUND_REMOVE)
manager?.cancel(NOTIFICATION_ID)
}
stopSelf()
}
private fun stopDiscovery() {
val listener = discovery ?: return
runCatching { nsd?.stopServiceDiscovery(listener) }
discovery = null
}
override fun onDestroy() {
main.removeCallbacks(timeout)
stopDiscovery()
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
super.onDestroy()
}
private fun notification(stage: Stage): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
NotificationManager.IMPORTANCE_HIGH).apply {
description = "Asks for the Wireless debugging pairing code while Settings is open"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
val cancel = PendingIntent.getService(this, 1,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
val builder = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentIntent(open)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
when (stage) {
Stage.Waiting, Stage.Idle -> builder
.setContentTitle("Pair Wireless debugging")
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
.setStyle(Notification.BigTextStyle().bigText(
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
is Stage.CodeNeeded -> {
val reply = PendingIntent.getService(this, 2,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else PendingIntent.FLAG_UPDATE_CURRENT)
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
builder
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
}
is Stage.Working -> builder
.setContentTitle("Setting up Steam")
.setContentText(stage.step)
.setProgress(0, 0, true)
.setOngoing(true)
.setOnlyAlertOnce(true)
Stage.Done -> builder
.setContentTitle("Steam is ready")
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
.setAutoCancel(true)
is Stage.Failed -> builder
.setContentTitle("Wireless debugging setup stopped")
.setContentText(stage.error)
.setStyle(Notification.BigTextStyle().bigText(stage.error))
.setAutoCancel(true)
}
return builder.build()
}
companion object {
private const val CHANNEL_ID = "wireless-adb-pairing"
private const val NOTIFICATION_ID = 3
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
private const val KEY_CODE = "code"
private const val TIMEOUT_MS = 5 * 60_000L
private val current = MutableStateFlow<Stage>(Stage.Idle)
val stage: StateFlow<Stage> = current
private fun publish(stage: Stage) { current.value = stage }
fun start(context: Context) {
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
publish(Stage.Waiting)
val intent = Intent(context, WirelessAdbPairingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun cancel(context: Context) {
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
publish(Stage.Idle)
return
}
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
}
fun notificationsEnabled(context: Context): Boolean =
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
}
}
@@ -249,7 +249,7 @@ fun CollapsibleRail(
TextButton(onClick = link.onClick, modifier = Modifier.padding(start = 4.dp)) {
Icon(link.icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp))
Spacer(Modifier.width(6.dp))
Text(link.label, fontSize = 11.sp)
Text(link.label, fontSize = 12.sp)
}
}
}
@@ -269,7 +269,7 @@ fun CollapsibleRail(
Text(
section.header,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
fontSize = 9.sp,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
modifier = Modifier.padding(start = 14.dp, top = 10.dp, bottom = 2.dp),
@@ -0,0 +1,93 @@
package com.droiddeck.launcher.files
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import java.io.File
// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr).
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
internal data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
internal fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
internal fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.droiddeck.launcher.ui.DroidDeckTheme
import java.io.File
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
class FileManagerActivity : ComponentActivity() {
@@ -19,10 +20,11 @@ class FileManagerActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen()
FileManagerScreen(initialDir = initialDir)
}
}
}
@@ -32,4 +34,8 @@ class FileManagerActivity : ComponentActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) recreate()
}
companion object {
const val EXTRA_START_DIR = "com.droiddeck.launcher.files.START_DIR"
}
}
@@ -0,0 +1,599 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's rows, tiles, favourites and context menu.
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
internal fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.style.TextOverflow
import java.io.File
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
internal fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
internal fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
@@ -7,12 +7,7 @@ import androidx.compose.foundation.Image
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
private fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
private fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
private fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
private data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
private fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
private fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1030,7 +813,7 @@ fun FileManagerScreen(
// when it doesn't fit we drop the /storage/emulated/0 prefix, not the tail.
text = elidePathStart(currentDir.absolutePath, 52),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontSize = 12.sp,
maxLines = 1,
// fill = true: the path takes all remaining width, so the free-space figure
// is pinned to the right edge instead of sliding around with the path length.
@@ -1040,7 +823,7 @@ fun FileManagerScreen(
Text(
"${FileOps.formatBytes(freeSpace)} free",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontSize = 12.sp,
maxLines = 1,
)
}
@@ -1414,675 +1197,3 @@ fun FileManagerScreen(
} // end rail + content Row
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
private fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 11.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
private fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.files
import android.widget.Toast
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's properties dialog.
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
internal fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -1,24 +1,15 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.zip.CRC32
/**
* The user's own Windows games, added to the Steam client's library.
*
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
* is the .exe in it that looks most like the game - the one named after the folder, else the
* largest, never an installer, redistributable or crash reporter - unless the user picked one
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
* with the appid the client itself would derive, so launching from the rail and from the
* client's own library are the same thing. Any number of Games folders, from anywhere on the
* device: each is bound into the session under /root/Games on its own.
*/
/** Windows game folders shared with the Steam session. */
object AddedGames {
private const val TAG = "AddedGames"
@@ -29,6 +20,7 @@ object AddedGames {
/** What steam://rungameid/ takes for a shortcut. */
val gameId: Long,
val candidates: List<File>,
val steamAppId: Int? = null,
) {
fun folderName(): String = folder.name
}
@@ -96,12 +88,15 @@ object AddedGames {
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
for (root in roots(context)) {
val dir = root.host
val folders = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
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)
}
return out
return out.distinctBy { it.folder.canonicalPath }
}
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
@@ -115,10 +110,23 @@ object AddedGames {
val name = folder.name
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
val appId = crc or 0x80000000L
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
}
}
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
val users = File(root, "config/loginusers.vdf").readText()
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
?: return@runCatching null
val account = recent.groupValues[1].toLong() - 76561197960265728L
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
.optInt(appId.toString()).takeIf { it > 0 }
}.getOrNull()
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
fun writeListing(context: Context, games: List<Game>): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
@@ -126,6 +134,7 @@ object AddedGames {
games.forEachIndexed { i, g ->
if (i > 0) json.append(',')
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
@@ -145,5 +154,5 @@ object AddedGames {
return guestPath(context, file)
}
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun quote(s: String): String = JSONObject.quote(s)
}
@@ -14,7 +14,15 @@ import java.io.File
*/
object Library {
/** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong())
/**
* [art] is the portrait capsule; [hero] the wide banner Steam shows above a game's page, when
* the client has cached one. [lastPlayed] is Steam's own "LastPlayed" (unix seconds), 0 = never.
*/
class SteamGame(
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
@@ -55,16 +63,35 @@ object Library {
4690330, // Legacy Steam Runtime
)
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
private val STEAM_HEROES = listOf("library_hero.jpg")
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
/** Steam's library roots visible to this launcher: its private default plus the selected library. */
private fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
}
/** 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 })
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
return roots.asSequence()
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
val libraries = steamLibraries(context)
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
@@ -77,19 +104,31 @@ object Library {
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
// StateFlags 4 = fully installed; anything else is downloading, updating or broken.
if (name.isEmpty() || flags and 4 == 0) return@forEach
val art = steamCapsule(cache, appId)
out[appId] = SteamGame(appId, name, art, label)
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
// App manifests are Valve KeyValues (VDF) files. installdir is one folder
// below steamapps/common; only expose it when the directory exists and the
// manifest value cannot escape that directory.
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
out[appId] = SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
)
}
}
return out.values.toList()
}
/** Steam stores current library capsules inside hash-named folders under the app's cache dir. */
private fun steamCapsule(cache: File, appId: Int): File? {
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
val appDir = File(cache, appId.toString())
val dirs = listOf(appDir) + appDir.listFiles()
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
return STEAM_CAPSULES.asSequence()
return names.asSequence()
.flatMap { name -> dirs.asSequence().map { File(it, name) } }
.firstOrNull { it.isFile && it.length() > 0L }
}
@@ -278,7 +317,9 @@ object Library {
"-C", "Dolphin.General.HotkeysRequireFocus=False", "-C", "Dolphin.Input.BackgroundInput=True",
"-e", guestPath,
)
"cemu" -> listOf("-g", guestPath)
// Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started
// dialog away (bannerlator-pad-defaults).
"cemu" -> listOf("-f", "-g", guestPath)
// Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and
// comes back (HK_FullscreenToggle, bannerlator-pad-defaults).
"melonds" -> listOf("-f", guestPath)
@@ -40,9 +40,11 @@ object FrameGen {
}
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
fun label(context: Context): String = when (engine(context)) {
ENGINE_WINFG -> "Win-FG ${multiplier(context)}×"
ENGINE_LSFG -> "LSFG ${multiplier(context)}×"
fun label(context: Context): String = label(engine(context), multiplier(context))
fun label(engine: String, multiplier: Int): String = when (engine) {
ENGINE_WINFG -> "Win-FG $multiplier×"
ENGINE_LSFG -> "LSFG $multiplier×"
else -> "Off"
}
@@ -82,47 +84,5 @@ object FrameGen {
return problem
}
/**
* Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. The LSFG rows are greyed
* and unpickable until Lossless Scaling is installed in the Steam client - the engine cannot
* run without its DLL, so offering it would only produce a setting that silently does nothing.
*/
fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) {
val lsfgReady = LsfgNative.isInstalled(activity)
val lsfgNote = if (lsfgReady) "" else " - install Lossless Scaling in Steam"
val choices = listOf("Off",
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
"LSFG 2×$lsfgNote", "LSFG 3×$lsfgNote", "LSFG 4×$lsfgNote")
val current = when (engine(activity)) {
ENGINE_WINFG -> multiplier(activity) - 1
ENGINE_LSFG -> if (lsfgReady) multiplier(activity) + 2 else 0
else -> 0
}
val adapter = object : android.widget.ArrayAdapter<String>(
activity, android.R.layout.simple_list_item_single_choice, choices) {
override fun isEnabled(position: Int) = lsfgReady || position < 4
override fun areAllItemsEnabled() = lsfgReady
override fun getView(position: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View {
val view = super.getView(position, convertView, parent)
view.isEnabled = isEnabled(position)
view.alpha = if (isEnabled(position)) 1f else 0.38f
return view
}
}
android.app.AlertDialog.Builder(activity)
.setTitle(com.droiddeck.launcher.R.string.frame_gen_title)
.setSingleChoiceItems(adapter, current) { dialog, which ->
when (which) {
0 -> set(activity, ENGINE_OFF, 2)
in 1..3 -> set(activity, ENGINE_WINFG, which + 1)
else -> set(activity, ENGINE_LSFG, which - 2)
}
dialog.dismiss()
onChanged()
}
.setNeutralButton(android.R.string.ok, null)
.show()
}
private const val TAG = "FrameGen"
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONArray
@@ -27,7 +28,9 @@ object TurnipReleases {
private const val KEY_DOWNLOADS = "downloads"
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
val sha256: String = "")
/** [latest] = each source's newest release tag, for the refresh line. */
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
@@ -89,11 +92,13 @@ object TurnipReleases {
val a = list.getJSONObject(j)
val name = a.getString("name")
val (linux, label) = src.classify(name, tag) ?: continue
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
// Newest first: the first release carrying a variant is the one offered.
if (!seen.add("$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label))
.put("linux", linux).put("label", label).put("sha256", sha))
}
}
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
@@ -131,7 +136,7 @@ object TurnipReleases {
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
}
val latest = ArrayList<Pair<String, String>>()
val l = json.optJSONArray("latest") ?: JSONArray()
@@ -167,6 +172,7 @@ object TurnipReleases {
/** Download an asset into the cache; the caller imports it and deletes the file. */
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
val target = File(context.cacheDir, asset.name)
val c = URL(asset.url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
@@ -191,6 +197,9 @@ object TurnipReleases {
}
}
}
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("Checksum mismatch - the download was discarded")
}
return target
} catch (e: Exception) {
FileUtils.delete(target)
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.onSignal
import com.droiddeck.launcher.ui.LocalPalette
class ControllerEditorActivity : ComponentActivity() {
@@ -47,7 +48,7 @@ class ControllerEditorActivity : ComponentActivity() {
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
) {
Text("Drag to move", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton("Reset", false) { controls.resetLayout() }
EditorButton("Cancel", false) { finish() }
EditorButton("Save", true) {
@@ -67,7 +68,7 @@ class ControllerEditorActivity : ComponentActivity() {
val pal = LocalPalette.current
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (primary) Color.White else MaterialTheme.colorScheme.onBackground,
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.clip(RoundedCornerShape(50))
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
.clickable(onClick = onClick)
@@ -45,11 +45,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
fun contains(x: Float, y: Float, r: Float): Boolean {
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
val dx = x - cx
val dy = y - cy
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
val hit = r * 1.25f
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
val hit = r * 1.25f + padding
return dx * dx + dy * dy <= hit * hit
}
@@ -350,6 +350,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
override fun onDraw(canvas: Canvas) {
for (control in controls) {
if (!isVisible(control)) continue
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
val held = control.pressedBy != -1
val radius = control.radius
if (control.stick >= 0) {
@@ -426,10 +427,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
val x = event.getX(index)
val y = event.getY(index)
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
if (control.pressedBy != -1) return true
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
@@ -468,7 +472,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
control.pressedBy = -1
control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
@@ -505,7 +509,25 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
controls.firstOrNull {
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
}
private fun adaptiveStickAt(x: Float, y: Float): Control? {
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
return controls.filter {
val dx = x - it.cx
val dy = y - it.cy
val reach = it.radius * 1.4f * sqrt(1.5f)
it.stick >= 0 && isVisible(it) && it.pressedBy == -1 && dx * dx + dy * dy <= reach * reach
}.minByOrNull {
val dx = x - it.cx
val dy = y - it.cy
dx * dx + dy * dy
}
}
private fun isVisible(control: Control): Boolean = when {
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
@@ -574,6 +596,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
controls.forEach {
it.pressedBy = -1
it.clicked = false
it.lastUp = 0L
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
it.kx = 0f; it.ky = 0f
}
@@ -26,9 +26,24 @@ public final class PadBridge {
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
private static final int SLOT = 0;
private static final float DEAD_ZONE = 0.12f;
private static final long QAM_GUIDE_LEAD_MS = 80;
private static final long QAM_A_HOLD_MS = 120;
private static final long QAM_GUIDE_TAIL_MS = 40;
// The client takes A as part of the chord only once it has had Guide held for a while, and a
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
// none in 20; with the session down to 1 fps a fixed 400 ms still let 3 in 15 through, where
// 1000 ms let none. The lead keeps 80 ms when frames are quick, for a QAM that feels immediate,
// and stretches to eight frames when they are slow.
private static final long QAM_GUIDE_LEAD_MIN_MS = 80;
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
private static final int QAM_GUIDE_LEAD_FRAMES = 8;
private static final long QAM_A_HOLD_MS = 200;
private static final long QAM_GUIDE_TAIL_MS = 200;
// Off the main thread, so a busy UI cannot shorten or stretch the steps.
private static final Handler chordHandler;
static {
android.os.HandlerThread thread = new android.os.HandlerThread("qam-chord", android.os.Process.THREAD_PRIORITY_DISPLAY);
thread.start();
chordHandler = new Handler(thread.getLooper());
}
private final FakeInputWriter writer;
private final PadState state = new PadState();
@@ -212,21 +227,27 @@ public final class PadBridge {
qamChordActive = true;
int generation = ++qamChordGeneration;
publish();
mainHandler.postDelayed(() -> pressQamA(generation), QAM_GUIDE_LEAD_MS);
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
}
static long qamGuideLeadMs() {
long frame = com.droiddeck.launcher.wayland.WaylandCompositor.recentFrameIntervalMs();
if (frame <= 0) return QAM_GUIDE_LEAD_MIN_MS;
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
}
private synchronized void pressQamA(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = true;
publish();
mainHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamA(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = false;
publish();
mainHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
}
private synchronized void releaseQamGuide(int generation) {
@@ -1,12 +1,11 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
import org.json.JSONArray
/** Optional Decky Loader installation for the ARM64 Linux guest. */
@@ -84,7 +83,7 @@ object DeckyManager {
val n = it.optString("name")
(n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://")
}?.optString("browser_download_url")?.takeIf { it.startsWith("https://") }
val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) }
val digest = binary.optString("digest").takeIf { Hashes.isGithubSha256(it) }
if (digest == null && checksum == null) continue
val size = binary.optLong("size", 0)
if (size <= 0) continue
@@ -109,7 +108,7 @@ object DeckyManager {
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
@@ -148,15 +147,6 @@ object DeckyManager {
if (wipeData) File(root, "root/homebrew").deleteRecursively()
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).buffered().use { input ->
val buffer = ByteArray(1024 * 1024)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun validElf(file: File, machine: Int): Boolean = runCatching {
file.inputStream().use { input ->
val h = ByteArray(20)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
@@ -20,6 +21,12 @@ object DesktopCatalog {
/** Advance this when the Plasma root's required package set changes. */
internal const val PLASMA_MOBILE_PACKAGE_REVISION = "archlinuxarm-plasma-apps-v2"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
/**
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
* In a catalog of its own so it never shows among the packages a user picks from.
*/
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
const val PROTON_SEED_ID = "proton-arm64"
class Entry(
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
@@ -28,8 +35,8 @@ object DesktopCatalog {
val icon: String, val category: String,
)
fun fetch(): List<Entry>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
fun fetch(url: String = CATALOG_URL): List<Entry>? {
val body = Downloader.downloadString(url) ?: return null
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
@@ -59,6 +66,15 @@ object DesktopCatalog {
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
/**
* True until the ARM64 Proton has been placed once. Its appmanifest counts too, so an install
* the client fetched itself is never overwritten; after the first placement the client owns it
* and updates it like any other app, and a user who removes it is not given it back.
*/
fun protonSeedNeeded(context: Context): Boolean =
installed(context, PROTON_SEED_ID) == null &&
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
// labwc comes with the hosted desktop package itself (its launcher is staged by the app at
// every session, so it cannot tell whether the package is there); SessionFiles uses the same test.
fun desktopInstalled(context: Context): Boolean =
@@ -81,6 +97,8 @@ object DesktopCatalog {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
// Every catalog row carries a sha256; one without is refused rather than trusted.
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
listener?.onProgress("Downloading ${entry.name}", 0)
@@ -88,11 +106,9 @@ object DesktopCatalog {
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return "Download failed"
if (entry.sha256.isNotEmpty()) {
listener?.onProgress("Verifying", -1)
val actual = LinuxRuntimeInstaller.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
}
listener?.onProgress("Verifying", -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
listener?.onProgress("Installing ${entry.name}", -1)
when (entry.kind) {
"appimage" -> {
@@ -7,6 +7,7 @@ import android.system.ErrnoException;
import android.system.Os;
import android.system.StructStat;
import android.system.StructUtsname;
import com.droiddeck.launcher.session.SessionPrefs;
import com.droiddeck.launcher.core.FileUtils;
@@ -39,7 +40,6 @@ 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";
private static final String GUEST_HOSTNAME = "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";
@@ -307,7 +307,7 @@ public final class LinuxRuntime {
cmd.add(prootBinary(context).getPath());
cmd.add("--kill-on-exit");
// Preserve the host kernel identity while giving the guest the app's branded host name.
cmd.add("--kernel-release=" + guestUtsname());
cmd.add("--kernel-release=" + guestUtsname(SessionPrefs.guestHostname(context)));
// Android's app seccomp policy traps the whole set*id family. Xwayland's Popen() calls
// setgid()/setuid() before it execs xkbcomp and _exit(127)s when they fail, so without
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
@@ -385,6 +385,8 @@ public final class LinuxRuntime {
}
}
bindGpuNode(context, cmd);
bindAdrenoStats(cmd);
bindCpuTemps(cmd, root);
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
@@ -436,15 +438,91 @@ public final class LinuxRuntime {
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
}
/**
* 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.
*/
private static void bindAdrenoStats(List<String> cmd) {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
String[][] stats = {
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/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"},
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
};
for (String[] stat : stats) {
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
}
}
/**
* mangoapp's CPU temperature is the mean of the thermal zones named cpuN-thermal or
* cpuN-top-thermal, as mainline kernels name them; Android kernels name the same sensors
* cpuss-0, cpu-1-0, cpu0-silver-usr, apc1-cpu0-usr and the like, so those zones are shown
* under the mainline name.
*/
private static void bindCpuTemps(List<String> cmd, File root) {
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
try {
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
return;
}
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
}
}
}
private static String firstReadable(String... paths) {
for (String path : paths) {
if (path != null && new File(path).canRead()) return path;
}
return null;
}
/** The temp file of the thermal zone for a sensor: the one named so, else the first whose name has it. */
private static String thermalZone(String sensor) {
java.util.Map<String, File> zones = thermalZones();
File zone = zones.get(sensor);
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(zones).entrySet()) {
if (zone == null && e.getKey().contains(sensor)) zone = e.getValue();
}
return zone != null ? new File(zone, "temp").getPath() : null;
}
/** The device's thermal zones by their sensor's name, in lower case. */
private static java.util.Map<String, File> thermalZones() {
java.util.Map<String, File> byType = new java.util.HashMap<>();
File[] zones = new File("/sys/class/thermal").listFiles((dir, n) -> n.startsWith("thermal_zone"));
if (zones == null) return byType;
for (File zone : zones) {
try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.FileReader(new File(zone, "type")))) {
byType.putIfAbsent(String.valueOf(r.readLine()).trim().toLowerCase(java.util.Locale.ROOT), zone);
} catch (IOException ignored) {
}
}
return byType;
}
private static void bind(List<String> cmd, String spec) {
cmd.add("-b");
cmd.add(spec);
}
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
private static String guestUtsname() {
private static String guestUtsname(String hostname) {
StructUtsname host = Os.uname();
return "\\" + host.sysname + "\\" + GUEST_HOSTNAME + "\\" + host.release
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
}
@@ -4,7 +4,9 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.Hashes;
import com.droiddeck.launcher.core.FileUtils;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@@ -23,7 +25,8 @@ import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
/**
* Downloads and unpacks the Linux runtime rootfs into {@code files/linuxfs}. It is opt-in: nothing
@@ -52,7 +55,7 @@ public final class LinuxRuntimeInstaller {
public final String sha256;
public final long size;
Release(String version, String url, String sha256, long size) {
public Release(String version, String url, String sha256, long size) {
this.version = version;
this.url = url;
this.sha256 = sha256;
@@ -60,6 +63,23 @@ public final class LinuxRuntimeInstaller {
}
}
/**
* One install per process. The launcher and the session screen can both start one, and a
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
* install would share the first one's archive and staging directory and wreck both.
*/
private static final class Job {
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
final CountDownLatch done = new CountDownLatch(1);
volatile String stage = "Starting\u2026";
volatile int percent = -1;
volatile boolean ok;
}
private static final Object JOB_LOCK = new Object();
private static Job running;
private static boolean removing;
private LinuxRuntimeInstaller() {}
/** The build currently unpacked, or null when the runtime is not installed. */
@@ -104,6 +124,66 @@ public final class LinuxRuntimeInstaller {
* not. An update therefore keeps Steam, the login and the installed games.
*/
public static boolean install(Context context, Release release, ProgressListener listener) {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (removing) return false;
owner = running == null;
if (owner) running = new Job();
job = running;
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
try {
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
});
return job.ok;
} finally {
synchronized (JOB_LOCK) {
running = null;
}
job.done.countDown();
}
}
/** True while an install is running in this process, whoever started it. */
public static boolean isInstalling() {
synchronized (JOB_LOCK) {
return running != null;
}
}
/**
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
* it ends and returns its result, or returns null at once when nothing is installing.
*/
public static Boolean attach(ProgressListener listener) {
Job job;
synchronized (JOB_LOCK) {
job = running;
if (job == null) return null;
if (listener != null) job.listeners.add(listener);
}
return join(job, listener);
}
private static boolean join(Job job, ProgressListener listener) {
try {
if (listener != null) listener.onProgress(job.stage, job.percent);
job.done.await();
return job.ok;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (listener != null) job.listeners.remove(listener);
}
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
try {
if (listener != null) listener.onProgress("Downloading", 0);
@@ -120,7 +200,7 @@ public final class LinuxRuntimeInstaller {
}
if (listener != null) listener.onProgress("Verifying", -1);
String actual = sha256(archive);
String actual = Hashes.sha256(archive);
if (!release.sha256.equalsIgnoreCase(actual)) {
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
return false;
@@ -130,6 +210,8 @@ public final class LinuxRuntimeInstaller {
// runtime that isInstalled() would happily launch.
File root = LinuxRuntime.rootDir(context);
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
FileUtils.delete(staging);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
@@ -139,7 +221,6 @@ public final class LinuxRuntimeInstaller {
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
FileUtils.delete(old);
if (root.isDirectory() && !root.renameTo(old)) {
FileUtils.delete(staging);
@@ -164,6 +245,8 @@ public final class LinuxRuntimeInstaller {
}
if (!staging.renameTo(root)) {
File keptTo = new File(staging, USER_DATA);
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
if (old.isDirectory()) old.renameTo(root);
return false;
}
@@ -177,8 +260,38 @@ public final class LinuxRuntimeInstaller {
}
}
public static void uninstall(Context context) {
FileUtils.delete(LinuxRuntime.rootDir(context));
private static void recoverInterruptedSwap(File root, File staging, File old) {
File target = root.isDirectory() ? root : old;
File stagedHome = new File(staging, USER_DATA);
File home = new File(target, USER_DATA);
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
FileUtils.delete(home);
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
}
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
Log.w(TAG, "restored the previous runtime after an interrupted update");
}
}
private static boolean isEmptyDir(File dir) {
String[] names = dir.list();
return dir.isDirectory() && names != null && names.length == 0;
}
/** Removes the runtime; returns false without touching it while an install is running. */
public static boolean uninstall(Context context) {
synchronized (JOB_LOCK) {
if (running != null || removing) return false;
removing = true;
}
try {
FileUtils.delete(LinuxRuntime.rootDir(context));
return true;
} finally {
synchronized (JOB_LOCK) {
removing = false;
}
}
}
/**
@@ -193,12 +306,10 @@ public final class LinuxRuntimeInstaller {
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
TarArchiveEntry entry;
String base = destination.getCanonicalPath() + File.separator;
while ((entry = tar.getNextTarEntry()) != null) {
File file = new File(destination, entry.getName());
// Refuse anything that would land outside the runtime directory.
if (!(file.getCanonicalPath() + (entry.isDirectory() ? File.separator : "")).startsWith(base)
&& !file.getCanonicalPath().equals(destination.getCanonicalPath())) {
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) {
Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName());
continue;
}
@@ -215,7 +326,13 @@ public final class LinuxRuntimeInstaller {
} else if (entry.isLink()) {
// A hard link to an earlier entry. Link where the filesystem allows it and
// fall back to a copy, which costs space but always works.
File target = new File(destination, entry.getLinkName());
// The target is checked too: a link to "../../shared_prefs/x" would pull a
// file from outside the rootfs into it.
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
try {
Files.createLink(file.toPath(), target.toPath());
@@ -244,16 +361,4 @@ public final class LinuxRuntimeInstaller {
return false;
}
}
public static String sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new BufferedInputStream(new FileInputStream(file), 1 << 16)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = in.read(buffer)) != -1) digest.update(buffer, 0, read);
}
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) sb.append(String.format("%02x", b));
return sb.toString();
}
}
@@ -0,0 +1,100 @@
package com.droiddeck.launcher.runtime
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
/**
* Keeps the process in the foreground while the Linux runtime downloads and unpacks. The runtime
* is several hundred megabytes; without a foreground service Android may kill the app the moment
* the user switches away, and the install starts over from the resumable part file.
*
* The install itself is [LinuxRuntimeInstaller.install], which runs one job per process: the
* screen that asked for it joins the same job to show progress, so it does not matter which of the
* two gets there first.
*/
class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification("Starting…", -1))
if (release == null) {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = Int.MIN_VALUE
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
// Every percent at most once: a notification per read would flood the system.
if (percent != shown) {
shown = percent
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
}
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "runtime-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime downloads and installs"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle("Installing the Linux runtime")
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
companion object {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
private const val EXTRA_SIZE = "size"
/** Starts the install of [release] in the foreground; a running install is joined instead. */
fun start(context: Context, release: LinuxRuntimeInstaller.Release) {
val intent = Intent(context, RuntimeInstallService::class.java)
.putExtra(EXTRA_VERSION, release.version)
.putExtra(EXTRA_URL, release.url)
.putExtra(EXTRA_SHA256, release.sha256)
.putExtra(EXTRA_SIZE, release.size)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
val sha256 = intent.getStringExtra(EXTRA_SHA256) ?: return null
return LinuxRuntimeInstaller.Release(version, url, sha256, intent.getLongExtra(EXTRA_SIZE, 0L))
}
}
}
@@ -0,0 +1,656 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
/**
* FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client.
*
* Every Proton the client runs games with keeps its own copy of these three inside its tree. This
* lists them, stores packages (.wcp: tar + zstd with a profile.json) downloaded from the Nightlies
* "-Linux" releases or imported, swaps one into a Proton, keeps the Proton's shipped files as
* "original bundles" - one per Proton build, so a Steam update adds a new one instead of replacing
* the last - restores those, and deletes stored packages.
*
* Where a Proton reads each component (its own `proton` script, ARM64 host):
* DXVK files/lib/wine/dxvk/{aarch64,i386}-windows -> the prefix's system32 / syswow64
* VKD3D files/lib/wine/vkd3d-proton/{aarch64,i386}-windows -> the same, on every launch
* FEX files/lib/wine/aarch64-windows/lib{arm64ec,wow64}fex.dll
* files/lib/wine/aarch64-unix/lib{arm64ec,wow64}fex.so (FEX 2607 and newer)
* Proton copies DXVK and VKD3D into the prefix on every normal launch, so a swap applies the next
* time a game starts. A swap into a Proton a running game is using waits until that game exits.
*
* Same formats and data layout as the Decky "Components Manager" plugin, whose data is taken over
* once on first use.
*/
object ComponentsManager {
private const val TAG = "Components"
private const val NIGHTLIES = "The412Banner/Nightlies"
/** Release tag -> component. Only the Linux repacks: the Android releases carry bionic unixlibs. */
private val RELEASES = linkedMapOf(
"FexCore-Linux" to "fex",
"Dxvk-Linux" to "dxvk",
"Dxvk-arm64ec-Linux" to "dxvk",
"Dxvk-gplasync-Linux" to "dxvk",
"Vkd3d-proton-Linux" to "vkd3d",
)
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
val LABEL = mapOf("fex" to "FEX", "dxvk" to "DXVK", "vkd3d" to "VKD3D-Proton")
private const val WINE = "files/lib/wine"
private val FEX_FILES = listOf(
"$WINE/aarch64-windows/libarm64ecfex.dll",
"$WINE/aarch64-windows/libwow64fex.dll",
"$WINE/aarch64-unix/libarm64ecfex.so",
"$WINE/aarch64-unix/libwow64fex.so",
)
private val COMP_DIR = mapOf("dxvk" to "$WINE/dxvk", "vkd3d" to "$WINE/vkd3d-proton")
/** The folders a Proton reads on ARM64. */
private val PE_ARCHES = listOf("aarch64-windows", "i386-windows")
/**
* The DLLs that ARE the component. A swap clears exactly these before unpacking, so no file of
* the previous version is left mixed in; anything else a Proton keeps beside them - GE's
* openvr_api_dxvk.dll, which its launcher copies into every prefix and aborts without - stays.
*/
private val COMP_DLLS = mapOf(
"dxvk" to setOf("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"),
"vkd3d" to setOf("d3d12.dll", "d3d12core.dll"),
)
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
data class ProtonView(
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
val reappliedAt: Long = 0,
)
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
private val lock = Any()
// ------------------------------------------------------------------ paths
private fun root(context: Context) = LinuxRuntime.rootDir(context)
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() }
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
/**
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
*/
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
// ------------------------------------------------------------------ state
private fun loadState(context: Context): JSONObject =
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
if (!has("active")) put("active", JSONObject())
if (!has("queued")) put("queued", JSONObject())
}
private fun saveState(context: Context, state: JSONObject) {
val f = stateFile(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(state.toString(2))
tmp.renameTo(f)
// Every change of choice reaches the launch wrappers at once.
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
/** Takes over the Decky plugin's packages, originals and swap records, once. */
private fun migratePlugin(context: Context) {
val marker = File(dataDir(context), ".plugin-imported")
if (marker.isFile) return
val plugin = File(root(context), PLUGIN_DATA)
if (plugin.isDirectory) {
runCatching {
File(plugin, "packages").listFiles()?.forEach { f ->
if (f.isFile && f.name.endsWith(".wcp")) {
val dst = File(packagesDir(context), f.name)
if (!dst.exists()) f.copyTo(dst)
}
}
val originals = File(plugin, "originals")
if (originals.isDirectory) originals.copyRecursively(originalsDir(context), overwrite = false)
val ps = File(plugin, "state.json")
if (ps.isFile && !stateFile(context).isFile) ps.copyTo(stateFile(context))
Log.i(TAG, "took over the Decky plugin's data from $plugin")
}.onFailure { Log.w(TAG, "plugin data import", it) }
}
marker.writeText("1\n")
}
// ------------------------------------------------------------------ Protons
private fun libraryRoots(context: Context): List<File> {
val roots = mutableListOf(steam(context))
val vdf = File(steam(context), "steamapps/libraryfolders.vdf")
runCatching {
Regex("\"path\"\\s+\"([^\"]+)\"").findAll(vdf.readText()).forEach {
val h = host(context, it.groupValues[1])
if (roots.none { r -> r.canonicalPath == h.canonicalPath }) roots.add(h)
}
}
return roots
}
private fun displayName(dir: File): String = runCatching {
Regex("\"display_name\"\\s+\"([^\"]+)\"").find(File(dir, "compatibilitytool.vdf").readText())?.groupValues?.get(1)
}.getOrNull() ?: dir.name
private fun protonVersion(dir: File): String = runCatching {
val parts = File(dir, "version").useLines { it.firstOrNull() ?: "" }.trim().split(Regex("\\s+"))
if (parts.size > 1) parts[1] else parts.firstOrNull()?.ifEmpty { null } ?: "unknown"
}.getOrDefault("unknown")
/** Every Proton tree with a Wine inside. Launch wrappers pointing at another Proton are left out. */
fun protons(context: Context): List<Proton> {
val found = LinkedHashMap<String, Proton>()
val bases = listOf(File(steam(context), "compatibilitytools.d") to false) +
libraryRoots(context).map { File(it, "steamapps/common") to true }
// Not canonical: /data/user/0 is a link to /data/data on many devices, and the guest path is
// what is left after removing the rootfs prefix exactly as the paths were built.
val rootPath = root(context).absolutePath.trimEnd('/')
for ((base, valve) in bases) {
val entries = base.listFiles()?.sortedBy { it.name } ?: continue
for (dir in entries) {
if (!File(dir, WINE).isDirectory || !File(dir, "proton").isFile) continue
val real = runCatching { dir.canonicalPath }.getOrDefault(dir.path)
if (found.containsKey(real)) continue
val guest = if (dir.absolutePath.startsWith(rootPath)) dir.absolutePath.removePrefix(rootPath) else dir.absolutePath
found[real] = Proton(safeName(dir.name), displayName(dir), dir, guest, protonVersion(dir), valve)
}
}
return found.values.toList()
}
private fun proton(context: Context, id: String) = protons(context).firstOrNull { it.id == id }
?: throw IllegalStateException("No Proton with id $id")
/**
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
*/
fun inUse(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
/**
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
*/
fun gameRunning(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
private fun anyProcess(proton: Proton, match: (String, String) -> Boolean): Boolean {
val needle = proton.guestPath.trimEnd('/') + "/"
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
if (match(needle, String(cmd).replace('\u0000', ' '))) return true
}
return false
}
// ------------------------------------------------------------------ component files
private fun readVersionFile(f: File): String = runCatching {
val text = f.readText().trim()
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
}.getOrDefault("")
private fun fexVersion(dir: File): String {
val dll = File(dir, FEX_FILES[0])
if (!dll.isFile) return ""
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
?: "FEX (build without a version name)"
}
private fun compFiles(dir: File, comp: String): List<String> {
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
val base = File(dir, COMP_DIR.getValue(comp))
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
}
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
private fun fingerprint(dir: File, comp: String): Map<String, String> =
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { Hashes.sha256(File(dir, it)) }
// ------------------------------------------------------------------ packages
private fun normalize(name: String) = name.removePrefix("./").trimStart('/')
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
}
throw IllegalArgumentException("no profile.json in ${f.name}")
}
private fun packageInfo(f: File): Package {
val prof = readProfile(f)
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
private fun installFiles(context: Context, dir: File, comp: String, wcp: File): Map<String, String> {
val staged = File(dataDir(context), "staging-${System.nanoTime()}")
try {
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/")) continue
require(rel.split('/').none { it == ".." }) { "unsafe path $rel" }
val out = File(staged, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
COMP_DIR[comp]?.let { compDir ->
for (arch in PE_ARCHES) {
val target = File(dir, "$compDir/$arch")
if (!File(staged, "$compDir/$arch").isDirectory || !target.isDirectory) continue
target.listFiles()?.filter { it.name.lowercase() in COMP_DLLS.getValue(comp) }?.forEach { it.delete() }
}
}
val hashes = LinkedHashMap<String, String>()
File(staged, "files").walkTopDown().filter { it.isFile }.forEach { src ->
val rel = src.relativeTo(staged).path
val dst = File(dir, rel)
dst.parentFile?.mkdirs()
val tmp = File(dst.parentFile, dst.name + ".dcm-new")
src.copyTo(tmp, overwrite = true)
if (rel.endsWith(".so")) tmp.setExecutable(true, false)
tmp.setReadable(true, false)
if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } }
hashes[rel] = Hashes.sha256(dst)
}
return hashes
} finally {
staged.deleteRecursively()
}
}
private fun writeWcp(dest: File, profile: JSONObject, dir: File, rels: List<String>) {
val tmp = File(dest.parentFile, dest.name + ".part")
TarArchiveOutputStream(ZstdOutputStream(BufferedOutputStream(FileOutputStream(tmp), 1 shl 16), 9)).use { tar ->
tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX)
val bytes = profile.toString(2).toByteArray()
tar.putArchiveEntry(TarArchiveEntry("./profile.json").apply { size = bytes.size.toLong() })
tar.write(bytes); tar.closeArchiveEntry()
for (rel in rels) {
val f = File(dir, rel)
tar.putArchiveEntry(TarArchiveEntry(f, "./$rel"))
FileInputStream(f).use { it.copyTo(tar) }
tar.closeArchiveEntry()
}
}
tmp.renameTo(dest)
}
// ------------------------------------------------------------------ originals
private fun originalFile(context: Context, p: Proton, comp: String) =
File(originalsDir(context), "${p.id}/${safeName(p.version)}/$comp.wcp").apply { parentFile?.mkdirs() }
fun originals(context: Context, protonId: String): List<Original> {
val base = File(originalsDir(context), protonId)
val versions = base.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.lastModified() } ?: return emptyList()
return versions.flatMap { v ->
COMPONENTS.mapNotNull { comp ->
val f = File(v, "$comp.wcp")
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
}
}
}
/**
* Saves a Proton's shipped files for this component before they are first replaced in this
* Proton build. A build already swapped into is not saved again: its files are ours.
*/
private fun ensureOriginal(context: Context, p: Proton, comp: String, state: JSONObject) {
val dest = originalFile(context, p, comp)
if (dest.isFile) return
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
if (active != null && active.optString("protonVersion") == p.version) return
val rels = compFiles(p.dir, comp)
if (rels.isEmpty()) return
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val profile = JSONObject()
.put("type", TYPE_OF.getValue(comp))
.put("versionName", "original-${p.version}")
.put("versionCode", 0)
.put("description", "Original · ${p.name} · ${p.version} · ${LABEL.getValue(comp)} $current".trim())
.put("files", JSONArray(rels.map { JSONObject().put("source", it).put("target", "\${proton}/$it") }))
writeWcp(dest, profile, p.dir, rels)
Log.i(TAG, "saved original $comp of ${p.name} ${p.version}")
}
/** Saves the originals of every Proton build not seen yet (first open, and after a Steam update). */
fun snapshotAll(context: Context) = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
for (p in protons(context)) for (comp in COMPONENTS) {
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
}
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
// ------------------------------------------------------------------ view
fun snapshot(context: Context): Snapshot = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
val reapplied = HashMap<String, Long>()
runCatching {
File(launchDir(context), "reapplied.log").forEachLine { line ->
val parts = line.split('\t')
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
}
}
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
val inUseLabel = when {
active == null -> "Original"
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
else -> {
val want = active.optJSONObject("files") ?: JSONObject()
val fp = fingerprint(p.dir, comp)
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
else "Changed outside Components"
}
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { "not present" }, detail, inUseLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.optString("label"),
)
}
ProtonView(p, comps, originals(context, p.id), gameRunning(p), reapplied[p.dir.name] ?: 0)
}
Snapshot(views, packages(context))
}
// ------------------------------------------------------------------ launch-time enforcement
/**
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
* what overwrote files in the first place.
*/
private fun syncLaunchState(context: Context, state: JSONObject) {
val dir = launchDir(context)
val store = File(dir, "store").apply { mkdirs() }
val all = protons(context)
val lines = StringBuilder()
val keep = HashSet<String>()
val active = state.sub("active")
for (pid in active.keys()) {
val p = all.firstOrNull { it.id == pid } ?: continue
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val a = comps.optJSONObject(comp) ?: continue
if (a.optString("protonVersion") != p.version) continue
val file = a.optString("file")
val (key, wcp) = if (file.startsWith("original ")) {
val v = file.removePrefix("original ")
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
if (!wcp.isFile) continue
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
unpacked.deleteRecursively()
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
val src = File(unpacked, rel)
if (!src.isFile) continue
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
}
}
}
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
val list = File(dir, "desired.tsv")
val tmp = File(dir, "desired.tsv.tmp")
tmp.writeText(lines.toString())
tmp.renameTo(list)
}
// ------------------------------------------------------------------ actions
/** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
}
applyPackage(context, p, info, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
saveState(context, state)
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
}
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
ensureOriginal(context, p, info.comp, state)
val hashes = installFiles(context, p.dir, info.comp, wcp)
state.sub("active").sub(p.id).put(info.comp, JSONObject()
.put("file", wcp.name).put("label", info.version).put("protonVersion", p.version)
.put("files", JSONObject(hashes.filterKeys { owned(info.comp, it) } as Map<*, *>))
.put("at", System.currentTimeMillis() / 1000))
Log.i(TAG, "swapped ${info.comp} ${info.version} into ${p.name} ${p.version}")
}
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
}
applyOriginal(context, p, comp, protonVersion, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(comp)
saveState(context, state)
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
installFiles(context, p.dir, comp, wcp)
val active = state.sub("active").sub(p.id)
if (current) active.remove(comp)
else active.put(comp, JSONObject().put("file", "original $protonVersion").put("label", "Original from $protonVersion")
.put("protonVersion", p.version).put("files", JSONObject(fingerprint(p.dir, comp) as Map<*, *>)).put("at", System.currentTimeMillis() / 1000))
}
/** Runs the swaps that waited for a game to close. Cheap when nothing is queued. */
fun applyQueued(context: Context): List<String> = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
if (queued.length() == 0) return emptyList()
val done = mutableListOf<String>()
val all = protons(context)
for (pid in queued.keys().asSequence().toList()) {
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
if (inUse(p)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val q = comps.optJSONObject(comp) ?: continue
runCatching {
if (q.optString("kind") == "original") {
val v = q.optString("protonVersion")
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
} else {
val wcp = File(packagesDir(context), q.optString("file"))
applyPackage(context, p, packageInfo(wcp), wcp, state)
}
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
comps.remove(comp)
}
if (comps.length() == 0) queued.remove(pid)
}
saveState(context, state)
done
}
fun cancelQueued(context: Context, protonId: String, comp: String) = synchronized(lock) {
val state = loadState(context)
state.sub("queued").optJSONObject(protonId)?.remove(comp)
saveState(context, state)
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
}
val f = File(packagesDir(context), name)
check(f.isFile) { "$name is not stored" }
f.delete()
"Deleted $name."
}
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(f.isFile) { "Not stored" }
f.delete()
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
"Deleted the $protonVersion original of ${LABEL[comp]}."
}
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
info.copy(file = dest.name)
}
// ------------------------------------------------------------------ catalog
/** The Nightlies listing: cached on disk, fetched again only when [force] (the refresh button). */
fun catalog(context: Context, force: Boolean): Catalog {
val cache = catalogFile(context)
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
for ((tag, comp) in RELEASES) {
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
if (!name.endsWith(".wcp")) continue
// Only packages GitHub has a sha256 for are offered: the download is checked against it.
if (!Hashes.isGithubSha256(a.optString("digest"))) continue
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
return Catalog(items, o.optLong("fetchedAt"))
}
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
}
}
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
object GameEnvironmentStore {
private const val SETTINGS = "game-environment.json"
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
val file = AtomicFile(File(context.filesDir, SETTINGS))
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
}
fun decode(json: JSONObject): GameEnvironment.Config {
require(json.getInt("version") == 1)
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
value?.keys()?.forEach { name ->
require(GameEnvironment.validName(name))
val entry = value.get(name)
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
put(name, if (entry == JSONObject.NULL) null else entry as String)
}
}
val games = json.optJSONObject("games")
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
games?.keys()?.forEach { id ->
require(id.isNotEmpty() && GameEnvironment.validScope(id))
put(id, entries(games.getJSONObject(id)))
}
})
}
fun encode(config: GameEnvironment.Config): JSONObject {
fun entries(values: Map<String, String?>) = JSONObject().apply {
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
}
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
config.games.forEach { (id, values) -> put(id, entries(values)) }
})
}
@Synchronized
fun save(context: Context, config: GameEnvironment.Config) {
val json = encode(config)
decode(json)
write(File(context.filesDir, SETTINGS), json.toString())
publish(context, config)
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
}
private fun write(file: File, text: String) {
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
val atomic = AtomicFile(file)
val output = atomic.startWrite()
try {
output.write(text.toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
throw error
}
}
}
@@ -17,15 +17,20 @@ import java.nio.charset.StandardCharsets
* progress, and lifting that out is the difference between "starting the Steam client" for three
* minutes and a percentage that moves.
*/
class LoadingState(context: Context) {
class LoadingState(context: Context, steam: Boolean = true) {
var visible by mutableStateOf(true)
var step by mutableStateOf("Starting the session…")
var percent by mutableIntStateOf(-1)
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
var ended by mutableStateOf(false)
/** The technical side of an ended session (exit status, log path), under the advice in [step]. */
var endedDetail by mutableStateOf<String?>(null)
private val hints = context.resources.getStringArray(com.droiddeck.launcher.R.array.loading_hints)
// The desktop and the emulators get their own: the Steam ones talk about Steam.
private val hints = context.resources.getStringArray(
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
)
private val startedAt = SystemClock.elapsedRealtime()
/** Once a second: the clock and the hint. */
@@ -35,10 +40,11 @@ class LoadingState(context: Context) {
hint = hints[((seconds / 8) % hints.size).toInt()]
}
fun showEnded(message: String) {
fun showEnded(message: String, detail: String? = null) {
visible = true
ended = true
step = message
endedDetail = detail
}
/** Re-reads the end of the log and updates the line and the bar. */
@@ -81,7 +81,7 @@ class PerfHud(context: Context) {
fun stop() {
running = false
WaylandCompositor.setGameListener(null)
WaylandCompositor.clearGameListener(listener)
text = ""
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.os.StatFs
import android.util.Log
@@ -7,7 +8,6 @@ import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.security.MessageDigest
import org.json.JSONArray
/** Immediate download and installation of the optional ARM64 Proton builds. */
@@ -21,7 +21,9 @@ object ProtonExtras {
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long)
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
val sha256: String? = null)
val tools = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
@@ -99,14 +101,24 @@ object ProtonExtras {
return "Download was incomplete; the partial file is kept for a resumable retry"
}
asset.sha512?.let { checksumUrl ->
onProgress("Verifying download", -1)
val checksumText = Downloader.downloadString(checksumUrl)
val expected = checksumText?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
if (expected != null && !expected.equals(sha512(archive), ignoreCase = true)) {
archive.delete()
return "Checksum mismatch; the download was deleted"
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
// release's own sha512 file. A download neither vouches for is not installed.
onProgress("Verifying download", -1)
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return "Could not read the release checksum; try again"
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
else -> {
archive.delete()
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
}
}
if (!verified) {
archive.delete()
return "Checksum mismatch; the download was deleted"
}
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
@@ -170,6 +182,7 @@ object ProtonExtras {
release.optString("tag_name"), name,
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
archive.optLong("size", 0L),
Hashes.githubSha256(archive.optString("digest")),
)
}
null
@@ -179,19 +192,6 @@ object ProtonExtras {
}
}
private fun sha512(file: File): String {
val digest = MessageDigest.getInstance("SHA-512")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(4 * 1024 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun requestLines(context: Context): List<String> =
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
}
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.session
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import android.widget.Toast
/**
* Other apps on the device that sign into the user's Steam account with their own client. Steam
@@ -34,31 +33,21 @@ object RivalClients {
fun name(pkg: String): String = if (pkg == "com.xiaoji.egggame") "GameHub" else pkg
/**
* Stop the installed rivals' background processes before the client starts, and say so. A
* rival in the foreground survives this, so the wording covers that case too. Call on the main
* thread; the Toast is shown from here.
* Stop the installed rivals' background processes before the client starts. A rival in the
* foreground survives this. The activity service call and its log messages are unchanged.
*/
fun stopBeforeSession(context: Context) {
val rivals = installed(context)
if (rivals.isEmpty()) return
val who = name(rivals[0])
Log.w(TAG, "competing Steam client installed: $rivals")
var asked = false
try {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
if (am != null) {
for (pkg in rivals) am.killBackgroundProcesses(pkg)
asked = true
Log.i(TAG, "asked Android to stop background processes of $rivals")
}
} catch (t: Throwable) {
Log.w(TAG, "could not stop competing Steam clients", t)
}
Toast.makeText(
context,
if (asked) "Closed $who in the background - it signs into your Steam account."
else "If $who is open, close it first - it signs into your Steam account and will sign this client out.",
Toast.LENGTH_LONG,
).show()
}
}
@@ -24,7 +24,9 @@ object SessionFiles {
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
GameEnvironmentStore.publish(context)
val files = arrayOf(
"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",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
@@ -38,6 +40,7 @@ object SessionFiles {
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
"usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games",
"usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults",
// 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).
@@ -156,28 +159,44 @@ object SessionFiles {
Log.e(TAG, "could not write ld.so.preload")
}
val startupMovie = File(root, "root/.local/share/Steam/config/uioverrides/movies/droiddeck-startup.webm")
val stagedMovie = File(startupMovie.parentFile, startupMovie.name + ".staged")
var movieInstalled = false
try {
startupMovie.parentFile?.mkdirs()
context.assets.open("steam-startup/droiddeck-startup.webm").use { input ->
stagedMovie.outputStream().use { output -> FileUtils.copy(input, output) }
}
movieInstalled = stagedMovie.setReadable(true, false) && stagedMovie.renameTo(startupMovie)
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie", e)
} finally {
if (!movieInstalled) stagedMovie.delete()
val startupMovieDir = File(root, "root/.local/share/Steam/config/uioverrides/movies")
// Steam looks up these conventional names in its user override directory. Keep both
// variants populated because the startup movie name differs across Steam clients.
val bigPictureMovieInstalled = stageStartupMovie(
context,
startupMovieDir,
"bigpicture_startup.webm",
)
stageStartupMovie(context, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
}
if (!movieInstalled) Log.e(TAG, "Steam startup movie NOT staged")
if (movieInstalled) ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
}
private fun stageStartupMovie(context: Context, directory: File, name: String): Boolean {
val movie = File(directory, name)
val staged = File(directory, "$name.staged")
var installed = false
try {
directory.mkdirs()
context.assets.open("steam-startup/droiddeck-startup.webm").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "Steam startup movie $name NOT staged")
return installed
}
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
private fun ensureStartupMovieDefault(config: File) {
val path = "/uioverrides/movies/droiddeck-startup.webm"
val path = "/uioverrides/movies/bigpicture_startup.webm"
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config for startup movie", it)
return
@@ -192,7 +211,10 @@ object SessionFiles {
Log.i(TAG, "Steam startup movie is set to DroidDeck")
return
}
if ((!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()) {
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
val hasExplicitSelection =
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
if (!legacyDefault && hasExplicitSelection) {
Log.i(TAG, "Steam startup movie selection preserved")
return
}
@@ -21,6 +21,13 @@ object SessionPrefs {
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
/** Whether the launcher hides Android's status and navigation bars. */
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
fun setLauncherFullscreen(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
fun setHudEnabled(context: Context, on: Boolean) {
@@ -204,6 +211,22 @@ object SessionPrefs {
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
}
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
@JvmStatic
fun guestHostname(context: Context): String =
validGuestHostname(prefs(context).getString("guestHostname", null)) ?: DEFAULT_GUEST_HOSTNAME
fun setGuestHostname(context: Context, name: String) {
val valid = validGuestHostname(name)
prefs(context).edit().apply { if (valid == null) remove("guestHostname") else putString("guestHostname", valid) }.apply()
}
fun validGuestHostname(name: String?): String? {
val trimmed = name?.trim() ?: return null
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
}
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
@@ -225,7 +248,7 @@ object SessionPrefs {
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
/** `steam -steamdeck -steamos3`: the client as SteamOS runs it. Expects Deck hardware; untested. */
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
@@ -291,6 +314,8 @@ object SessionPrefs {
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
@@ -306,6 +331,13 @@ object SessionPrefs {
prefs(context).edit().putString("steamChannel", id).apply()
}
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
fun setRunSteamAtStartup(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("runSteamAtStartup", on).apply()
}
/**
* The folders of the user's own Windows games (one game per subfolder), any number of them
* from anywhere on the device. The single folder an earlier build kept is carried in.
@@ -336,8 +368,8 @@ object SessionPrefs {
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
}
/** The app's colour theme (ui/Themes ids); Paper on black unless chosen otherwise. */
fun theme(context: Context): String = prefs(context).getString("theme", "paper") ?: "paper"
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
fun theme(context: Context): String = prefs(context).getString("theme", "graphite") ?: "graphite"
fun setTheme(context: Context, id: String) {
prefs(context).edit().putString("theme", id).apply()
@@ -58,6 +58,7 @@ import java.util.Locale
*/
open class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var sessionPid = -1
@@ -85,7 +86,7 @@ open class SessionService : Service() {
}
}
/** Counts sessions this service has started; a process exit from an earlier one is ignored. */
private var sessionGen = 0
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
@@ -302,6 +303,202 @@ open class SessionService : Service() {
if (!SessionProcess.hasSiblingProcess(this)) killStragglers()
SessionFiles.stage(this, root)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
val size = SessionState.outputSize
val guest = ArrayList<String>()
// 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, root)
val pulse = startAudio(guest, sessionDir)
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
if (SessionState.mode == MODE_DESKTOP) {
guest.add("BL_DESKTOP_ENV=" + SessionPrefs.desktopEnvironment(this))
} else if (SessionState.mode == MODE_PLASMA_MOBILE) {
guest.add("BL_DESKTOP_ENV=" + SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE)
}
if (SessionState.hdr) {
// The activity opened the compositor's HDR gate: gamescope offers HDR to its clients
// and DXVK takes the HDR10 swapchain when a game asks for one.
guest.add("BL_HDR=1")
guest.add("DXVK_HDR=1")
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
guest.add("BL_FPS=0")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
// through droiddeck-gpu instead. vulkan / gles2 use the app's patched wlroots and fall back
// to pixman by themselves. droiddeck-wlr-renderer in Downloads overrides the choice.
if (SessionState.mode == MODE_DESKTOP) {
val override = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-wlr-renderer")
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this)))
}
// The user's own games, for the runtime's shortcuts writer to put in the client's library
// before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts).
if (steamHere) {
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
guest.add("BL_LAUNCH_DIR=" + sessionRoot.path)
// The second library's name, for bannerlator-steam-library; the bind itself is made below.
GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) }
if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop")
// The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's
// "Switch to Desktop"): the desktop's autostart opens the client's desktop UI there.
if (SessionState.mode == MODE_DESKTOP && SessionState.steamUi == "desktop") guest.add("BL_DESKTOP_STEAM=1")
val shellGuest = ArrayList(guest).apply {
add("SHELL=/bin/bash")
add("TERM=xterm-256color")
add("/bin/bash")
add("-c")
add("cd \"\$HOME\" && exec /bin/bash -i")
}
guest.add(LinuxRuntime.SESSION_SCRIPT)
guest.add(SessionState.mode)
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl?.takeIf { it.startsWith("steam://") }?.let {
guest.add(it)
Log.i(TAG, "steam: handing the client $it")
}
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
// labwc composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
// there (RPCS3 died with VK_ERROR_SURFACE_LOST); gamescope's Xwayland is the path the
// Steam games already render through.
if (SessionState.mode == MODE_RUN) {
val program = SessionState.program
if (program.isNullOrEmpty()) {
Log.e(TAG, "run mode without a program")
stopSession(65)
return
}
guest.add(program)
guest.addAll(SessionState.programArgs)
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
}
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, if (externalDisplay) "shm-external-display" else "shm"))
val binds = sessionBinds(controllersOn, fakeInputDir, root, externalDisplay)
val command = LinuxRuntime.commandForRoot(
this, root, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = HostEnvironment()
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(this).path
hostEnv["PROOT_TMP_DIR"] = cacheDir.path
// proot links against a libtalloc beside it, and Android's linker does not search an
// executable's own directory: unnamed, the process dies before it starts and says so only
// in `logcat -b crash`.
// proot reads this itself, so it belongs in proot's own environment rather than the guest's.
if (SessionPrefs.prootNoSeccomp(this)) {
hostEnv["PROOT_NO_SECCOMP"] = "1"
Log.i(TAG, "proot: seccomp acceleration off by request")
}
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
val terminalCommand = LinuxRuntime.commandForRoot(
this, root, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, shellGuest,
)
val terminalHostEnvironment = LinkedHashMap(System.getenv())
hostEnv.asArray().forEach { entry ->
val separator = entry.indexOf('=')
if (separator > 0) terminalHostEnvironment[entry.substring(0, separator)] = entry.substring(separator + 1)
}
if (gen == sessionGen && SessionState.running) {
SessionTerminal.prepare(terminalCommand, terminalHostEnvironment.map { (key, value) -> "$key=$value" }.toTypedArray(), root, gen)
}
// Whether the client signs in to Valve or starts offline: read once, while it starts, and
// rewritten by the client when it exits, so it is set again here at every session start.
if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root)
val networkLink = LinuxNetworkLinkComponent(this, root)
networkLink.attach(this)
networkLink.publish()
components.add(networkLink)
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while it was starting; not launching it")
if (gen == sessionGen) components.clear()
return
}
components.forEach { it.start() }
val line = command.joinToString(" ") {
it.replace("\\", "\\\\").replace(" ", "\\ ")
}
watchLaunchRequests(sessionRoot)
// One session replacing another (the desktop's Steam launchers): the old proot is killed
// by the teardown a second after the new one has started, and its exit used to arrive
// here as "session ended: 137" and end the NEW session. An exit belongs to the session
// that started it.
val pid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
if (gen != sessionGen) {
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
return@start
}
Log.i(TAG, "session ended: $status")
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while its guest was starting; taking it down")
if (gen == sessionGen) {
launchWatcher?.stopWatching()
launchWatcher = null
components.reversed().forEach { runCatching { it.stop() } }
components.clear()
}
if (pid > 1) Thread({ teardown(pid) }, "session-teardown").start()
return
}
sessionPid = pid
if (pid > 1) {
SessionEvents.guestStarted(pid)
} else {
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", pid)
stopSession(-1)
return
}
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
},
resumeAudio = {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
},
)
mainHandler.post { updateSuspendPolicy() }
}
}
/** The session's own log folder, opened and filled with what is known before anything starts. */
private fun openSessionFolder(): File {
// One folder per session, claimed by whoever started first - the activity starts the
// compositor before this service runs - so the compositor's log lands in the same place.
val sessionDir = SessionPaths.beginOrCurrent(this)
@@ -317,9 +514,11 @@ open class SessionService : Service() {
// Everything the app decides from here on - the driver it chose, the audio line, a rival
// client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it.
SessionLogCapture.start(File(sessionDir, "app.log"))
return sessionDir
}
val size = SessionState.outputSize
val guest = ArrayList<String>()
/** 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, root: File) {
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
@@ -365,9 +564,6 @@ open class SessionService : Service() {
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
// 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
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
@@ -377,15 +573,6 @@ open class SessionService : Service() {
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (steamHere) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -402,7 +589,10 @@ open class SessionService : Service() {
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
}
/** PulseAudio, and the DirectAudio relay when a path needs it; returns the daemon for suspend and resume. */
private fun startAudio(guest: MutableList<String>, sessionDir: File): PulseAudioComponent {
// PulseAudio always: the client is a native Linux program and has no other way to make a
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
// an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it
@@ -446,110 +636,35 @@ open class SessionService : Service() {
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
return pulse
}
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
if (SessionState.mode == MODE_DESKTOP) {
guest.add("BL_DESKTOP_ENV=" + SessionPrefs.desktopEnvironment(this))
} else if (SessionState.mode == MODE_PLASMA_MOBILE) {
guest.add("BL_DESKTOP_ENV=" + SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE)
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
if (SessionState.hdr) {
// The activity opened the compositor's HDR gate: gamescope offers HDR to its clients
// and DXVK takes the HDR10 swapchain when a game asks for one.
guest.add("BL_HDR=1")
guest.add("DXVK_HDR=1")
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
guest.add("BL_FPS=0")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
if (controllersOn) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
SessionState.fakeInputDir = fakeInputDir
}
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
// through droiddeck-gpu instead. vulkan / gles2 use the app's patched wlroots and fall back
// to pixman by themselves. droiddeck-wlr-renderer in Downloads overrides the choice.
if (SessionState.mode == MODE_DESKTOP) {
val override = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-wlr-renderer")
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this)))
}
// The user's own games, for the runtime's shortcuts writer to put in the client's library
// before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts).
if (steamHere) {
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
guest.add("BL_LAUNCH_DIR=" + sessionRoot.path)
// The second library's name, for bannerlator-steam-library; the bind itself is made below.
GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) }
if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop")
// The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's
// "Switch to Desktop"): the desktop's autostart opens the client's desktop UI there.
if (SessionState.mode == MODE_DESKTOP && SessionState.steamUi == "desktop") guest.add("BL_DESKTOP_STEAM=1")
val shellGuest = ArrayList(guest).apply {
add("SHELL=/bin/bash")
add("TERM=xterm-256color")
add("/bin/bash")
add("-c")
add("cd \"\$HOME\" && exec /bin/bash -i")
}
guest.add(LinuxRuntime.SESSION_SCRIPT)
guest.add(SessionState.mode)
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl?.takeIf { it.startsWith("steam://") }?.let {
guest.add(it)
Log.i(TAG, "steam: handing the client $it")
}
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
// labwc composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
// there (RPCS3 died with VK_ERROR_SURFACE_LOST); gamescope's Xwayland is the path the
// Steam games already render through.
if (SessionState.mode == MODE_RUN) {
val program = SessionState.program
if (program.isNullOrEmpty()) {
Log.e(TAG, "run mode without a program")
stopSession(65)
return
}
guest.add(program)
guest.addAll(SessionState.programArgs)
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
}
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, if (externalDisplay) "shm-external-display" else "shm"))
SessionState.fakeInputDir = fakeInputDir
}
/** What proot binds into the guest: the pads, the battery, storage, the game library, added games and ROMs. */
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File, root: File, externalDisplay: Boolean): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
// The client's battery readout (the Quick Access Menu, the top bar) reads
@@ -569,7 +684,7 @@ open class SessionService : Service() {
// tree and could not find the phone at all. The same storage is placed under home as
// well, and the ROMs folder chosen on the main screen beside it; a bind rather than a
// link, so a folder on an SD card works the same.
val home = if (externalDisplay) File(filesDir, "plasma-mobile-home") else File(LinuxRuntime.rootDir(this), "root")
val home = if (externalDisplay) File(filesDir, "plasma-mobile-home") else File(root, "root")
home.mkdirs()
File(home, "Storage").mkdirs()
if (externalDisplay) binds.add("${home.path}:/root")
@@ -609,85 +724,7 @@ open class SessionService : Service() {
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
val command = LinuxRuntime.commandForRoot(
this, root, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = HostEnvironment()
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(this).path
hostEnv["PROOT_TMP_DIR"] = cacheDir.path
// proot links against a libtalloc beside it, and Android's linker does not search an
// executable's own directory: unnamed, the process dies before it starts and says so only
// in `logcat -b crash`.
// proot reads this itself, so it belongs in proot's own environment rather than the guest's.
if (SessionPrefs.prootNoSeccomp(this)) {
hostEnv["PROOT_NO_SECCOMP"] = "1"
Log.i(TAG, "proot: seccomp acceleration off by request")
}
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
val terminalCommand = LinuxRuntime.commandForRoot(
this, root, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, shellGuest,
)
val terminalHostEnvironment = LinkedHashMap(System.getenv())
hostEnv.asArray().forEach { entry ->
val separator = entry.indexOf('=')
if (separator > 0) terminalHostEnvironment[entry.substring(0, separator)] = entry.substring(separator + 1)
}
if (gen == sessionGen && SessionState.running) {
SessionTerminal.prepare(terminalCommand, terminalHostEnvironment.map { (key, value) -> "$key=$value" }.toTypedArray(), root, gen)
}
// Whether the client signs in to Valve or starts offline: read once, while it starts, and
// rewritten by the client when it exits, so it is set again here at every session start.
if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root)
val networkLink = LinuxNetworkLinkComponent(this, root)
networkLink.attach(this)
networkLink.publish()
components.add(networkLink)
components.forEach { it.start() }
val line = command.joinToString(" ") {
it.replace("\\", "\\\\").replace(" ", "\\ ")
}
watchLaunchRequests(sessionRoot)
// One session replacing another (the desktop's Steam launchers): the old proot is killed
// by the teardown a second after the new one has started, and its exit used to arrive
// here as "session ended: 137" and end the NEW session. An exit belongs to the session
// that started it.
sessionPid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
if (gen != sessionGen) {
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
return@start
}
Log.i(TAG, "session ended: $status")
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
if (gen == sessionGen && SessionState.running && sessionPid > 1) {
SessionEvents.guestStarted(sessionPid)
} else if (gen == sessionGen && SessionState.running) {
SessionEvents.record("guest.start_failed", mapOf("pid" to sessionPid))
SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", sessionPid)
stopSession(-1)
return
}
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
},
resumeAudio = {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
},
)
mainHandler.post { updateSuspendPolicy() }
}
return binds
}
private fun teardown(prootPid: Int, expectedStartTime: Long? = null) {
@@ -745,6 +782,24 @@ open class SessionService : Service() {
return try { Pair(fields[1].toInt(), fields[19].toLong()) } catch (e: NumberFormatException) { null }
}
private fun askSteamToExit(prootPid: Int) {
if (!File("/proc/$prootPid").exists()) return
val request = File(LinuxRuntime.sessionRoot(this), "steam-stop")
try {
request.writeText("")
} catch (e: Exception) {
Log.w(TAG, "could not ask the Steam client to exit", e)
return
}
val started = System.currentTimeMillis()
if (waitForExit(prootPid, STEAM_PICKUP_MS) || request.exists()) {
request.delete()
return
}
val exited = waitForExit(prootPid, STEAM_EXIT_MS)
Log.i(TAG, "Steam client ${if (exited) "exited" else "did not exit"} after ${System.currentTimeMillis() - started} ms")
}
private fun waitForExit(pid: Int, timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
@@ -760,6 +815,30 @@ open class SessionService : Service() {
* behind, still holding the Wayland socket and the audio server the next session needs. They
* are our uid, so they are ours to kill.
*/
/**
* Every process of ours still running a program out of the Linux runtime once proot is gone.
* proot's --kill-on-exit and the tree sweep only reach what proot still traces; a tracee it
* lost - an Xwayland that aborted from one thread and then looped on a syscall proot's own
* seccomp filter, with no tracer left, answers ENOSYS - survives both, reparented to init,
* and wrote 9 GB of one line into the session log before anything killed it.
*/
private fun killGuestLeftovers() {
val me = android.os.Process.myPid()
val rootfs = try { File(filesDir, "linuxfs").canonicalPath } catch (e: Exception) { return } + "/"
val procs = File("/proc").listFiles { f -> f.name.all { it.isDigit() } } ?: return
var killed = 0
for (proc in procs) {
val pid = proc.name.toIntOrNull() ?: continue
if (pid == me) continue
// Another uid's exe is not ours to read; a process already gone has none.
val exe = try { File(proc, "exe").canonicalPath } catch (e: Exception) { continue }
if (!exe.startsWith(rootfs)) continue
android.os.Process.killProcess(pid)
killed++
}
if (killed > 0) Log.w(TAG, "killed $killed guest process(es) proot no longer tracked")
}
private fun killStragglers() {
val me = android.os.Process.myPid()
val procs = File("/proc").listFiles { f -> f.name.all { it.isDigit() } } ?: return
@@ -872,8 +951,12 @@ open class SessionService : Service() {
}
}
private fun finishSessionStop(status: Int) {
private fun finishSessionStop(status: Int, stoppedGen: Int) {
mainHandler.post {
if (stoppedGen != sessionGen || SessionState.running) {
Log.i(TAG, "session $stoppedGen finished stopping after a new one started")
return@post
}
SessionState.suspended = false
SessionState.guestPid = -1
releaseLocks()
@@ -893,9 +976,12 @@ open class SessionService : Service() {
}
private fun stopSession(status: Int) {
if (!SessionState.running) return
if (!SessionProcess.isExternalDisplay(this)) stopExternalDisplaySession(this)
SessionState.running = false
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
}
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
@@ -934,17 +1020,20 @@ open class SessionService : Service() {
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
val finishAfterTeardown: () -> Unit = {
if (prootPid > 1 || auxiliary.isNotEmpty()) {
Thread({
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
stopPlasmaShell(plasmaShellPid, ended)
finishSessionStop(status)
killGuestLeftovers()
finishSessionStop(status, stoppedGen)
}, "session-teardown").start()
} else {
stopPlasmaShell(plasmaShellPid, ended)
finishSessionStop(status)
finishSessionStop(status, stoppedGen)
}
}
if (prootPid > 1) acquireLocks()
@@ -1111,6 +1200,8 @@ open class SessionService : Service() {
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
/** How long proot gets to run its own cleanup before it is killed outright. */
private const val GRACE_MS = 1200L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
@@ -1147,6 +1238,10 @@ open class SessionService : Service() {
}
fun stop(context: Context) {
if (!SessionState.running) {
SessionState.stopRequested = true
return
}
context.startService(Intent(context, currentServiceClass()).setAction(ACTION_STOP))
}
@@ -0,0 +1,434 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
import java.text.DateFormat
import java.util.Date
/** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */
private class InstalledItem(
val name: String, val detail: String, val tag: String,
val selected: Boolean, val removable: Boolean,
/** Stored package file, or null for an original. */
val file: String?,
/** The Proton build an original belongs to. */
val protonVersion: String?,
)
private val GOLD = Color(0xFFF2C66D)
private val ROW_SHAPE = RoundedCornerShape(10.dp)
/**
* FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry.
* A title row with the Nightlies refresh, then the Proton and the component (LB / RB turn it), what
* is in use, and the installed packages beside the ones on the Nightlies - stacked when the page is
* narrow. Tapping an installed row swaps it in; the refresh button is the only thing that goes online.
*/
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
fun ComponentsPage(
snapshot: Snapshot?,
catalog: List<CatalogItem>,
catalogAt: Long,
protonId: String?,
comp: String,
checking: Boolean,
busy: String?,
downloads: Map<String, Int>,
onProton: (String) -> Unit,
onComp: (String) -> Unit,
onSwap: (String) -> Unit,
onRestore: (String) -> Unit,
onCancelQueued: () -> Unit,
onDeletePackage: (String) -> Unit,
onDeleteOriginal: (String) -> Unit,
onDownload: (CatalogItem) -> Unit,
onRefresh: () -> Unit,
onImport: () -> Unit,
onBack: () -> Unit,
requestInitialFocus: Boolean = true,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
BackHandler(onBack = onBack)
var confirm by remember { mutableStateOf<Pair<String, () -> Unit>?>(null) }
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 }
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 (busy != null) "$busy…" else "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,
maxLines = 1, modifier = Modifier.weight(1f),
)
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
// ---- which Proton, which component ------------------------------------------------------
val comps = ComponentsManager.COMPONENTS
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (view != null) 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 ->
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 ""),
focusRequester = if (i == 0) first else null,
) { onProton(v.proton.id); protonMenu = false }
}
}
}
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
}
// ---- what is in use -----------------------------------------------------------------------
if (view != null) {
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 ""
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface)
.border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp),
) {
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,
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)
else if (!narrow) Text(
if (view.inUseByGame) "Waits until the game closes" else "Changes apply the next time a game starts",
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1,
)
}
}
// ---- the lists ------------------------------------------------------------------------------
when {
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)
}
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.",
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp),
)
else -> {
val p = view.proton
val st = view.components.getValue(comp)
val build = ComponentsManager.safeName(p.version)
val originals = view.originals.filter { it.comp == comp }
val stored = snapshot.packages.filter { it.comp == comp }
val activeIsOriginal = st.activeFile == null
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",
"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)
}
val storedNames = snapshot.packages.map { it.file }.toSet()
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
val first = remember { FocusRequester() }
LaunchedEffect(p.id, comp, requestInitialFocus) {
if (requestInitialFocus) runCatching { first.requestFocus() }
}
val installed: @Composable () -> Unit = {
SettingsGroup("Installed · ${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.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
)
rows.forEachIndexed { i, item ->
InstalledLine(
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!!) }
},
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!!) }
},
)
}
}
}
val nightly: @Composable () -> Unit = {
SettingsGroup("On the Nightlies · ${available.size}") {
if (available.isEmpty()) Text(
if (catalogAt == 0L) "Refresh to list the packages on the Nightlies." else "Nothing new since the last check.",
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) } }
}
}
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
if (narrow) { installed(); nightly() }
else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { installed() }
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) }
}
}
}
}
}
}
if (about) AlertDialog(
onDismissRequest = { about = false },
title = { Text("Components") },
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.",
fontSize = 14.sp,
)
},
confirmButton = { FocusText("OK", pal.signal) { about = false } },
)
confirm?.let { (body, action) ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(confirmTitle) },
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() }
},
dismissButton = {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
},
)
}
}
@Composable
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(20.dp))
}
}
/**
* A small button. Resting: grey outline (blue text when [accent]). Focused or hovered: a solid blue
* fill with an outline in the text colour - unmistakable in a column of identical buttons.
*/
@Composable
private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant else if (hot) pal.onSignal else if (accent) pal.signal else colors.onBackground,
)
}
}
@Composable
private fun Tag(text: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val (fg, bg) = when (text) {
"ORIGINAL" -> GOLD to Color(0x22F2C66D)
"IN USE" -> pal.onSignal to pal.signal
"NEW" -> pal.good to Color(0x224CD37F)
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,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(bg).padding(horizontal = 7.dp, vertical = 1.dp),
)
}
@Composable
private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) {
if (item.selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape))
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
Tag(item.tag)
}
Text(item.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp))
}
if (item.selected) Tag("IN USE")
}
// The trash column is always there (empty when the row can't be deleted) so rows line up.
if (!item.removable) Spacer(Modifier.padding(end = 6.dp).size(40.dp))
else {
val delSrc = remember { MutableInteractionSource() }
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
.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)) }
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@Composable
private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp),
) {
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,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp),
)
}
if (progress == null) SmallButton("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))
if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@@ -41,12 +41,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
SettingsRow("Colour", "Tint of the on-screen buttons", highlighted = tintOpen) {
SettingsRow("Color", "Tint of the on-screen buttons", highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
}
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Colour") { firstItemFocus ->
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "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)
@@ -59,7 +59,7 @@ 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", "A stick centres where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
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") {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton("Edit") { a.onEditLayout() }
@@ -68,7 +68,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
}
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 colour, opacity, size, stick behaviour, mapping and layout", "Reset", a.onResetAll)
ActionRow("Reset controller", "Restore the default color, opacity, size, stick behavior, mapping and layout", "Reset", a.onResetAll)
}
@Composable
@@ -194,12 +194,12 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
if (row.tag.isNotEmpty()) Text(
row.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
row.tag, fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
}
if (row.removable) {
@@ -221,7 +221,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
/** A text button that shows where controller focus is: an outline in its own colour. */
@Composable
private fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
@@ -0,0 +1,22 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import com.droiddeck.launcher.gpu.FrameGen
/**
* The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup,
* a game's launch settings and the session drawer, so they say the same thing.
*/
@Composable
internal fun FrameGenMenu(
host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean,
onPick: (engine: String, multiplier: Int) -> Unit,
) {
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam"
fun pick(e: String, m: Int) { onPick(e, m); host.open = null }
MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) }
}
}
@@ -0,0 +1,186 @@
package com.droiddeck.launcher.ui
import android.graphics.Bitmap
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.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 kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import java.io.File
// Cover art: posters, the art grid and its tiles.
internal class Tile(
val title: String, val sub: String?, val art: File?, val key: String,
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
val showFooter: Boolean = true,
val onClick: () -> Unit,
)
@Composable
internal fun Poster(art: File?, name: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) }
Box(
modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f)
.graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false }
.clip(Shape12).background(artBrush(hueOf(name))),
) {
if (art != null) CoverImage(art, Modifier.fillMaxSize())
else Text(name, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) }
// Big enough to recognise a game by its art and read its name under it; icon tiles are squares.
val minSize = if (square) 76.dp else if (wide) 120.dp else 96.dp
val gap = 12.dp
// Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy
// grid composes only the rows on screen, so a press towards the next row bounced back among
// the visible tiles. A few hundred tiles lay out fine; the page's scroll follows the focused one.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val avail = maxWidth - 8.dp
val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1)
val tileWidth = (avail - gap * (cols - 1)) / cols
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) {
for (row in tiles.chunked(cols)) {
Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) {
for (t in row) key(t.key) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
}
}
}
}
}
}
@Composable
private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableInteractionSource, hot: Boolean, track: Modifier) {
val colors = MaterialTheme.colorScheme
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "tileScale")
val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift")
val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev")
val pal = LocalPalette.current
val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing")
Column(
modifier = track
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) }
.clip(Shape12)
.background(colors.surface)
.border(2.5.dp, ring, Shape12)
.alpha(if (t.dim && !hot) 0.55f else 1f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
) {
Box(modifier = Modifier.fillMaxWidth().shine(hot)) {
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap)
}
if (t.showFooter) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (t.sub != null) Text(t.sub, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
/**
* Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header -
* is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop.
*/
@Composable
private fun CoverImage(art: File, modifier: Modifier) {
var wideArt by remember(art) { mutableStateOf(false) }
Box(modifier) {
if (wideArt) {
AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp),
)
Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)))
}
AsyncImage(
model = art, contentDescription = null,
contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop,
onSuccess = { state ->
val size = state.painter.intrinsicSize
if (size.width > size.height * 1.1f) wideArt = true
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
val colors = MaterialTheme.colorScheme
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
when {
art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
else CoverImage(art, Modifier.fillMaxSize())
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
else -> {
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f))))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(6.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
}
@@ -0,0 +1,409 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.CompositionLocalProvider
import android.os.Build
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.Spacer
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import java.io.File
// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators.
@Composable
internal fun Pane(
s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier,
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) {
val backdropArt: File? = when {
s.pageKey != null && page != null -> null
selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }?.art
selected == "games" -> s.steamGames.maxByOrNull { it.lastPlayed }?.art
selected.startsWith("rom:") -> romFor(s, selected)?.second?.art
else -> null
}
Backdrop(backdropArt)
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,
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 }
},
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)
}
}
}
}
@Composable
private fun Backdrop(art: File?) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Crossfade(targetState = art, animationSpec = Motion.tw(700), label = "backdrop") { a ->
// A game's own art, blurred, where it has some; otherwise the plain ground with one quiet
// glow of the theme's signal colour - a colour per title hash read as noise.
if (a != null && Build.VERSION.SDK_INT >= 31) {
Box(modifier = Modifier.fillMaxSize().alpha(0.26f).blur(70.dp)) {
AsyncImage(model = a, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f })
}
} else {
Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(pal.signal.copy(alpha = 0.07f), Color.Transparent), center = Offset.Zero, radius = 1400f)))
}
}
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f)))))
}
private fun romFor(s: FrontEndState, selected: String): Pair<Library.Emulator, Library.Rom>? {
val parts = selected.split(":")
val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } ?: return null
val g = e.games.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) ?: return null
return e to g
}
@Composable
private fun Content(
s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier,
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
// Setup scrolls inside itself, under its tabs.
if (selected == "setup") {
Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) {
SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
}
return
}
// Steam is a full-bleed wall of the library with Play over it.
if (selected == "steam") {
SteamHome(s, a, modifier)
return
}
// The Games tab lays out its own list and detail.
if (selected == "games" || selected.startsWith("app:")) {
GamesPage(s, a, selected, onSelect, modifier)
return
}
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
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.") }
else Rise(3, Modifier.fillMaxWidth()) {
ArtGrid(s.androidApps.map { app ->
Tile(
app.label,
null,
null,
"android:${app.packageName}",
onClick = { onAndroidAppClick(app) },
iconBitmap = app.icon,
)
})
}
}
selected == "desktop" -> {
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
Rise(0) {
PageHeader("Desktop") {
if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on 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) }
}
if (available.isNotEmpty()) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
}
}
selected.startsWith("emu:") -> {
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
if (e == null) Note("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") }
}
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
) {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
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",
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
}
}
if (e.installed) {
Rise(3) {
Actions {
PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) }
BusyChip(s)
SecondaryButton("ROMs folder", onClick = a.onRoms)
if (pkg != null) SecondaryButton(
if (pkg.kind == "appimage") "Remove" else "Hide",
enabled = s.packageBusyId == null && !s.sessionRunning,
) { a.onRemovePackage(pkg.id) }
}
}
Rise(4) { SectionTitle("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(' ')}.",
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
}
} else {
if (pkg != null) Rise(2) {
Actions {
PrimaryButton(
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${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)
}
}
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."
pkg.notes.isNotBlank() -> pkg.notes
else -> "Install ${e.name} into the Linux desktop runtime."
})
}
}
if (s.packageBusyId == pkg?.id) Rise(4) {
val stage = s.packageStage
Text(
if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "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.") }
}
}
}
selected.startsWith("rom:") -> {
val pair = romFor(s, selected)
if (pair == null) Note("That game is gone from the ROMs folder.") 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}")
}
}
Rise(1) { Title(g.name) }
Rise(2) {
Row {
Column(modifier = Modifier.weight(1f)) {
Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp))
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) }
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
}
}
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
}
}
val others = e.games.filter { it !== g }
if (others.isNotEmpty()) {
Rise(3) { SectionTitle("Also in ${e.name}", null) }
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
}, wide = others.none { it.art != null })
}
}
}
}
else -> Note("That page is gone. Pick a section on the left.")
}
}
}
/** The Linux desktop itself, above the emulators that run in it. */
@Composable
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
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)
Cog(onClick = a.onDesktopSettings)
BusyChip(s)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
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)
}
if (!narrow) actions()
}
if (narrow) actions()
}
}
/** Emulators as wide tiles - three across, or a list on a narrow page. */
@Composable
private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSelect: (String) -> Unit) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
emulators.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}") }
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
/**
* 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.
*/
@Composable
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)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
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,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@@ -0,0 +1,271 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
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.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.frontend.Library
// The Games page: the list, the hero for the selected game and its labels.
/**
* The Games tab: installed Steam games down the left, most recently played first, and the one
* picked beside them - its banner, Launch and the launch settings. One game skips the list.
*/
@Composable
internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val narrow = LocalNarrowPane.current
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(2) {
Actions { PrimaryButton("Play Steam", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
}
return
}
val host = rememberMenuHost()
if (games.size == 1) {
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
GameActions(current, s, a)
}
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
}
Rise(1) { SectionTitle("Launch settings", null) }
Rise(2) { LaunchSettings(s, a, host) }
}
return
}
val pal = LocalPalette.current
Row(modifier = modifier) {
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) },
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(
modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState())
.padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp),
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a)
}
SectionTitle("Launch settings", null)
LaunchSettings(s, a, host)
}
}
}
@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) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Game files", compact = true) { a.onBrowseFiles(dir) }
}
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
}
BusyChip(s)
}
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
Text(
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
@Composable
private fun GameList(
games: List<Library.SteamGame>, current: Library.SteamGame,
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
// Laid out whole, as the art grid is: the pad's focus search only finds rows that exist.
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
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(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
}
for (g in games) key(g.appId) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
}
}
}
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
@Composable
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
LaunchedEffect(focused) { if (focused && !selected) onSelect() }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
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)
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
.controllerConfirm(onClick = onLaunch)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) {
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
}
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",
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
/** A page's title, with room at its right for a status chip. */
@Composable
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
) {
Text(
title, fontSize = if (LocalNarrowPane.current) 22.sp else 26.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 1,
)
trailing()
}
}
/** 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)
}
/** 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)
}
/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */
private fun lastPlayedText(lastPlayed: Long): String? {
if (lastPlayed <= 0L) return null
val span = android.text.format.DateUtils.getRelativeTimeSpanString(
lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString()
return "Last played " + span.replaceFirstChar { it.lowercase() }
}
private fun libraryLabel(library: String): String = when (library) {
"internal" -> "Internal storage"
"added" -> "Added game"
else -> library
}
/**
* A game's wide banner: Steam's hero art where the client cached one, else its capsule blurred to
* fill the width. The copy sits bottom-left over a scrim of the ground colour so it always reads;
* the art takes the banner's size, so a banner given a minimum height grows to fit its copy.
*/
@Composable
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
val image = g.hero ?: g.art
if (image != null) AsyncImage(
model = image, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
.then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier),
)
Spacer(
Modifier.matchParentSize().background(
Brush.horizontalGradient(
0f to colors.background.copy(alpha = 0.92f),
0.55f to colors.background.copy(alpha = 0.6f),
1f to Color.Transparent,
),
),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
content = content,
)
}
}
@@ -0,0 +1,261 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
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.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
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.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)
}
}
internal val AttentionAmber = Color(0xFFFFB547)
/**
* The launcher's sections, always on screen down the left edge: the app is landscape-only, so the
* width a hamburger menu would save is better spent keeping every section one press away.
*/
@Composable
internal fun SideRail(
s: FrontEndState, selected: String,
onSelect: (String) -> Unit, onFocusSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
val compact = LocalConfiguration.current.screenHeightDp < 420
// Under 600dp wide (4:3 and square screens) the labels would cost a fifth of the width.
val iconOnly = isNarrowScreen()
val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) ||
PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
Image(painterResource(R.drawable.logo), contentDescription = "DroidDeck", modifier = Modifier.size(if (compact) 28.dp else 34.dp))
Spacer(Modifier.height(if (compact) 6.dp else 12.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
) {
RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, 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, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
}
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
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))
}
}
var lastRunning by remember { mutableStateOf("") }
if (s.running != null) lastRunning = s.running
AnimatedVisibility(
s.running != null,
enter = expandVertically(Motion.sp(0.75f)) + fadeIn(Motion.tw(300)),
exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)),
) { ResumeRailItem(lastRunning, compact, iconOnly, a.onResume) }
}
}
@Composable
private fun RailItem(
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean,
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val frontFocus = LocalFrontFocus.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val pressed by src.collectIsPressedAsState()
if (frontFocus != null) LaunchedEffect(focused) {
if (focused) {
frontFocus.focusedRail = key
onFocus()
}
else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null
}
val fg by animateColorAsState(
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
Motion.tw(220), label = "railFg",
)
val fill by animateColorAsState(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing")
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
.clip(Shape14)
.background(fill)
.border(2.dp, ring, Shape14)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
) {
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (compact) 20.dp else 22.dp))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
}
if (badge) Box(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
.size(8.dp).clip(CircleShape).background(AttentionAmber),
)
}
}
/** The running session, one press from anywhere in the launcher. */
@Composable
private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
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")
// Animations off: the live dot alone, no pulsing ring.
val still = Motion.scale == 0f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier
.padding(top = 6.dp)
.width(if (iconOnly) 52.dp else 80.dp)
.clip(Shape14)
.background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f))
.border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
.semantics { contentDescription = "Resume $name" }
.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) {
if (!still) Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape))
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good))
}
// 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(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,434 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.padding
import androidx.compose.foundation.layout.size
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.filled.Refresh
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.VideogameAsset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
private enum class CheckState { OK, WARN, BUSY }
/** One requirement in Setup's system check: a status mark, what it is, and at most one action. */
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when (state) {
CheckState.OK -> pal.good
CheckState.WARN -> AttentionAmber
CheckState.BUSY -> pal.signal
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
.background(if (state == CheckState.WARN) AttentionAmber.copy(alpha = 0.07f) else Color.Transparent)
.heightIn(min = 60.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(tint.copy(alpha = 0.16f))) {
Icon(
when (state) {
CheckState.OK -> Icons.Filled.Check
CheckState.WARN -> Icons.Filled.PriorityHigh
CheckState.BUSY -> Icons.Filled.Refresh
},
contentDescription = when (state) {
CheckState.OK -> "Done"
CheckState.WARN -> "Needs attention"
CheckState.BUSY -> "Working"
},
tint = tint, modifier = Modifier.size(16.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (detail != null) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (action != null) action()
}
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@Composable
internal fun SetupPanel(
s: FrontEndState,
a: FrontEndActions,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
) {
val host = rememberMenuHost()
var processLimitBusy by remember { mutableStateOf(false) }
var processLimitMessage by remember { mutableStateOf<String?>(null) }
val setProcessLimit: (Boolean) -> Unit = { enabled ->
processLimitBusy = true
processLimitMessage = null
a.onSetPhantomProcessLimit(enabled) { error ->
processLimitBusy = false
if (error == null) {
a.onRefreshPhantomStatus()
} else {
processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device."
onRequestWirelessAdb(enabled)
}
}
}
val runtime = when {
s.busy -> "Working…"
!s.ready -> "Install"
s.available != null && s.available != s.installed -> "Update"
else -> "Manage"
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val gpuOk = remember { DeviceSupport.adreno() }
val gpuName = remember { DeviceSupport.gpuName() }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
// Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page.
val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About")
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
// The control a controller was on went with the old tab: it lands on the new tab itself.
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
runCatching { tabFocus[tab].requestFocus() }
}
}
Rise(0, Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().bumpers(
onPrevious = { pick((tab + tabs.size - 1) % tabs.size) },
onNext = { pick((tab + 1) % tabs.size) },
),
) {
PageHeader("Setup") {
Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", 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)
// 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,
if (gpuOk) "Device supported" else "GPU not supported",
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
)
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
"Linux 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"
},
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
"Child-process 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"
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails }
}
}
AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
// 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)) {
Text(
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
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)
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
}
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
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",
divider = false,
)
}
SectionTitle("Tools", null)
ToolGrid(s, a)
}
1 -> {
val controller = s.controller
if (controller != null && a.controller != null) SettingsGroup("Controller") {
ControllerRows(host, s.oscMode, controller, a.controller)
}
if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.")
}
2 -> {
SettingsGroup("Session") {
ChoiceRow(
host, "back-actions", "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,
)
SettingsRow("Frame generation", "Select the frame generation mode") {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
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)
ToggleRow(
host, "offline", "Offline mode",
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup("Launcher") {
SettingsRow("Theme", "Choose the launcher appearance") {
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 ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
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",
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",
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
if (s.homeScreenEnabled) {
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
}
}
}
else -> {
SettingsGroup("About") {
ActionRow("Build", s.buildLabel, "Check for newer", a.onCheckLatestBuild)
ActionRow("Credits", "The people and projects DroidDeck builds on", "View", a.onCredits)
}
}
}
}
}
}
}
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
@Composable
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),
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
for (row in tools.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
@Composable
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
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 = "toolScale")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.paneItem("tool:${t.title}")
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
}
/**
* What shapes a launch, beside the game rather than three screens away in Setup. They are the
* app-wide settings - each card opens the same page or menu Setup does.
*/
@Composable
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
// Three across, two on a narrow page; each row's cards share one height.
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 ->
Box(m) {
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
host.open = if (host.open == "fg") null else "fg"
}
FrameGenMenu(s, a, host)
}
}
if (controller != null) add { m -> SettingCard("Controls", "Button mapping", "card:controls", m, controller.onMapping) }
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (card in row) card(Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, 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 = "cardScale")
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge")
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier.paneItem(id)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, edge, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@@ -0,0 +1,237 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import android.provider.Settings
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// Small building blocks shared by the front end's pages: text styles, chips and buttons.
internal val Shape14 = RoundedCornerShape(14.dp)
@Composable
internal fun Eyebrow(t: String) {
val pal = LocalPalette.current
val rule = remember { Animatable(0f) }
LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal))
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
}
}
@Composable internal fun Title(t: String) = Text(t, fontSize = 26.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp))
@Composable internal fun Lede(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp))
@Composable
internal fun SectionTitle(t: String, detail: String?) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) {
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground)
Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
}
}
@Composable internal fun Note(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().clip(Shape12).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line2, Shape12).padding(12.dp))
/** A page's buttons: they wrap onto a second line rather than run off a narrow page. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun Actions(content: @Composable () -> Unit) = FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) { content() }
/** A status chip in a row of buttons, centred on their height. */
@Composable
internal fun ActionChip(t: String, ok: Boolean) = Box(contentAlignment = Alignment.Center, modifier = Modifier.heightIn(min = 44.dp)) { Chip(t, ok) }
internal val Shape16 = RoundedCornerShape(16.dp)
/** One level up, as Back and B do: a real button, big enough to hit ([compact]: for the tightest layouts). */
@Composable
internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(22.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp)
.clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(start = 8.dp, end = 16.dp),
) {
Icon(Icons.Filled.ChevronLeft, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp))
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
internal fun Chip(t: String, ok: Boolean) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Text(
t, fontSize = 12.sp, color = if (ok) pal.good else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(colors.surfaceVariant).border(1.dp, if (ok) pal.good.copy(alpha = 0.3f) else pal.line, RoundedCornerShape(99.dp)).padding(horizontal = 9.dp, vertical = 4.dp),
)
}
@Composable
internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
@Composable
internal fun PrimaryButton(
text: String,
enabled: Boolean = true,
main: Boolean = false,
compact: Boolean = false,
modifier: Modifier = Modifier,
/** The Steam tab's own Play: bigger than any other button on a page. */
large: Boolean = false,
icon: ImageVector? = null,
onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// The page's main button is where the pane is entered from the rail.
val frontFocus = if (main) LocalFrontFocus.current else null
if (frontFocus != null) DisposableEffect(Unit) {
frontFocus.primaryAttached++
onDispose { frontFocus.primaryAttached-- }
}
val track = (if (frontFocus == null) Modifier.paneItem("btn:$text") else Modifier).downToFirstTile()
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "btnScale")
val lift by animateFloatAsState(if (hot) 14f else 6f, Motion.tw(300), label = "btnLift")
// Where it sits, so a session it starts can grow out of it (LaunchFlood).
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.then(track)
.onGloballyPositioned { placed[0] = it }
// 48dp to touch on a handheld; compact rows keep 44.
.heightIn(min = if (compact) 44.dp else 48.dp)
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier)
.graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.clip(Shape12)
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
.shine(hot, 0.45f)
// The grow and shine alone barely show on the light fill: outline it when a
// controller is on it, as the other controls are.
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
onClick()
}
.padding(
start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp,
end = if (large) 26.dp else if (compact) 10.dp else 18.dp,
top = if (large) 16.dp else if (compact) 7.dp else 11.dp,
bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp,
),
) {
val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant
if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (large) 20.dp else 17.dp))
Text(
text, fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp,
fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, maxLines = 1,
)
}
}
@Composable
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge")
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
Box(
contentAlignment = Alignment.Center,
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp),
) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) }
}
@Composable
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")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge")
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)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12)
.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)) }
}
@@ -0,0 +1,234 @@
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.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
var open by remember { mutableStateOf(false) }
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))
}
}
if (open) GameEnvironmentEditor { open = false }
}
@Composable
private fun GameEnvironmentEditor(onClose: () -> Unit) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
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 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)
LaunchedEffect(Unit) {
runCatching {
withContext(Dispatchers.IO) {
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
}
}.onSuccess { (settings, installed) -> config = settings; games = installed }
.onFailure { error = true }
}
fun save(next: GameEnvironment.Config) {
busy = true
coroutine.launch {
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
result.onSuccess { config = next; error = false }.onFailure { error = true }
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 })
}
}
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))
}
}
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
}
},
)
editing?.let { (name, value) ->
VariableDialog(name, value, onDismiss = { editing = null }) { key, content ->
config?.let {
val entries = it.entries(scope).toMutableMap()
if (name.isNotEmpty() && name != key) entries[name] = null
entries[key] = content
save(it.withEntries(scope, entries))
}
editing = null
}
}
}
@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)
}
},
)
}
@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)
}
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
})
}
}
}
if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true,
label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth())
}
@Composable
private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) {
val option = GameEnvironmentOptions.find(name)
var expanded 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())
})
}
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
}
})
}
}
}
if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
}
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())
}
}
@@ -0,0 +1,276 @@
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.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.hypot
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.snapshotFlow
import androidx.compose.animation.core.spring
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.Dp
import java.lang.ref.WeakReference
import kotlin.math.PI
import kotlin.math.sin
// Starting a session from a blue button: the button grows until the page is its blue, and the
// 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.
/** Carries the flood's colour to the session, which opens on it. */
const val EXTRA_FLOOD = "com.droiddeck.launcher.FLOOD"
/** Where the last pressed blue button sat, so a session started by it can grow out of it. */
internal object LaunchOrigin {
private var bounds: Rect? = null
private var button: Any? = null
private var at = 0L
/** The last button a session was started from, for a stop to return into. */
private var last: WeakReference<Any>? = null
/** The button a flood is growing out of: it steps aside so only the flood shows. */
var flooding by mutableStateOf<Any?>(null)
fun mark(r: Rect, key: Any) {
bounds = r
button = key
last = WeakReference(key)
at = SystemClock.uptimeMillis()
}
/**
* Where the button the running session was started from sits now, if it is still on screen;
* it hides for the return, as for the flood.
*/
fun takeReturn(): Rect? {
val key = last?.get() as? Array<*> ?: return null
val c = key.getOrNull(0) as? LayoutCoordinates ?: return null
if (!c.isAttached) return null
flooding = key
return c.boundsInRoot()
}
/** The button behind a launch starting now, if one was pressed just before; it hides for the flood. */
fun take(): Rect? {
val b = bounds?.takeIf { SystemClock.uptimeMillis() - at < 1_500 }
if (b != null) flooding = button
bounds = null
button = null
return b
}
}
/** A session stopped behind a flood: the blue the front end opens on, for a few seconds. */
internal object QuitFlood {
private var color: Int? = null
private var at = 0L
fun mark(argb: Int) {
color = argb
at = SystemClock.uptimeMillis()
}
fun take(): Int? {
val c = color?.takeIf { SystemClock.uptimeMillis() - at < 3_000 }
color = null
return c
}
}
/** The page a flood grows over, sinking back a touch as it is covered; [progress] is the flood's. */
@Composable
internal fun FloodBehind(progress: () -> Float, content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize().graphicsLayer { val k = sinkScale(progress()); scaleX = k; scaleY = k }) { content() }
}
private fun sinkScale(progress: Float) = 1f - 0.04f * progress
private val Gather = CubicBezierEasing(0.6f, 0f, 0.15f, 1f)
/**
* The pressed button, [from] in root px, stretching out to cover the page. Each edge rides its own
* loose spring: the ones with furthest to go set off first and quickest, the ones near the page's
* edge lag and then snap after them, so it pulls like something elastic rather than zooming. It
* goes blobby in flight, its gradient settles into the flat blue of the ball, and [onProgress]
* (0 to 1) lets the page behind sink a little. Calls [onCovered] once nothing but blue shows.
* Swallows touches while it runs.
*/
@Composable
internal fun LaunchFlood(
from: Rect,
onProgress: (Float) -> Unit,
/** The button's own colours, where it is not the page's blue one (a red Stop). */
fromColors: Pair<Color, Color>? = null,
cornerAtRest: Dp = 12.dp,
onCovered: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val start = fromColors ?: (colors.primary to pal.primary2)
// 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) } }
val covered by rememberUpdatedState(onCovered)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(page != Size.Zero) {
if (page == Size.Zero) return@LaunchedEffect
val travel = listOf(from.left, from.top, page.width - from.right, page.height - from.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
val runs = edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(1f, spring(dampingRatio = 0.48f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}
// Covered once every edge has reached the page's, the first time round.
snapshotFlow { edges.all { it.value >= 0.995f } }.first { it }
covered()
runs.forEach { it.join() }
}
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
drawFlood(from, edges.map { it.value }, start, pal.signal, cornerAtRest.toPx())
}
}
/**
* The flood from a button at [from] with its edges [v] of the way to the page's (left, top, right,
* bottom): the button sinking with the page behind, blobby in flight, its colours [start] giving
* way to the flat [signal] in the first half.
*/
private fun DrawScope.drawFlood(from: Rect, v: List<Float>, start: Pair<Color, Color>, signal: Color, cornerAtRest: Float) {
val (l, t, r, b) = v
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(cornerAtRest * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
val brush = Brush.linearGradient(
listOf(lerp(start.first, signal, mix), lerp(start.second, signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
)
drawRoundRect(brush, topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner))
}
/**
* The front end's first frames after a stop: the page in [flood] after a beat, drawn back down
* into the button at [to] (root px, measured before the page sank) on critically damped springs,
* the far edges first, so it sets down on the button without a bounce. [onProgress] (1 to 0) lets
* the page behind rise back. No button to return to: the blue fades. Calls [onLanded] at the end.
*/
@Composable
internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, onLanded: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val edges = remember { List(4) { Animatable(1f) } }
val fade = remember { Animatable(1f) }
val landed by rememberUpdatedState(onLanded)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(page != Size.Zero) {
if (page == Size.Zero) return@LaunchedEffect
progress(1f)
// 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)))
} 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)
coroutineScope {
edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(0f, spring(dampingRatio = 1f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}.forEach { it.join() }
}
}
progress(0f)
landed()
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
if (to == null) drawRect(flood, alpha = fade.value)
else drawFlood(to, edges.map { it.value }, colors.primary to pal.primary2, flood, 12.dp.toPx())
}
}
/**
* The session's first frames: the page in [flood], drawing in to a circle over [ball] (centre and
* radius in root px, once the throbber is laid out) that lands the size of it. Calls [onLanded] then.
*/
@Composable
internal fun FloodGather(flood: Color, ball: Pair<Offset, Float>?, onLanded: () -> Unit) {
val gather = remember { Animatable(0f) }
var started by remember { mutableStateOf(false) }
val landed by rememberUpdatedState(onLanded)
LaunchedEffect(ball != null) {
if (ball == null || started) return@LaunchedEffect
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))
landed()
}
Canvas(Modifier.fillMaxSize().pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
val b = ball
if (b == null) {
drawRect(flood)
return@Canvas
}
val (c, r) = b
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(far, r, gather.value), center = c)
}
}
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
// Sizes that decide how the launcher reflows on 4:3 and square screens (480x360 or 480x480 dp) as
// well as on the 16:9 ones it was first laid out for.
/** A page narrower than this reflows: settings rows stack, grids drop columns, posters go. */
val NarrowPaneWidth = 520.dp
/** Whether the page being drawn is narrow - the pane beside the rail on a 4:3 or square screen. */
val LocalNarrowPane = compositionLocalOf { false }
/** Too narrow a screen for a labelled rail: the rail shows icons only. */
@Composable
internal fun isNarrowScreen(): Boolean = LocalConfiguration.current.screenWidthDp < 600
@@ -16,6 +16,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
@@ -68,6 +70,10 @@ class ModeSettings(
val forceFullscreen: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
/** Steam only: start a Steam session when DroidDeck opens. */
val runSteamAtStartup: Boolean = false,
/** Steam only: the user's chosen Games folders; null outside Steam. */
val addedGamesDirs: List<String>? = null,
val addedGames: List<AddedGameRow> = emptyList(),
@@ -122,6 +128,8 @@ class ModeSettingsActions(
val onFexPreset: (String) -> Unit = {},
val onForceFullscreen: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onPickAddedGamesDir: () -> Unit = {},
val onForgetAddedGamesDir: (path: String) -> Unit = {},
val onAddedGamesArt: (Boolean) -> Unit = {},
@@ -289,6 +297,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = a.onSuspendPolicy,
)
}
if (steam) SettingsGroup("Startup") {
ToggleRow(
host, "steam-startup", "Run Steam when DroidDeck starts",
"Open the Steam session when you launch DroidDeck.",
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
if (steam) SettingsGroup("Decky") {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
@@ -342,6 +357,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
if (steam && s.steamChannel != null) SettingsGroup("Client") {
ToggleRow(
host, "steamdeck", "Steam Deck mode",
"Enables Steam's Deck interface and Quick Access performance overlay controls. Applies next session.",
s.steamDeckMode, onChange = a.onSteamDeckMode,
)
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,
@@ -353,13 +373,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
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"}") + ". Forget: the games leave the client's library at the next session start; nothing on disk is touched.",
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) },
)
}
ActionRow(
if (s.addedGamesDirs.isEmpty()) "Games folder" else "Another games folder",
"Your own Windows games, one subfolder each, anywhere: internal storage, the SD card, a USB drive. As many folders as you like. Each game goes into the client's library as a non-Steam game under the ARM64 Proton, at the next session start.",
stringResource(R.string.added_games_import_hint),
"Add…", onClick = a.onPickAddedGamesDir,
)
ToggleRow(
@@ -374,12 +394,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
if (steam && s.fexPreset != null) SettingsGroup("Games") {
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(
host, "fex", "FEX preset", "Applies on next game launch.",
FexPreset.all.map { it.id to it.label }, s.fexPreset,
note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
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.",
@@ -406,7 +427,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val open = host.open == "storage"
SettingsRow(
"Second library",
"Adds a library location in Steam. Applies next session.",
stringResource(R.string.second_library_import_hint),
highlighted = open,
) {
androidx.compose.foundation.layout.Box {
@@ -7,15 +7,22 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
class CoreRow(val core: Int, val label: String)
@@ -29,18 +36,18 @@ fun PerformancePage(
zinkLazy: Boolean,
glThread: Boolean,
noGlError: Boolean,
steamDeckMode: Boolean,
noXalia: Boolean,
prootNoSeccomp: Boolean,
guestHostname: String,
phantomWarning: String?,
onClientOverride: (Boolean) -> Unit,
onTuSysmem: (Boolean) -> Unit,
onZinkLazy: (Boolean) -> Unit,
onGlThread: (Boolean) -> Unit,
onNoGlError: (Boolean) -> Unit,
onSteamDeckMode: (Boolean) -> Unit,
onNoXalia: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
onGameCore: (Int, Boolean) -> Unit,
onDismiss: () -> Unit,
@@ -50,7 +57,7 @@ fun PerformancePage(
val coreItems = cores.map { it.core to it.label }
SettingsPage(
host, title = "Performance",
lede = "CPU cores and session fixes. Applies next session.",
lede = "CPU cores, session fixes and the host name. Applies next session.",
onBack = onDismiss,
) {
SettingsGroup("Steam client cores") {
@@ -87,11 +94,6 @@ fun PerformancePage(
"Disables per-call GL validation.",
noGlError, onChange = onNoGlError,
)
ToggleRow(
host, "deck", "Steam Deck mode",
"Runs the client with -droiddeck. SteamOS helpers and battery info are provided.",
steamDeckMode, onChange = onSteamDeckMode,
)
}
SettingsGroup("Session fixes") {
ToggleRow(
@@ -110,6 +112,25 @@ fun PerformancePage(
prootNoSeccomp, onChange = onProotNoSeccomp,
)
}
SettingsGroup("Session 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.",
) {
OutlinedTextField(
draft, { v ->
draft = v.take(63)
if (draft.isBlank() || SessionPrefs.validGuestHostname(draft) != null) onGuestHostname(draft)
},
singleLine = true, isError = !valid && draft.isNotBlank(),
placeholder = { Text(SessionPrefs.DEFAULT_GUEST_HOSTNAME) },
modifier = Modifier.width(220.dp),
)
}
}
if (phantomWarning != null) {
Spacer(Modifier.height(16.dp))
Column(
@@ -1,63 +1,96 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage
@Composable
fun PhantomProcessGatePage(
status: PhantomProcessStatus,
onDismiss: () -> Unit,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onEnterAddressManually: () -> Unit,
onCopyCommand: () -> Unit,
onOpenNotificationSettings: () -> Unit,
) {
BackHandler(onBack = onDismiss)
val context = LocalContext.current
val stage by WirelessAdbPairingService.stage.collectAsState()
val environment by produceState(GateEnvironment.read(context)) {
while (true) {
value = GateEnvironment.read(context)
kotlinx.coroutines.delay(2_000)
}
}
val hasToggle = PhantomProcessLimit.hasDeveloperToggle()
BoxWithConstraints(Modifier.fillMaxWidth()) {
val compact = maxWidth < 620.dp && maxHeight < 500.dp
SettingsPage(
host = rememberMenuHost(),
title = if (compact) "Steam paused" else "Steam cannot start yet",
title = "One Android setting to change",
eyebrow = "Steam",
lede = if (compact) compactStatus(status) else "${PhantomProcessLimit.title(status)} · checking again every two seconds",
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.",
onBack = onDismiss,
scrollContent = false,
scrollContent = true,
compactLayout = compact,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val wide = maxWidth >= 620.dp
Column(
modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
when {
compact -> CompactGateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
wide -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.weight(1f)) {
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
}
Column(Modifier.weight(1f)) { AdbFallback() }
}
else -> {
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
AdbFallback()
}
val fix: @Composable ColumnScope.() -> Unit = {
FixGroup(hasToggle, stage, environment, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging,
onOpenNotificationSettings, onCancel = { WirelessAdbPairingService.cancel(context) })
}
val side: @Composable ColumnScope.() -> Unit = {
StatusGroup(status, compact)
OtherWays(hasToggle, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging, onEnterAddressManually, onCopyCommand)
}
if (wide) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth()) {
Column(Modifier.weight(1.2f), content = fix)
Column(Modifier.weight(1f), content = side)
}
} else {
Column(Modifier.widthIn(max = 600.dp).fillMaxWidth()) {
fix()
side()
}
}
}
@@ -65,97 +98,168 @@ fun PhantomProcessGatePage(
}
}
private fun compactStatus(status: PhantomProcessStatus): String {
val state = when (status) {
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
PhantomProcessStatus.UNREADABLE -> "Child-process limit is unreadable"
else -> PhantomProcessLimit.title(status)
private data class GateEnvironment(val onWifi: Boolean, val notifications: Boolean) {
companion object {
fun read(context: Context): GateEnvironment {
val connectivity = context.getSystemService(ConnectivityManager::class.java)
val wifi = runCatching {
connectivity?.getNetworkCapabilities(connectivity.activeNetwork)
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}.getOrDefault(true)
return GateEnvironment(wifi, WirelessAdbPairingService.notificationsEnabled(context))
}
}
return "$state · retrying every 2 sec"
}
@Composable
private fun CompactGateInstructions(
status: PhantomProcessStatus,
private fun FixGroup(
hasToggle: Boolean,
stage: Stage,
environment: GateEnvironment,
compact: Boolean,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onCancel: () -> Unit,
) {
SettingsGroup("1 · Developer options", compact = true) {
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) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
PhantomProcessLimit.gateInstructions(status),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PrimaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Use on-device ADB", compact = true, onClick = onSetUpWirelessAdb)
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)
}
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.")
(stage as? Stage.Failed)?.let { Body(it.error, error = true) }
PrimaryButton(
if (stage is Stage.Failed) "Try again" else "Fix it for me",
compact = compact,
enabled = environment.onWifi && environment.notifications,
onClick = onFixWithWirelessDebugging,
)
}
}
if (!hasToggle && !pairing) {
if (!environment.onWifi) {
Body("Wireless debugging needs Wi-Fi. Connect to any network first; it doesn't need internet.", 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)
}
}
}
}
SettingsGroup("3 · Computer ADB last resort", compact = true) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
"If the setting is missing and Wireless debugging is unavailable, run this from a computer:",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
PhantomProcessLimit.ADB_COMMAND,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace, fontSize = 9.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@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",
)
val active = when (stage) {
Stage.Waiting -> 1
is Stage.CodeNeeded -> 2
else -> 3
}
steps.forEachIndexed { index, step ->
val done = index < active
val current = index == active
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (done) "✓" else "${index + 1}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = if (done) LocalPalette.current.good else MaterialTheme.colorScheme.onSurfaceVariant)
Text(step, style = MaterialTheme.typography.bodySmall,
color = if (current) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
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."
is Stage.Working -> stage.step
else -> ""
},
style = MaterialTheme.typography.bodySmall,
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)
}
}
@Composable
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
SettingsGroup("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.")
}
}
}
@Composable
private fun GateInstructions(
status: PhantomProcessStatus,
private fun OtherWays(
hasToggle: Boolean,
compact: Boolean,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onEnterAddressManually: () -> Unit,
onCopyCommand: () -> Unit,
) {
SettingsGroup("1 · Developer options first") {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
var open by rememberSaveable { mutableStateOf(false) }
SettingsGroup("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))
Text(if (open) "▴" else "▾", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (open) Column(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (hasToggle) {
SecondaryButton("Use Wireless debugging 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("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:")
Text(
PhantomProcessLimit.gateInstructions(status),
PhantomProcessLimit.adbCommand(),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PrimaryButton("Open Developer options", onClick = onOpenDeveloperOptions)
SecondaryButton("Use on-device ADB", onClick = onSetUpWirelessAdb)
SecondaryButton("Copy command", compact = true, onClick = onCopyCommand)
}
}
}
@Composable
private fun AdbFallback() {
SettingsGroup("3 · Computer ADB last resort") {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Use this only if Developer options has no setting and Wireless debugging is unavailable. Connect this device to a computer with ADB and run:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
Text(
PhantomProcessLimit.ADB_COMMAND,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun Body(text: String, modifier: Modifier = Modifier, error: Boolean = false, warn: Boolean = false) {
Text(
text,
modifier = modifier,
style = MaterialTheme.typography.bodySmall,
color = when {
error -> MaterialTheme.colorScheme.error
warn -> androidx.compose.ui.graphics.Color(0xFFFFB86B)
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
@Composable
@@ -33,7 +33,7 @@ fun ProtonPage(
val colors = MaterialTheme.colorScheme
SettingsPage(
host,
title = "Compatibility tools",
title = "Proton versions",
eyebrow = "Setup",
lede = "Download and install an ARM64 Proton build now, then select it per game in Steam > Properties > Compatibility.",
onBack = onBack,
@@ -70,7 +70,7 @@ fun ProtonPage(
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…",
fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
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))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
@@ -0,0 +1,313 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.runtime.produceState
import androidx.compose.runtime.key
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.runtime.remember
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import com.droiddeck.launcher.frontend.Library
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.filled.Pause
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
// The session's loading screen, its paused screen and the HUD text style.
@Composable
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.padding(12.dp)
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
/** 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 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"),
) else listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Desktop", "Getting the desktop ready"),
LoadStage("Opening", "Waiting for the first frame"),
)
/**
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
* order, and the screen never steps back.
*/
private fun stageOf(step: String, steam: Boolean): Int {
val t = step.lowercase()
return when {
"linux runtime" in t -> 0
"starting the session" in t -> 1
!steam && "desktop" in t -> 1
steam && "starting the steam client" in t -> 3
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
else -> -1
}
}
/** Download and install lines are worth reading as they are; the script's own notes are not. */
private fun readableStep(step: String): Boolean {
val t = step.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
}
@Composable
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
title: String = "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,
onShareLogs: (() -> Unit)? = null,
onClose: (() -> Unit)? = null,
/** The blue the page opens on when a button flooded to it, gathered into the throbber's ball. */
flood: Color? = null,
onFlooded: () -> Unit = {},
) {
val colors = MaterialTheme.colorScheme
var ball by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
val at = stageOf(step, steam)
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 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>()) {
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
) {
CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f))
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(
Brush.radialGradient(
0f to colors.background.copy(alpha = 0.94f),
0.6f to colors.background.copy(alpha = 0.72f),
1f to colors.background.copy(alpha = 0.55f),
center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f,
),
)
},
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f))))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp),
) {
// 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()) },
running = !ended && flood == null,
)
}
Spacer(Modifier.height(30.dp))
Text(
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp),
)
if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp))
if (ended && endedDetail != null) Text(
endedDetail, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant.copy(alpha = 0.8f),
textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 12.dp).widthIn(max = 520.dp),
)
if (ended) EndedActions(onRetry, onShareLogs, onClose)
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp),
)
if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp))
if (flood != null) FloodGather(flood, ball, onFlooded)
}
}
/** What to do about an ended session: nothing leaves on a timer, and the pad starts on Try again. */
@Composable
private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onClose: (() -> Unit)?) {
val first = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
runCatching { first.requestFocus() }
}
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)
}
}
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
@Composable
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
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")
val still = Motion.scale == 0f
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
) {
repeat(count) { i ->
Box(
Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp))
.background(if (i < active) pal.signal else track)
.drawBehind {
if (i != active) return@drawBehind
if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height))
else if (still) drawRect(pal.signal.copy(alpha = 0.5f))
else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height))
},
)
}
}
}
/** Cancel as the pad's B, in the corner; a tap works too. */
@Composable
private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp))
.background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel)
.padding(horizontal = 12.dp),
) {
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)
}
}
/** A session paused in the background, ready to pick up where it left off. */
@Composable
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val interactionSource = remember { MutableInteractionSource() }
val resumeFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
runCatching { resumeFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(pal.background.copy(alpha = 0.94f))
.clickable(interactionSource = interactionSource, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.widthIn(max = 440.dp).padding(32.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) {
Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp))
}
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
PrimaryButton(
"Resume",
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
onClick = onResume,
)
}
}
}
@@ -1,10 +1,17 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.draw.alpha
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
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material3.Icon
@@ -31,10 +38,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.AlertDialog
@@ -47,12 +52,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.HomeApp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -64,11 +65,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -84,25 +85,33 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
private val drawerPageTitles = listOf("Display", "Controls", "Settings")
private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings")
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "touch", "suspend")
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend")
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(3)
private val last = arrayOfNulls<String>(DRAWER_PAGES)
var focused by mutableStateOf<String?>(null)
private set
@@ -120,79 +129,6 @@ private class DrawerFocus {
fun forget(page: Int) { last[page] = null }
}
@Composable
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.padding(12.dp)
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
fun LoadingOverlay(step: String, percent: Int, elapsed: String, hint: String, ended: Boolean) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(interactionSource = MutableInteractionSource(), indication = null) {}
.padding(32.dp),
) {
Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(64.dp))
Spacer(Modifier.height(14.dp))
Text(if (ended) "The session ended" else "Steam is loading", color = Color.White, fontSize = 17.sp)
Text(step, color = Color(0xFFB8C4D0), fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 6.dp))
if (!ended) {
Spacer(Modifier.height(14.dp))
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.width(220.dp))
else LinearProgressIndicator(modifier = Modifier.width(220.dp))
Text(elapsed, color = Color(0xFF667788), fontSize = 11.sp, modifier = Modifier.padding(top = 8.dp))
Text(
hint, color = Color(0xFF667788), fontSize = 11.sp, textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 18.dp).widthIn(max = 360.dp),
)
}
}
}
@Composable
fun SessionPausedOverlay(onResume: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val resumeFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
runCatching { resumeFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xF20B0D10))
.clickable(interactionSource = interactionSource, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp),
) {
OutlinedButton(
onClick = onResume,
modifier = Modifier.focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
) {
Text("Resume session")
}
}
}
}
/** Everything the drawer shows and does. */
class DrawerActions(
val steam: Boolean,
@@ -241,7 +177,15 @@ class DrawerActions(
val onBackground: () -> Unit,
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
/** Swaps [value] ("orig:<build>" or a stored package file) into a Proton's component. */
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
)
@OptIn(ExperimentalComposeUiApi::class)
@@ -253,7 +197,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
val pageScroll = remember { List(3) { ScrollState(0) } }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
@@ -263,6 +207,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
@@ -287,7 +232,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
// A 4:3 or near-square screen (under 440dp tall) gets one-line shortcuts and a shorter tab row,
// so the settings keep most of the height; a narrow one keeps some of the game in view.
val short = maxHeight < 440.dp
val sheetWidth = minOf(360.dp, maxWidth * 0.92f)
AnimatedVisibility(
open,
enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)),
@@ -296,30 +245,22 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
Column(
modifier = Modifier
.fillMaxHeight()
.width(340.dp)
.width(sheetWidth)
.background(pal.background.copy(alpha = 0.97f))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.focusGroup()
.controllerBack {
if (host.open != null) host.open = null else a.onClose()
}
.padding(horizontal = 14.dp, vertical = 16.dp),
.padding(if (short) 12.dp else 16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 6.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Brush.linearGradient(listOf(colors.primary, pal.primary2))))
Spacer(Modifier.width(10.dp))
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold,
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,
color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
ShareSessionLogsButton(modifier = focus.track(page, "share-logs")) {
host.open = null
a.onShareLogs()
}
Spacer(Modifier.width(8.dp))
StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true }
}
if (a.onSteamMenu != null && a.onQam != null) {
val qamInteraction = remember { MutableInteractionSource() }
val qamHot = qamInteraction.collectIsFocusedAsState().value || qamInteraction.collectIsHoveredAsState().value
var qamStartedOnPress by remember { mutableStateOf(false) }
LaunchedEffect(qamInteraction) {
qamInteraction.interactions.collect { interaction ->
@@ -332,62 +273,29 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
modifier = Modifier.fillMaxWidth().padding(top = if (short) 8.dp else 12.dp),
) {
val steamSrc = remember { MutableInteractionSource() }
val steamHot = steamSrc.collectIsFocusedAsState().value || steamSrc.collectIsHoveredAsState().value
OutlinedButton(
onClick = { host.open = null; a.onSteamMenu.invoke() },
interactionSource = steamSrc,
modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "steam")).controllerConfirm {
host.open = null
a.onSteamMenu.invoke()
},
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
border = BorderStroke(if (steamHot) 2.dp else 1.dp, if (steamHot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (steamHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
) {
Text("STEAM", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, maxLines = 1)
QuickAction("Steam menu", Icons.Outlined.Menu, Modifier.weight(1f).then(focus.track(page, "steam")), compact = short) {
host.open = null
a.onSteamMenu.invoke()
}
OutlinedButton(
onClick = {
host.open = null
if (!qamStartedOnPress) a.onQam.invoke()
qamStartedOnPress = false
},
QuickAction(
"Quick access", Icons.Outlined.MoreHoriz,
Modifier.weight(1f).then(focus.track(page, "qam")).semantics { contentDescription = "Open Quick Access Menu" },
compact = short,
interactionSource = qamInteraction,
modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "qam"))
.semantics { contentDescription = "Open Quick Access Menu" }.controllerConfirm {
host.open = null
a.onQam.invoke()
},
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
border = BorderStroke(if (qamHot) 2.dp else 1.dp, if (qamHot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (qamHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
onConfirm = { host.open = null; a.onQam.invoke() },
) {
Text("QAM", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, maxLines = 1)
host.open = null
if (!qamStartedOnPress) a.onQam.invoke()
qamStartedOnPress = false
}
// 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) {
val deskSrc = remember { MutableInteractionSource() }
val deskHot = deskSrc.collectIsFocusedAsState().value || deskSrc.collectIsHoveredAsState().value
OutlinedButton(
onClick = { host.open = null; a.onSwitchToDesktop.invoke() },
interactionSource = deskSrc,
modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "desktop"))
.semantics { contentDescription = "Switch to Desktop" }.controllerConfirm {
host.open = null
a.onSwitchToDesktop.invoke()
},
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
border = BorderStroke(if (deskHot) 2.dp else 1.dp, if (deskHot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (deskHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
) {
Text("DESKTOP", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, maxLines = 1)
QuickAction("Desktop", Icons.Outlined.DesktopWindows, Modifier.weight(1f).then(focus.track(page, "desktop")), compact = short) {
host.open = null
a.onSwitchToDesktop.invoke()
}
}
}
@@ -395,20 +303,19 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
) {
DrawerOutlineButton("LB ‹", modifier = Modifier.height(48.dp).then(focus.track(page, "prev"))) {
host.open = null; onPageChange((page + 2) % 3)
DrawerBumper("LB", "Previous page", modifier = focus.track(page, "prev")) {
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
}
DrawerPageTabs(page = page, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerOutlineButton("› RB", modifier = Modifier.height(48.dp).then(focus.track(page, "next"))) {
host.open = null; onPageChange((page + 1) % 3)
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerBumper("RB", "Next page", modifier = focus.track(page, "next")) {
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
}
}
Column(
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,
@@ -418,23 +325,15 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
val fgOpen = host.open == "fg"
val fgLabel = when (a.frameGenEngine) {
FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×"
FrameGen.ENGINE_LSFG -> "LSFG ${a.frameGenMultiplier}×"
else -> "Off"
}
val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier)
SettingsRow("Frame generation", null, highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (a.lsfgReady) null else "Requires Lossless Scaling"
MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_WINFG && a.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_LSFG && a.frameGenMultiplier == m, enabled = a.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
}
FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick)
}
}
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
1 -> {
SettingsGroup("Controls") {
if (a.directTouchOnly) {
@@ -454,10 +353,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
SettingsGroup("Keyboard") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
DrawerOutlineButton("Hardware", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "hardware"))) {
DrawerOutlineButton("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(42.dp).then(focus.track(page, "android"))) {
DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "android"))) {
host.open = null; a.onKeyboard()
}
}
@@ -465,7 +364,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
val secondScreenModes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
if (a.plasmaMobileInstalled) listOf(SecondScreenMode.PLASMA_MOBILE) else emptyList()
ChoiceRow(host, "second-screen-mode", "Mode", null,
ChoiceRow(host, "second-screen-mode", "Shows", null,
secondScreenModes.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,
@@ -521,9 +420,20 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
ChoiceRow(host, "shape", "Screen ratio", null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
if (a.steam) ChoiceRow(host, "fex", "FEX preset", null,
FexPreset.all.map { it.id to it.label }, a.fexPreset,
}
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
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")) {
host.open = null
a.onShareLogs()
}
}
}
Spacer(Modifier.height(18.dp))
if (!a.isHomeApp) DrawerOutlineButton("Background", modifier = focus.track(page, "background")) {
@@ -531,7 +441,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
}
} }
}
}
}
@@ -556,7 +466,14 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
val stop = { confirmStop = false; a.onStop() }
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
// against the screen and the session's overlay converts them back.
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val stop = {
confirmStop = false
val from = a.onStopFrom
if (from != null) from(stopOnScreen) else a.onStop()
}
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
@@ -568,9 +485,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
val dialogView = LocalView.current
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop),
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
},
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},
@@ -605,33 +526,75 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
val src = remember { MutableInteractionSource() }
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")
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(42.dp).semantics { contentDescription = "Stop session" }
.clip(RoundedCornerShape(12.dp)).background(fill).border(1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.5f), RoundedCornerShape(12.dp))
val shape = RoundedCornerShape(20.dp)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" }
.clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick),
) { Text("×", fontSize = 25.sp, fontWeight = FontWeight.Medium, color = colors.error) }
.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)
}
}
/** One of the sheet's shortcuts into Steam: an icon over its name, the whole tile the target. */
@Composable
private fun ShareSessionLogsButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
private fun QuickAction(
label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier,
compact: Boolean = false,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onConfirm: (() -> Unit)? = null,
onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val hot = interactionSource.collectIsFocusedAsState().value || interactionSource.collectIsHoveredAsState().value
val shape = RoundedCornerShape(12.dp)
val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
.hoverable(interactionSource)
.clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onConfirm ?: onClick)
.padding(horizontal = 6.dp)
val content: @Composable () -> Unit = {
Icon(icon, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(20.dp))
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (compact) Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), modifier = tile,
) { content() }
else Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically), modifier = tile,
) { content() }
}
/** A bumper as a keycap, 44dp to touch; LB / RB on the pad turn the page too. */
@Composable
private fun DrawerBumper(label: String, description: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val ring = RoundedCornerShape(10.dp)
val cap = RoundedCornerShape(7.dp)
Box(
contentAlignment = Alignment.Center,
modifier = modifier.widthIn(min = 88.dp).height(42.dp)
.semantics { contentDescription = "Send current session logs" }
.clip(RoundedCornerShape(12.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(1.dp, if (hot) pal.signal else colors.outline.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick),
modifier = modifier.size(44.dp).clip(ring)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ring)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
.controllerConfirm(onClick = onClick)
.semantics { contentDescription = description },
) {
Text("send logs", fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1)
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier.clip(cap).background(colors.surfaceVariant).border(1.dp, pal.line2, cap).padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
@@ -643,17 +606,24 @@ private fun ShareSessionLogsButton(modifier: Modifier = Modifier, onClick: () ->
* pad like the dots it replaces.
*/
@Composable
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (Int) -> Unit) {
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val count = drawerPageIcons.size
val middle = (count - 1) / 2f
val motion = tween<Float>(durationMillis = 340, easing = FastOutSlowInEasing)
val lean by animateFloatAsState(-(page - middle) * DRAWER_TAB_LEAN_DP, motion, label = "tabLean")
Box(
androidx.compose.foundation.layout.BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier.height(64.dp).clipToBounds(),
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
) {
val tab = if (compact) 40f else 56f
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
// ([tab] dp) and pushed outward by the lean, which scales with the spacing. So
// (count-1) * spacing * (1 + lean/64) + 56 <= width
// - never more than the mock's 64 dp, never tighter than 32 dp.
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
drawerPageIcons.forEachIndexed { index, icon ->
val selected = index == page
val source = remember { MutableInteractionSource() }
@@ -666,8 +636,8 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * DRAWER_TAB_SPACING_DP + lean).dp)
.size(56.dp)
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
@@ -681,14 +651,146 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, onSelect: (
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(30.dp))
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
}
}
}
}
/** Display, Controls, Components, Settings. */
const val DRAWER_PAGES = 4
private const val DRAWER_PAGE_COMPONENTS = 2
/**
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
* deleting stay on the Components page in the app.
*/
@Composable
private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
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))
}
return
}
val running = snap.protons.filter { it.inUseByGame }
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") {
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value
val pal = LocalPalette.current
Column(
modifier = Modifier.fillMaxWidth().then(track("cmp-running"))
.focusable(interactionSource = src)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.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))
} 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) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 3.dp)) {
Text(ComponentsManager.LABEL.getValue(comp), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(96.dp))
Text(r.components.getValue(comp).inUse, fontSize = 12.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
if (view == null) {
SettingsGroup("Components") {
Text("No Proton is installed yet.", fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(14.dp))
}
return
}
val p = view.proton
SettingsGroup("Components") {
DrawerStackedChoice(
host, "cmp-proton", "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)) },
).joinToString(" · "),
snap.protons.map { it.proton.id to it.proton.name }, p.id,
chipModifier = track("cmp-proton"), onPick = { pick = it },
)
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}") } +
snap.packages.filter { it.comp == comp }.map { it.file to it.version }
if (options.isEmpty()) continue
val current = st.activeFile ?: "orig:$build"
val shown = options.firstOrNull { it.first == current }?.second
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 },
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.",
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.",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
)
}
/**
* A choice for the drawer's narrow column: label, then the box across the full width, then the
* detail underneath - so a long Proton or package name never squeezes the text beside it.
*/
@Composable
private fun <T> DrawerStackedChoice(
host: MenuHost, key: String, label: String, hint: String?,
options: List<Pair<T, String>>, selected: T, note: String? = null,
chipModifier: Modifier = Modifier, onPick: (T) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val open = host.open == key
Column(
modifier = Modifier.fillMaxWidth()
.background(if (open) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Box(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, modifier = chipModifier.fillMaxWidth()) {
host.open = if (open) null else key
}
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
onPick(value)
host.open = null
}
}
}
}
if (hint != null) Text(hint, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 5.dp))
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
@@ -14,6 +14,14 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -96,6 +104,85 @@ internal fun Modifier.controllerBack(onBack: () -> Unit): Modifier = onPreviewKe
}
}
/** LB and RB turn a page's tabs; the keys are taken whenever focus is anywhere inside [this]. */
internal fun Modifier.bumpers(onPrevious: () -> Unit, onNext: () -> Unit): Modifier = onPreviewKeyEvent { event ->
val keyEvent = event.nativeKeyEvent
val previous = keyEvent.keyCode == KeyEvent.KEYCODE_BUTTON_L1
if (!previous && keyEvent.keyCode != KeyEvent.KEYCODE_BUTTON_R1) {
false
} else {
if (keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { if (previous) onPrevious() else onNext() }
true
}
}
/**
* A page's tabs with the pad's bumpers drawn beside them. The host turns them with LB/RB (see
* [bumpers]); the keycaps are for touch and stay out of the d-pad's path. The row scrolls sideways
* when a narrow page cannot fit every tab.
*/
@Composable
internal fun TabStrip(
tabs: List<String>, selected: Int, onSelect: (Int) -> Unit,
modifier: Modifier = Modifier, focusRequesters: List<FocusRequester>? = null,
) {
val colors = MaterialTheme.colorScheme
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) }
Row(
modifier = Modifier.weight(1f, fill = false).clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape)
.horizontalScroll(rememberScrollState()).padding(3.dp),
) {
tabs.forEachIndexed { i, label ->
val on = i == selected
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val pick = { onSelect(i) }
val tabShape = RoundedCornerShape(9.dp)
Text(
label, fontSize = 14.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1,
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(tabShape)
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape)
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
}
BumperKey("RB", "Next tab") { onSelect((selected + 1) % tabs.size) }
}
}
/** A bumper drawn as a keycap, 44dp to touch; the pad presses the real one. */
@Composable
private fun BumperKey(label: String, description: String, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val cap = RoundedCornerShape(7.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp)
.focusProperties { canFocus = false }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = onClick)
.semantics { contentDescription = description },
) {
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onSurfaceVariant,
modifier = Modifier.clip(cap).background(colors.surfaceVariant).border(1.dp, pal.line2, cap)
.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
/** The narrowest a setting's value box gets; the session menu's sheet sets it lower. */
val LocalChipMinWidth = androidx.compose.runtime.compositionLocalOf { 150.dp }
class MenuHost {
var open by mutableStateOf<String?>(null)
}
@@ -154,14 +241,14 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
.padding(6.dp),
) {
if (title != null) Text(
title.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, color = colors.onSurfaceVariant,
title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, top = 6.dp, bottom = 6.dp),
)
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { content(firstItemFocus) }
if (note != null) {
Spacer(Modifier.height(4.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
Text(note, fontSize = 11.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp))
Text(note, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp))
}
}
}
@@ -198,7 +285,7 @@ fun MenuItem(
}
Column(modifier = Modifier.weight(1f)) {
Text(label, fontSize = 13.5.sp, color = if (checked) pal.signal else colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (trailing != null) { Spacer(Modifier.width(8.dp)); trailing() }
}
@@ -215,7 +302,8 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.widthIn(min = 150.dp)
.widthIn(min = LocalChipMinWidth.current)
.heightIn(min = 44.dp)
.clip(RoundedCornerShape(10.dp))
.background(colors.surfaceVariant)
.border(1.dp, edge, RoundedCornerShape(10.dp))
@@ -243,7 +331,7 @@ fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 10.dp),
modifier = Modifier.fillMaxWidth().padding(top = if (compact) 4.dp else 16.dp, bottom = if (compact) 3.dp else 6.dp),
) {
Text(title.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, color = colors.onSurfaceVariant)
Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Box(modifier = Modifier.weight(1f).height(1.dp).background(pal.line))
}
Column(modifier = Modifier.fillMaxWidth().clip(GroupShape).background(colors.surface).border(1.dp, pal.line, GroupShape)) { content() }
@@ -254,13 +342,20 @@ fun SettingsRow(label: String, hint: String?, highlighted: Boolean = false, cont
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val bg by animateColorAsState(if (highlighted) pal.signal.copy(alpha = 0.10f) else Color.Transparent, Motion.tw(200), label = "rowBg")
Row(
// A narrow page stacks the control under its label, so neither squeezes the other.
if (LocalNarrowPane.current) Column(
modifier = Modifier.fillMaxWidth().background(bg).padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 12.dp),
) {
Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (hint != null) Text(hint, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
Box(Modifier.padding(top = 8.dp)) { control() }
} else Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().background(bg).padding(horizontal = 14.dp, vertical = 11.dp),
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (hint != null) Text(hint, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
control()
}
@@ -293,7 +388,46 @@ fun <T> ChoiceRow(
@Composable
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) =
ChoiceRow(host, key, label, hint, listOf(true to "On", false to "Off"), checked, enabled, chipModifier = chipModifier, onPick = onChange)
SettingsRow(label, hint) {
ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) }
}
/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */
@Composable
fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = null, modifier: Modifier = Modifier, onChange: (Boolean) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack")
val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge")
val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob")
val flip = { onChange(!checked) }
val shape = RoundedCornerShape(99.dp)
// 48dp tall to touch; the visible track sits inside it.
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.heightIn(min = 48.dp).widthIn(min = 64.dp)
.alpha(if (enabled) 1f else 0.5f)
.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 },
) {
Box(
Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track)
.border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape),
) {
Box(
Modifier.padding(4.dp).size(22.dp)
.graphicsLayer { translationX = knob * 22.dp.toPx() }
.clip(CircleShape)
.background(if (checked) pal.onSignal else colors.onSurfaceVariant),
)
}
}
}
@Composable
fun MultiRow(
@@ -343,62 +477,71 @@ fun SettingsPage(
val scrollState = scroll ?: rememberScrollState()
val colors = MaterialTheme.colorScheme
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
val narrow = LocalNarrowPane.current && !compactLayout
Column(
modifier = Modifier.fillMaxSize().padding(
horizontal = if (compactLayout) 14.dp else 22.dp,
vertical = if (compactLayout) 6.dp else 18.dp,
horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp,
vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp,
),
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp),
) {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Outlined when a controller's focus is on it, like every other control here.
val backSrc = remember { MutableInteractionSource() }
val backHot = backSrc.collectIsFocusedAsState().value || backSrc.collectIsHoveredAsState().value
Text(
"‹ Back", fontSize = if (compactLayout) 11.sp else 12.sp, fontWeight = FontWeight.SemiBold,
color = if (backHot) LocalPalette.current.signal else colors.onSurfaceVariant,
modifier = Modifier.clip(RoundedCornerShape(8.dp))
.border(2.dp, if (backHot) LocalPalette.current.signal else Color.Transparent, RoundedCornerShape(8.dp))
.hoverable(backSrc).clickable(interactionSource = backSrc, indication = null, onClick = onBack)
.padding(horizontal = 6.dp, vertical = if (compactLayout) 2.dp else 4.dp),
)
if (eyebrow != null) {
Spacer(Modifier.width(10.dp))
Eyebrow(eyebrow)
if (narrow) {
// Back and the title share one line: a 4:3 screen has no height to spare.
Rise(0) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(bottom = 6.dp),
) {
BackLink("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),
)
if (action != null) action()
}
}
}
Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
if (compactLayout) {
Text(
title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = colors.onBackground,
modifier = Modifier.padding(vertical = 2.dp),
)
} else {
Title(title)
if (lede != null) Rise(1) { Lede(lede) }
} else {
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically) {
BackLink("Back", compact = compactLayout, onClick = onBack)
if (eyebrow != null) {
Spacer(Modifier.width(10.dp))
Eyebrow(eyebrow)
}
}
if (action != null) action()
}
}
if (lede != null) Rise(2) {
if (compactLayout) {
Text(
lede,
fontSize = 11.sp,
color = colors.onSurfaceVariant,
modifier = Modifier.padding(bottom = 3.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Lede(lede)
Rise(1) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
if (compactLayout) {
Text(
title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = colors.onBackground,
modifier = Modifier.padding(vertical = 2.dp),
)
} else {
Title(title)
}
}
if (action != null) action()
}
}
if (lede != null) Rise(2) {
if (compactLayout) {
Text(
lede,
fontSize = 12.sp,
color = colors.onSurfaceVariant,
modifier = Modifier.padding(bottom = 3.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Lede(lede)
}
}
}
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
@@ -0,0 +1,231 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.runtime.State
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.frontend.Library
/** From this many games the Steam tab is a wall; fewer would only repeat the same covers. */
private const val WALL_MIN_GAMES = 4
/**
* The Steam tab: the library as a slowly drifting, tilted wall of capsules (a few games instead
* lean on their own; none, a wall of empty ones), and over it Steam and the one thing to do here -
* Play. Everything about a single game lives on the Games tab.
*/
@Composable
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val narrow = LocalNarrowPane.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Box(modifier = modifier.fillMaxSize().clipToBounds()) {
if (games.size in 1 until WALL_MIN_GAMES) CapsuleFan(games)
else {
if (games.isEmpty()) Box(
Modifier.fillMaxSize().drawBehind {
drawRect(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.12f), Color.Transparent),
center = Offset(size.width * 0.72f, size.height * 0.42f), radius = 380.dp.toPx(),
),
)
},
)
CapsuleWall(games)
// The wall fades out behind the words, and toward the bottom where the buttons sit.
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to colors.background.copy(alpha = 0.97f),
0.34f to colors.background.copy(alpha = 0.86f),
0.68f to colors.background.copy(alpha = 0.25f),
1f to colors.background.copy(alpha = 0.1f),
),
),
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.55f to Color.Transparent, 1f to colors.background.copy(alpha = 0.9f))))
}
Column(
modifier = Modifier.align(Alignment.BottomStart)
.padding(start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, end = 20.dp),
) {
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,
fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1,
)
// Said only when something stands between Play and Steam; a ready runtime needs no words.
if (s.busy || !s.ready || (s.available != null && s.available != s.installed)) RuntimeChip(s)
}
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)
Cog(size = 54.dp, onClick = a.onSteamSettings)
}
}
}
}
/**
* Columns of capsules tilted a few degrees, alternate columns drifting up and down. Each column
* holds its run of covers twice and moves by exactly one run, so the loop has no seam. An empty
* library draws the same wall in blank capsules, slower.
*/
@Composable
internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
) {
val capW = 112.dp
val capH = 168.dp
val gap = 14.dp
val transition = rememberInfiniteTransition(label = "wall")
val drift = transition.animateFloat(
0f, 1f,
infiniteRepeatable(tween(driftMs, easing = LinearEasing)),
label = "drift",
)
// Animations off in the system settings: a still wall.
val still = Motion.scale == 0f
BoxWithConstraints(modifier.fillMaxSize()) {
// Centred on the page and big enough that, once tilted, it still covers every corner.
val tilt = Math.toRadians(13.0)
val cos = kotlin.math.cos(tilt).toFloat()
val sin = kotlin.math.sin(tilt).toFloat()
val wallW = (maxWidth * cos + maxHeight * sin) * 1.1f
val wallH = maxOf(maxHeight * 2.4f, (maxWidth * sin + maxHeight * cos) * 1.1f)
val columns = ((wallW + gap) / (capW + gap)).toInt() + 1
val perRun = ((wallH + gap) / (capH + gap)).toInt() + 1
val run = (capH + gap) * perRun
Row(
horizontalArrangement = Arrangement.spacedBy(gap),
modifier = Modifier
.wrapContentSize(Alignment.TopStart, unbounded = true)
.offset(x = -(wallW - maxWidth) / 2, y = -(wallH - maxHeight) / 2)
.requiredSize(wallW, wallH)
.graphicsLayer { rotationZ = -13f },
) {
repeat(columns) { c ->
Column(
verticalArrangement = Arrangement.spacedBy(gap),
modifier = Modifier.wrapContentHeight(Alignment.Top, unbounded = true).graphicsLayer {
val shift = if (still) 0f else run.toPx() * drift.value
translationY = if (c % 2 == 0) -shift else shift - run.toPx()
},
) {
repeat(perRun * 2) { i ->
val g = if (games.isEmpty()) null else games[(c * 5 + (i % perRun) * 3) % games.size]
Capsule(g, RoundedCornerShape(10.dp), Modifier.size(capW, capH))
}
}
}
}
}
}
/** One to three games, each a large capsule leaning on the others and floating a little. */
@Composable
private fun CapsuleFan(games: List<Library.SteamGame>) {
val pal = LocalPalette.current
val transition = rememberInfiniteTransition(label = "fan")
val floats = listOf(9_000, 11_000, 13_000).map { ms ->
transition.animateFloat(0f, 1f, infiniteRepeatable(tween(ms, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "float")
}
val still = Motion.scale == 0f
// Where each sits (share of the page), its height (share of the page's) and its lean; front first.
val slots = listOf(
FanSlot(0.54f, 0.14f, 0.62f, -9f),
FanSlot(0.70f, 0.24f, 0.56f, 5f),
FanSlot(0.40f, 0.30f, 0.50f, -17f),
)
BoxWithConstraints(
Modifier.fillMaxSize().drawBehind {
drawRect(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.14f), Color.Transparent),
center = Offset(size.width * 0.68f, size.height * 0.45f), radius = size.height * 0.6f,
),
)
},
) {
for (i in games.indices.reversed()) {
val slot = slots[i]
val h = maxHeight * slot.height
val v: State<Float> = floats[i]
Capsule(
games[i], RoundedCornerShape(14.dp),
Modifier
.offset(x = maxWidth * slot.x, y = maxHeight * slot.y)
.size(h * 2f / 3f, h)
.graphicsLayer {
val t = if (still) 0f else v.value
rotationZ = slot.lean + 2f * t
translationY = -12.dp.toPx() * t
shadowElevation = 24.dp.toPx()
shape = RoundedCornerShape(14.dp)
clip = false
},
)
}
}
}
private class FanSlot(val x: Float, val y: Float, val height: Float, val lean: Float)
/** A game's portrait capsule, or a blank one standing in for a game yet to be installed. */
@Composable
private fun Capsule(g: Library.SteamGame?, shape: RoundedCornerShape, modifier: Modifier) {
val pal = LocalPalette.current
if (g == null) {
Box(modifier.clip(shape).background(Brush.linearGradient(listOf(pal.surface, pal.background))).border(1.dp, pal.line, shape))
return
}
Box(modifier.clip(shape).background(artBrush(hueOf(g.name)))) {
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
}
}
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.session.SessionPrefs
@@ -18,11 +19,19 @@ class Palette(
)
object Themes {
const val GRAPHITE = "graphite"
const val PAPER = "paper"
const val ICON_BLUE = "blue"
const val ELECTRIC = "electric"
val all: List<Palette> = listOf(
Palette(
GRAPHITE, "Graphite", "Graphite surfaces, with the logo's own orb blue for everything selected, focused or pressed.",
background = Color(0xFF0A0B0D), surface = Color(0xFF121417), surfaceVariant = Color(0xFF1A1D22), line = Color(0xFF262A31), line2 = Color(0xFF343A43),
onBackground = Color(0xFFF2F4F7), onSurfaceVariant = Color(0xFF9AA3AF),
primary = Color(0xFF1A9FFF), primary2 = Color(0xFF1487DB), onPrimary = Color(0xFF03111F), signal = Color(0xFF1A9FFF),
good = Color(0xFF4CD37F),
),
Palette(
PAPER, "Paper on black", "White is the primary, the icon's blue is the signal for rules, rings and dots.",
background = Color(0xFF000000), surface = Color(0xFF0F0F10), surfaceVariant = Color(0xFF191A1C), line = Color(0xFF262729), line2 = Color(0xFF343638),
@@ -49,7 +58,13 @@ object Themes {
fun byId(id: String): Palette = all.firstOrNull { it.id == id } ?: all[0]
}
val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.PAPER) }
val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) }
/**
* Text and icons drawn on a [Palette.signal] fill: near-black where the blue is light enough that
* white would not read (Graphite's #1A9FFF gives white 2.8:1), white where it is deep.
*/
val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White
@Composable
fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
@@ -0,0 +1,171 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.rotate
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* The DroidDeck mark as the loading indicator. Every loop the D's square half bends into an arc,
* the ring they make turns once around the ball, and the half straightens back into the D.
*/
@Composable
internal fun LogoThrobber(modifier: Modifier = Modifier, running: Boolean = true) {
val ink = MaterialTheme.colorScheme.onBackground
val ball = LocalPalette.current.signal
val loop = rememberInfiniteTransition(label = "throbber")
.animateFloat(0f, 1f, infiniteRepeatable(tween(LOOP_MS, easing = LinearEasing)), label = "loop")
// Animations off in the system settings, or nothing running: the plain D.
val still = !running || Motion.scale == 0f
val left = remember { Path() }
val right = remember { Path() }
Canvas(modifier.aspectRatio(LogoShape.WIDTH / LogoShape.HEIGHT)) {
val p = if (still) 0f else loop.value
val bend = when {
p < 0.10f -> 0f
p < 0.24f -> Settle.transform((p - 0.10f) / 0.14f)
p < 0.76f -> 1f
p < 0.90f -> 1f - Settle.transform((p - 0.76f) / 0.14f)
else -> 0f
}
val turn = if (p < 0.16f || p > 0.84f) 0f else Turn.transform((p - 0.16f) / 0.68f) * 360f
val ballScale = when {
p < 0.12f -> 1f
p < 0.30f -> 1f - 0.2f * Settle.transform((p - 0.12f) / 0.18f)
p < 0.70f -> 0.8f
p < 0.88f -> 0.8f + 0.2f * Settle.transform((p - 0.70f) / 0.18f)
else -> 1f
}
val s = size.width / LogoShape.WIDTH
fun px(x: Float, y: Float) = Offset((x - LogoShape.LEFT) * s, y * s)
val centre = px(LogoShape.CX, LogoShape.CY)
LogoShape.left(left, bend, ::px)
LogoShape.right(right, ::px)
rotate(turn, centre) {
drawPath(left, ink)
drawPath(right, ink)
}
drawCircle(ball, radius = LogoShape.BALL * s * ballScale, center = centre)
}
}
/** The ball of a throbber laid out in [bounds]: its centre and its radius, at rest. */
internal fun throbberBall(bounds: androidx.compose.ui.geometry.Rect): Pair<Offset, Float> {
val s = bounds.width / LogoShape.WIDTH
return Offset(bounds.left + (LogoShape.CX - LogoShape.LEFT) * s, bounds.top + LogoShape.CY * s) to LogoShape.BALL * s
}
private const val LOOP_MS = 1900
private val Settle = CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
private val Turn = CubicBezierEasing(0.65f, 0f, 0.35f, 1f)
/**
* The logo's geometry in its SVG's own units (viewBox -55.98 0 239.92 223.72): a D of a square
* left side and a round right side, cut by a ring around the ball and split top and bottom.
* The left half is sampled along rays from the centre so it can bend, point for point, into the
* mirror of the right half's arc.
*/
private object LogoShape {
const val LEFT = -55.98f
const val WIDTH = 239.92f
const val HEIGHT = 223.72f
const val CX = 63.98f
const val CY = 111.86f
const val BALL = 55.98f
private const val OUTER = 111.86f
private const val INNER = 71.81f
private const val SQUARE_X = -31.98f
private const val SPLIT_LEFT = 56.055f
private const val SPLIT_RIGHT = 71.885f
private class Pt(val x: Float, val y: Float)
private fun at(a: Double, r: Float) = Pt((CX + r * cos(a)).toFloat(), (CY + r * sin(a)).toFloat())
private fun angle(x: Float, y: Float) = atan2((y - CY).toDouble(), (x - CX).toDouble())
// Angles taken the long way round, through the left: from about -94 degrees down to -266.
private fun leftward(a: Double) = if (a < 0) a else a - 2 * Math.PI
private val rays: List<Double> = run {
val top = angle(SPLIT_LEFT, 0f)
val topCorner = angle(SQUARE_X, 0f)
val bottomCorner = leftward(angle(SQUARE_X, HEIGHT))
val bottom = leftward(angle(SPLIT_LEFT, HEIGHT))
// The corners are rays of their own, so the square half keeps sharp corners.
fun span(a: Double, b: Double, n: Int) = List(n) { i -> a + (b - a) * i / (n - 1) }
span(top, topCorner, 9).dropLast(1) + span(topCorner, bottomCorner, 25).dropLast(1) + span(bottomCorner, bottom, 9)
}
private val square: List<Pt> = rays.map { a ->
val c = cos(a)
val sn = sin(a)
val hits = buildList {
if (c < -1e-9) add((SQUARE_X - CX) / c)
if (sn < -1e-9) add((0f - CY) / sn)
if (sn > 1e-9) add((HEIGHT - CY) / sn)
}
val t = hits.minOrNull() ?: 0.0
Pt((CX + t * c).toFloat(), (CY + t * sn).toFloat())
}.toMutableList().also { it[0] = Pt(SPLIT_LEFT, 0f); it[it.lastIndex] = Pt(SPLIT_LEFT, HEIGHT) }
private val arc: List<Pt> = rays.map { at(it, OUTER) }.toMutableList().also {
val dy = sqrt(OUTER * OUTER - (SPLIT_LEFT - CX) * (SPLIT_LEFT - CX))
it[0] = Pt(SPLIT_LEFT, CY - dy)
it[it.lastIndex] = Pt(SPLIT_LEFT, CY + dy)
}
private val leftInner: List<Pt> = run {
val dx = (SPLIT_LEFT - CX).toDouble()
val dy = sqrt(INNER * INNER - dx * dx)
val from = atan2(dy, dx)
val to = atan2(-dy, dx) + 2 * Math.PI
List(rays.size) { i -> at(from + (to - from) * i / (rays.size - 1), INNER) }
}
private val rightHalf: List<Pt> = run {
val dx = (SPLIT_RIGHT - CX).toDouble()
val oy = sqrt(OUTER * OUTER - dx * dx)
val iy = sqrt(INNER * INNER - dx * dx)
val outerFrom = atan2(-oy, dx)
val outerTo = atan2(oy, dx)
val innerFrom = atan2(iy, dx)
val innerTo = atan2(-iy, dx)
List(40) { i -> at(outerFrom + (outerTo - outerFrom) * i / 39, OUTER) } +
List(40) { i -> at(innerFrom + (innerTo - innerFrom) * i / 39, INNER) }
}
/** The left half, [bend] of the way from the square D side (0) to the arc (1). */
fun left(path: Path, bend: Float, px: (Float, Float) -> Offset) {
path.reset()
square.forEachIndexed { i, q ->
val r = arc[i]
val o = px(q.x + (r.x - q.x) * bend, q.y + (r.y - q.y) * bend)
if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y)
}
for (q in leftInner) px(q.x, q.y).let { path.lineTo(it.x, it.y) }
path.close()
}
fun right(path: Path, px: (Float, Float) -> Offset) {
path.reset()
rightHalf.forEachIndexed { i, q ->
val o = px(q.x, q.y)
if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y)
}
path.close()
}
}
@@ -172,9 +172,9 @@ fun WirelessAdbFixPage(
SettingsPage(
host = rememberMenuHost(),
title = when {
compactSplit && step < 2 -> "Wireless ADB"
step == 2 -> "Wireless ADB ready"
else -> "Set up on-device ADB"
compactSplit && step < 2 -> "Wireless debugging"
step == 2 -> "Child-process limit updated"
else -> "Change the child-process limit"
},
eyebrow = "Setup",
lede = when {
@@ -201,7 +201,7 @@ fun WirelessAdbFixPage(
) {
SettingsGroup("Child-process limit", compact = compactSplit) {
Text(
"Android confirmed the limit is ${if (desiredEnabled) "enabled" else "disabled"}.",
"Android confirmed the limit is ${if (desiredEnabled) "on" else "off"}.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(14.dp),
)
@@ -332,7 +332,7 @@ private fun WirelessStepForm(
StatusMessage(busy, message, Modifier.weight(1f), isError = messageIsError)
SecondaryButton("Pair again", compact = compact, enabled = !busy, onClick = onPairAgain)
PrimaryButton(
if (desiredEnabled) "Enable limit" else "Disable limit",
if (desiredEnabled) "Turn limit on" else "Turn limit off",
compact = compact,
enabled = !busy && parseAdbAddress(connectionAddress) != null,
onClick = onApply,
@@ -24,7 +24,7 @@ class HdrProbe(
) {
/** Why HDR cannot be offered, or null when it can. */
val reason: String? = when {
Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a colour space)"
Build.VERSION.SDK_INT < 29 -> "needs Android 10 or newer (display layers with a color space)"
!hdr10 -> if (formats.isEmpty()) "this display reports no HDR support" else "this display supports $formats, not HDR10"
else -> null
}
@@ -26,6 +26,10 @@ public final class WaylandCompositor {
* listener must marshal to the UI thread itself. */
public static void setFirstFrameListener(Runnable r) { firstFrameListener = r; }
public static synchronized void clearFirstFrameListener(Runnable r) {
if (firstFrameListener == r) firstFrameListener = null;
}
/** Invoked from native (banner_on_first_frame) on the first present. */
@SuppressWarnings("unused")
static void onFirstFramePresented() {
@@ -52,6 +56,10 @@ public final class WaylandCompositor {
public static void setGameListener(GameListener l) { gameListener = l; }
public static synchronized void clearGameListener(GameListener l) {
if (gameListener == l) gameListener = null;
}
/** Invoked from native (banner_on_game_surface). */
@SuppressWarnings("unused")
static void onGameSurface(String window, String gpuName) {
@@ -59,9 +67,30 @@ public final class WaylandCompositor {
if (l != null) l.onGameSurface(window, gpuName);
}
private static volatile long lastFrameNanos;
private static volatile long frameIntervalNanos;
/**
* How long frames are taking to arrive right now, in ms, or -1 before the first: the smoothed
* interval, or the time since the last frame when that is longer (a session that has stalled).
*/
public static long recentFrameIntervalMs() {
long last = lastFrameNanos;
if (last == 0) return -1;
return Math.max(frameIntervalNanos, System.nanoTime() - last) / 1_000_000;
}
/** Invoked from native (banner_on_game_frame) for every frame of the HUD's window. */
@SuppressWarnings("unused")
static void onGameFrame() {
long now = System.nanoTime();
long previous = lastFrameNanos;
lastFrameNanos = now;
if (previous != 0) {
long interval = now - previous;
long smoothed = frameIntervalNanos;
frameIntervalNanos = smoothed == 0 ? interval : (smoothed * 3 + interval) / 4;
}
com.droiddeck.launcher.session.PerfHints.onFrame();
GameListener l = gameListener;
if (l != null) l.onGameFrame();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -1,11 +1,8 @@
<!--
Status-bar icon: Android renders it as a single-colour silhouette, so this is the launcher
controller reduced to one path, with the d-pad and the buttons punched out (evenOdd) rather
than drawn in a second colour that would never survive.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M40,36 H68 C79,36 87,44.5 87,55.5 v4 c0,6.6 -5.4,12 -12,12 c-4.5,0 -8.6,-2.5 -10.6,-6.5 L62,60 H46 l-2.4,5 C41.6,69 37.5,71.5 33,71.5 c-6.6,0 -12,-5.4 -12,-12 v-4 C21,44.5 29,36 40,36 Z M33,47.5 h4.5 v-4.5 h4.5 v4.5 h4.5 v4.5 h-4.5 v4.5 h-4.5 v-4.5 h-4.5 z M71,43 a2.5,2.5 0 1,0 0.01,0 z M77,47.5 a2.5,2.5 0 1,0 0.01,0 z M65,47.5 a2.5,2.5 0 1,0 0.01,0 z M71,52 a2.5,2.5 0 1,0 0.01,0 z M54,44 a6,6 0 1,0 0.01,0 z" />
android:viewportWidth="239.92" android:viewportHeight="239.92">
<group android:translateX="55.98" android:translateY="8.1">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M-31.98,0 H63.97 C125.81,0 175.93,50.08 175.93,111.86 S125.81,223.72 63.97,223.72 H-31.98 Z M-7.83,111.86 a71.81,71.81 0 1,0 143.62,0 a71.81,71.81 0 1,0 -143.62,0 Z M8,111.86 a55.98,55.98 0 1,0 111.96,0 a55.98,55.98 0 1,0 -111.96,0 Z M56.055,0 h15.83 v40.49 h-15.83 Z M56.055,183.23 h15.83 v40.49 h-15.83 Z" />
</group>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 B

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more