diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2d479537..864786267 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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-, 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" diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 72ef506be..3ae1ab307 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2620b96df..870c6b5f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" \ diff --git a/.gitignore b/.gitignore index 244bb5f33..965928cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index a07db935c..664860498 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ -# DroidDeck +

+ + + DroidDeck + +

-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. + +

Tapping DroidDeck on the Android home screen and landing in Steam Big Picture

## Requirements and install diff --git a/app/build.gradle b/app/build.gradle index 568a8ab49..fac983903 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f53a0d5e0..964152a1a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,7 +48,8 @@ + android:screenOrientation="sensorLandscape" + android:configChanges="keyboard|keyboardHidden|navigation"> @@ -143,6 +144,13 @@ android:process=":externalDisplay" android:exported="false" android:stopWithTask="false" /> + + + :-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>) target_include_directories(bannerwayland PRIVATE generated prebuilt/include diff --git a/app/src/main/cpp/waylandcomp/src/blend.frag b/app/src/main/cpp/waylandcomp/src/blend.frag new file mode 100644 index 000000000..88739de25 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/blend.frag @@ -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); +} diff --git a/app/src/main/cpp/waylandcomp/src/blend.vert b/app/src/main/cpp/waylandcomp/src/blend.vert new file mode 100644 index 000000000..5c87cc352 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/blend.vert @@ -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); +} diff --git a/app/src/main/cpp/waylandcomp/src/blend_frag.h b/app/src/main/cpp/waylandcomp/src/blend_frag.h new file mode 100644 index 000000000..9d2af54a3 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/blend_frag.h @@ -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 +}; diff --git a/app/src/main/cpp/waylandcomp/src/blend_pass.c b/app/src/main/cpp/waylandcomp/src/blend_pass.c new file mode 100644 index 000000000..471eedc9a --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/blend_pass.c @@ -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 + +/* 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; +} diff --git a/app/src/main/cpp/waylandcomp/src/blend_pass.h b/app/src/main/cpp/waylandcomp/src/blend_pass.h new file mode 100644 index 000000000..2faade252 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/blend_pass.h @@ -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 + +/* 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 diff --git a/app/src/main/cpp/waylandcomp/src/blend_vert.h b/app/src/main/cpp/waylandcomp/src/blend_vert.h new file mode 100644 index 000000000..f402c6a92 --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/blend_vert.h @@ -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 +}; diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 1c07aaa0d..3df3e06fe 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -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//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, ¶ms_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); } diff --git a/app/src/main/cpp/waylandcomp/src/compositor_internal.h b/app/src/main/cpp/waylandcomp/src/compositor_internal.h new file mode 100644 index 000000000..db739132a --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/compositor_internal.h @@ -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 +#include +#include +#include +#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//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); diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.c b/app/src/main/cpp/waylandcomp/src/sc_layer.c index cd60fd351..1fcbf3357 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.c +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.c @@ -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(); diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.h b/app/src/main/cpp/waylandcomp/src/sc_layer.h index 94a6f9065..f2f12a704 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.h +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.h @@ -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); diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 8c1d897fc..7a2ecfd48 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -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 #include @@ -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); } diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 899736520..eef9c80cc 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -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. diff --git a/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c b/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c new file mode 100644 index 000000000..bdda9d75e --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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, ¶ms_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)); + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt new file mode 100644 index 000000000..3cbb18656 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt @@ -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(null) + var compCatalog by mutableStateOf>(emptyList()) + var compCatalogAt by mutableStateOf(0L) + var compProton by mutableStateOf(null) + var compComp by mutableStateOf("dxvk") + var compChecking by mutableStateOf(false) + var compBusy by mutableStateOf(null) + var compDownloads by mutableStateOf>(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" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt new file mode 100644 index 000000000..f38f87922 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/DeckyMenu.kt @@ -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(null) + var deckyReleases by mutableStateOf>(emptyList()) + var deckyChecking by mutableStateOf(false) + var deckyStage by mutableStateOf(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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt new file mode 100644 index 000000000..88c2e8853 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt @@ -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>(emptyList()) + /** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */ + var linuxDownloads by mutableStateOf>(emptyList()) + var androidDownloads by mutableStateOf>(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() + var linuxSteam by mutableStateOf("") + var linuxDesktop by mutableStateOf("") + var androidRows by mutableStateOf>(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 +} + diff --git a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt index f71ca928a..cc1b2afc0 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt @@ -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()) } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index fb9aa19bd..fb69a99fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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(null) + /** A session launch waiting on its button to fill the page (LaunchFlood). */ + private var flood by mutableStateOf(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(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(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(null) - private var deckyReleases by mutableStateOf>(emptyList()) - private var deckyChecking by mutableStateOf(false) - private var deckyStage by mutableStateOf(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(null) private var catalog by mutableStateOf?>(null) @@ -115,10 +122,6 @@ class MainActivity : ComponentActivity() { private var desktopEnvironment by mutableStateOf(SessionPrefs.DESKTOP_ENV_LXQT) private var offlineAccount by mutableStateOf(null) private var offline by mutableStateOf(false) - private var protonRows by mutableStateOf>(emptyList()) - private var protonBusyId by mutableStateOf(null) - private var protonStage by mutableStateOf(null) - private var protonPercent by mutableIntStateOf(-1) private var showPerformance by mutableStateOf(false) private var clientOverride by mutableStateOf(false) private var clientCores by mutableStateOf>(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(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>(emptyList()) - /** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */ - private var linuxDownloads by mutableStateOf>(emptyList()) - private var androidDownloads by mutableStateOf>(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() - private var linuxSteam by mutableStateOf("") - private var linuxDesktop by mutableStateOf("") - private var androidRows by mutableStateOf>(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?>(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(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") } } diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt new file mode 100644 index 000000000..ee055bb32 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt @@ -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>(emptyList()) + var protonBusyId by mutableStateOf(null) + var protonStage by mutableStateOf(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)) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index bf4056e5c..d74672715 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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(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(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(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 diff --git a/app/src/main/java/com/droiddeck/launcher/core/ArchivePaths.java b/app/src/main/java/com/droiddeck/launcher/core/ArchivePaths.java new file mode 100644 index 000000000..3f9eaee44 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/ArchivePaths.java @@ -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; + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 0d664147f..df904eace 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt index e76d02d7b..f0c0feb3f 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt @@ -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) + class Preset(val id: String, val label: Int, val detail: Int, val env: List) 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 = 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 = byId(id).env } diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt new file mode 100644 index 000000000..11975edd3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -0,0 +1,38 @@ +package com.droiddeck.launcher.core + +object GameEnvironment { + data class Config( + val shared: Map = emptyMap(), + val games: Map> = emptyMap(), + ) { + fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty() + + fun withEntries(scope: String, entries: Map): 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 = linkedMapOf( + "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 = + defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty() +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt new file mode 100644 index 000000000..0904c5b2a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt @@ -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 = 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(",") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/Hashes.kt b/app/src/main/java/com/droiddeck/launcher/core/Hashes.kt new file mode 100644 index 000000000..072874c87 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/Hashes.kt @@ -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:"), 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) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt b/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt index 3c21f6ff6..4155a29dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/PhantomProcessLimit.kt @@ -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 = 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)" diff --git a/app/src/main/java/com/droiddeck/launcher/core/TarZst.java b/app/src/main/java/com/droiddeck/launcher/core/TarZst.java index 0f0a71636..0a1110f23 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/TarZst.java +++ b/app/src/main/java/com/droiddeck/launcher/core/TarZst.java @@ -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(); diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt index 82b6e4d63..c97ba3a86 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbFix.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt new file mode 100644 index 000000000..d4645962a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/WirelessAdbPairingService.kt @@ -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.Idle) + val stage: StateFlow = 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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt b/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt index c90ae3262..bd180835f 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/CollapsibleRail.kt @@ -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), diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt b/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt new file mode 100644 index 000000000..60c359bf4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt @@ -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 +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt index 8a3c56bba..88c2462ed 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt @@ -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" + } } diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt new file mode 100644 index 000000000..054430090 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt @@ -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 { + 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(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(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, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt new file mode 100644 index 000000000..b1705146f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt @@ -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 { + val inner: Comparator = 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 { 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) +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 6442e656b..ecae61e2f 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -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 { - val inner: Comparator = 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 { 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 { - 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(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(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(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, - ) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt new file mode 100644 index 000000000..8c302b853 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt @@ -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(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, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt index bd5a75983..6d833d931 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt @@ -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, + val steamAppId: Int? = null, ) { fun folderName(): String = folder.name } @@ -96,12 +88,15 @@ object AddedGames { fun scan(context: Context): List { val out = ArrayList() - 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) { @@ -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): 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) } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 3ed6de0a4..29191c37e 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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) { /** 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> { + 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//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 { 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() 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): 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) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt index 90ea86478..236c17a00 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt @@ -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( - 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" } diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt index e9fd3e188..14e7e81e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt @@ -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, val latest: List>, val failed: List, 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>() 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) diff --git a/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt b/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt index 2c7d7bc69..c94b5cf9c 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ControllerEditorActivity.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index 17b659734..e14ac7ccc 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 518904973..890fde729 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index cef72df49..601527623 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index c267da810..e7adcac83 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -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? { - val body = Downloader.downloadString(CATALOG_URL) ?: return null + fun fetch(url: String = CATALOG_URL): List? { + 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" -> { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index d86c86304..20afc9f0e 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -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 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 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 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 zones = thermalZones(); + File zone = zones.get(sensor); + for (java.util.Map.Entry 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 thermalZones() { + java.util.Map 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 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\\"; } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java index 1a6e07c60..4643e9b40 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java @@ -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 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(); - } } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt new file mode 100644 index 000000000..31a26fd34 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt new file mode 100644 index 000000000..da00b1b39 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -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, val originals: List, 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, val packages: List) + data class Catalog(val items: List, 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 { + 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 { + val found = LinkedHashMap() + 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 { + 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 = + 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 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 = 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 { + 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() + 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) { + 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 { + 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() + 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() + 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 = synchronized(lock) { + val state = loadState(context) + val queued = state.sub("queued") + if (queued.length() == 0) return emptyList() + val done = mutableListOf() + 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt new file mode 100644 index 000000000..4210d07e6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -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 = 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) = 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 + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt index 4295747b2..566300c61 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/LoadingState.kt @@ -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(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. */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index ae3326c1e..b0d49257b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -81,7 +81,7 @@ class PerfHud(context: Context) { fun stop() { running = false - WaylandCompositor.setGameListener(null) + WaylandCompositor.clearGameListener(listener) text = "" } diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index fcb920079..2a94134b8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -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 = FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/RivalClients.kt b/app/src/main/java/com/droiddeck/launcher/session/RivalClients.kt index 12436c33e..75973e467 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RivalClients.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RivalClients.kt @@ -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() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 3e39ca93e..cf2f6e03a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index e32be0eec..9ae74a91f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index fddfdbcc6..b0e0bdc64 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -58,6 +58,7 @@ import java.util.Locale */ open class SessionService : Service() { private val components = java.util.concurrent.CopyOnWriteArrayList() + 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() + // 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() + /** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */ + private fun addClientEnvironment(guest: MutableList, 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, 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, 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 { val binds = ArrayList() 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)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt new file mode 100644 index 000000000..7822ef44e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -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, + catalogAt: Long, + protonId: String?, + comp: String, + checking: Boolean, + busy: String?, + downloads: Map, + 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 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)) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index a424ba136..ee5ffd4df 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index 6f75b9b67..df76a1e81 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt new file mode 100644 index 000000000..2d533fd52 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrameGenMenu.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt new file mode 100644 index 000000000..a49df3698 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -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, 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) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt new file mode 100644 index 000000000..3d6424001 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -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? { + 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, 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), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt new file mode 100644 index 000000000..523ce958a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -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, 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, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt new file mode 100644 index 000000000..41f38fe41 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 286a4f333..d245f0346 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -1,5 +1,7 @@ package com.droiddeck.launcher.ui +import java.io.File + import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.ExperimentalComposeUiApi @@ -8,131 +10,61 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged -import android.graphics.Bitmap -import android.os.Build import android.provider.Settings import android.view.Display import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.MutableTransitionState -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.snap import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -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.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.BoxWithConstraints import androidx.compose.runtime.key import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator 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.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow 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.blur -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect 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.layout.LayoutCoordinates -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.input.InputMode -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.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil.compose.AsyncImage -import com.droiddeck.launcher.R import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen @@ -140,8 +72,8 @@ import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus import com.droiddeck.launcher.session.SessionPrefs -import java.io.File import kotlin.math.roundToInt +import kotlinx.coroutines.withTimeoutOrNull class FrontEndState( val installed: String?, @@ -163,7 +95,7 @@ class FrontEndState( val frameGenMultiplier: Int = 2, val lsfgReady: Boolean = false, val pageKey: String? = null, - val theme: String = Themes.PAPER, + val theme: String = Themes.GRAPHITE, val isHomeApp: Boolean = false, val homeScreenEnabled: Boolean = false, val defaultHomeLabel: String? = null, @@ -181,6 +113,7 @@ class FrontEndState( val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null, val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE, val showPhantomGate: Boolean = false, + val launcherFullscreen: Boolean = true, ) class FrontEndActions( @@ -198,15 +131,19 @@ class FrontEndActions( val onRuntime: () -> Unit, val onFrameGenPick: (engine: String, multiplier: Int) -> Unit, val onProtons: () -> Unit, + /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ + val onComponents: (focusContent: Boolean) -> Unit, val onPerformance: () -> Unit, val onRoms: () -> Unit, val onFiles: () -> Unit, + val onBrowseFiles: (File) -> Unit = {}, val onLogs: () -> Unit, val onShareLogs: () -> Unit = {}, val onOffline: () -> Unit, val onCredits: () -> Unit, val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, + val onLauncherFullscreen: (Boolean) -> Unit = {}, val onHomeApp: () -> Unit = {}, val onHomeScreen: (Boolean) -> Unit = {}, val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> }, @@ -214,18 +151,23 @@ class FrontEndActions( val onCheckLatestBuild: () -> Unit = {}, val onRefreshPhantomStatus: () -> Unit = {}, val onOpenDeveloperOptions: (Int?) -> Unit = {}, - val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") }, + val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") }, val onFindWirelessAdbPort: (String, (Int?) -> Unit) -> Unit = { _, done -> done(null) }, - val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless ADB is unavailable") }, - val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless ADB is unavailable") }, + val onWirelessAdbApply: (String, Int, Boolean, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") }, + val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") }, val onCopyPhantomCommand: (Boolean) -> Unit = {}, val onDismissPhantomGate: () -> Unit = {}, + val onStartWirelessAdbPairing: () -> Unit = {}, + val onOpenNotificationSettings: () -> Unit = {}, val controller: ControllerActions? = null, ) - internal object Motion { var scale = 1f + /** The system's animator scale, re-read on each resume so "Remove animations" applies without a restart. */ + fun refresh(context: android.content.Context) { + scale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + } val Ease = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) fun ms(base: Int) = (base * scale).roundToInt() fun tw(base: Int, delay: Int = 0): FiniteAnimationSpec = if (scale == 0f) snap() else tween(ms(base), ms(delay), Ease) @@ -234,11 +176,14 @@ internal object Motion { } private val Shape10 = RoundedCornerShape(10.dp) -private val Shape12 = RoundedCornerShape(12.dp) -private fun hueOf(name: String) = (name.hashCode().toUInt() % 360u).toFloat() -private fun tint(h: Float, s: Float = 0.7f, v: Float = 0.58f) = Color.hsv(h, s, v) -private fun artBrush(h: Float) = Brush.linearGradient(listOf(tint(h), tint((h + 32f) % 360f, 0.65f, 0.30f), tint((h + 64f) % 360f, 0.6f, 0.14f))) +internal val Shape12 = RoundedCornerShape(12.dp) + +internal fun hueOf(name: String) = (name.hashCode().toUInt() % 360u).toFloat() + +internal fun tint(h: Float, s: Float = 0.7f, v: Float = 0.58f) = Color.hsv(h, s, v) + +internal fun artBrush(h: Float) = Brush.linearGradient(listOf(tint(h), tint((h + 32f) % 360f, 0.65f, 0.30f), tint((h + 64f) % 360f, 0.6f, 0.14f))) @Composable internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit) { @@ -259,7 +204,7 @@ private fun Modifier.staggerIn(i: Int): Modifier { } @Composable -private fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier { +internal fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier { val x = remember { Animatable(-1f) } LaunchedEffect(trigger) { if (trigger) { x.snapTo(-1f); x.animateTo(1f, Motion.tw(800)) } } return drawWithContent { @@ -278,16 +223,14 @@ private fun Modifier.shine(trigger: Boolean, strength: Float = 0.22f): Modifier } } - /** * Controller focus on the front end: each rail item's requester and the page's main button (Play, * Open desktop, Open , Launch...). The pane leaves, to the left, for the selected rail * item - whatever tile or button it leaves from - and remembers that control, so coming back in * lands on it again; a page not yet visited enters on its main button. */ -private class FrontFocus { +internal class FrontFocus { val rail = HashMap() - val menuToggle = FocusRequester() val primary = FocusRequester() var primaryAttached by mutableStateOf(0) var focusedRail by mutableStateOf(null) @@ -296,7 +239,8 @@ private class FrontFocus { // and the last one focused. val items = HashMap() val attached = HashMap() - var last: String? = null + // State, so the controller hints follow it: A launches from a game row but selects elsewhere. + var last by mutableStateOf(null) // The first tile of the page's grid: Down from the page's buttons goes to it, not to whichever // tile happens to sit under the button. val firstTile = FocusRequester() @@ -315,14 +259,14 @@ private class FrontFocus { /** Down from this button goes to the first tile of the page's grid, when there is one. */ @Composable -private fun Modifier.downToFirstTile(): Modifier { +internal fun Modifier.downToFirstTile(): Modifier { val ff = LocalFrontFocus.current ?: return this return this.focusProperties { down = if (ff.firstTileAttached > 0) ff.firstTile else FocusRequester.Default } } /** Marks the first tile of the page's grid. */ @Composable -private fun Modifier.firstTile(): Modifier { +internal fun Modifier.firstTile(): Modifier { val ff = LocalFrontFocus.current ?: return this DisposableEffect(Unit) { ff.firstTileAttached++ @@ -333,7 +277,7 @@ private fun Modifier.firstTile(): Modifier { /** Lets the pane come back to this control: it is remembered when focused. */ @Composable -private fun Modifier.paneItem(id: String): Modifier { +internal fun Modifier.paneItem(id: String): Modifier { val ff = LocalFrontFocus.current ?: return this val req = remember(id) { ff.items.getOrPut(id) { FocusRequester() } } DisposableEffect(id) { @@ -343,7 +287,23 @@ private fun Modifier.paneItem(id: String): Modifier { return this.focusRequester(req).onFocusChanged { if (it.isFocused) ff.last = id } } -private val LocalFrontFocus = staticCompositionLocalOf { null } +internal val LocalFrontFocus = staticCompositionLocalOf { null } + +/** The pane's exit animation (170ms) and a frame's margin: a new page is in after this. */ +private const val PAGE_EXIT_MS = 200 + +/** + * Asks [target] for focus once a frame until [done] (or a focus request lands), for at most half a + * second: a control can take focus only once it is laid out, and that is a frame or two, not a + * fixed number of milliseconds. + */ +internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusRequester) { + repeat(30) { + androidx.compose.runtime.withFrameNanos { } + if (done()) return + if (runCatching { target().requestFocus() }.isSuccess && done()) return + } +} @Composable fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { @@ -355,12 +315,12 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () - @Composable private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) { var selected by rememberSaveable { mutableStateOf("steam") } - var navOpen by rememberSaveable { mutableStateOf(false) } var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) } var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) } var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current val ctx = LocalContext.current val phantomGateVisible = s.showPhantomGate && PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) val processSettingsPageVisible = phantomGateVisible || showWirelessAdbFix || showDeveloperDisplayChoice @@ -381,120 +341,110 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() } - // Back (and B) from a game or an emulator steps out one level, as its "โ€น" link does, instead - // of leaving the app: a game -> its emulator (or Steam), an emulator -> Desktop. + // Back (and B) from a ROM or an emulator steps out one level, as its "โ€น" link does, instead + // of leaving the app: a ROM -> its emulator, an emulator -> Desktop. BackHandler( enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) && - (selected.startsWith("app:") || selected.startsWith("emu:") || selected.startsWith("rom:")), + (selected.startsWith("emu:") || selected.startsWith("rom:")), ) { - selected = when { - selected.startsWith("app:") -> "steam" - selected.startsWith("emu:") -> "desktop" - else -> "emu:" + selected.removePrefix("rom:").substringBefore(':') - } + selected = if (selected.startsWith("emu:")) "desktop" else "emu:" + selected.removePrefix("rom:").substringBefore(':') } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } - remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true } + // The last game uninstalled leaves the Games tab on its empty state. + LaunchedEffect(s.steamGames.isEmpty()) { + if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" + } + remember { Motion.refresh(ctx); true } - var anyFocused by remember { mutableStateOf(false) } - BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.background).systemBarsPadding().onFocusChanged { anyFocused = it.hasFocus }) { - val drawerWidth = minOf(280.dp, maxWidth * 0.82f) - val railSelection = when { - s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true -> "steam" - s.pageKey?.startsWith("settings:") == true -> "desktop" - selected.startsWith("app:") -> "steam" - selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" - else -> s.pageKey ?: selected - } - val onRailSelect: (String) -> Unit = { key -> + val railSelection = when { + s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" + s.pageKey?.startsWith("settings:steam") == true -> "steam" + s.pageKey?.startsWith("settings:") == true -> "desktop" + selected.startsWith("app:") -> "games" + selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" + else -> s.pageKey ?: selected + } + // Bumped each time a rail item is picked, so a controller moves on into the new page. + var railPicks by remember { mutableStateOf(0) } + val showRailPage: (String, Boolean) -> Unit = { key, focusContent -> + // Components is a full page like Protons or Performance, opened over the current rail + // selection rather than replacing it. + if (key == "components") a.onComponents(focusContent) + else { if (s.pageKey != null) a.onPageBack() selected = key - navOpen = false } - // Start controllers on the current page's main action; the rail is initially collapsed. - val window = LocalWindowInfo.current - val inputMode = LocalInputModeManager.current + } + val onRailFocus: (String) -> Unit = { key -> + if (key != railSelection) showRailPage(key, false) + } + val onRailSelect: (String) -> Unit = { key -> + showRailPage(key, true) + railPicks++ + } + val inputModeManager = LocalInputModeManager.current + val window = LocalWindowInfo.current + var anyFocused by remember { mutableStateOf(false) } + Box( + modifier = Modifier.fillMaxSize().background(colors.background) + .then(if (s.launcherFullscreen) Modifier else Modifier.systemBarsPadding()) + .onFocusChanged { anyFocused = it.hasFocus }, + ) { + // Start controllers on the current page's main action, else on the rail. LaunchedEffect(processSettingsPageVisible) { if (processSettingsPageVisible) return@LaunchedEffect snapshotFlow { window.isWindowFocused }.first { it } - repeat(20) { - if (anyFocused) return@LaunchedEffect - if (inputMode.inputMode != InputMode.Keyboard) inputMode.requestInputMode(InputMode.Keyboard) - val target = if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.menuToggle - runCatching { target.requestFocus() } - kotlinx.coroutines.delay(100) - } + if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) + focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } // A tile or button that opens a page goes away with the page it was on, and focus with it; // the pad then had nothing to move from (a press landed back on the rail's first item). So // once the new page is in, a controller lands on its main button. - val inputModeManager = LocalInputModeManager.current LaunchedEffect(selected, s.pageKey, processSettingsPageVisible) { if (processSettingsPageVisible) return@LaunchedEffect - kotlinx.coroutines.delay(450) - if (!anyFocused && inputModeManager.inputMode == InputMode.Keyboard) runCatching { - if (frontFocus.primaryAttached > 0) frontFocus.primary.requestFocus() - else frontFocus.menuToggle.requestFocus() - } + // Past the old page's exit (170ms), then the first frame the new page takes focus. + kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) + if (anyFocused || inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + focusWithinFrames({ anyFocused }) { if (frontFocus.primaryAttached > 0) frontFocus.primary else frontFocus.railFor(railSelection) } } - LaunchedEffect(navOpen, railSelection, processSettingsPageVisible) { - if (processSettingsPageVisible) return@LaunchedEffect - if (navOpen) { - frontFocus.focusedRail = null - repeat(12) { - if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) - runCatching { frontFocus.railFor(railSelection).requestFocus() } - if (frontFocus.focusedRail == railSelection) return@LaunchedEffect - kotlinx.coroutines.delay(80) - } - } else { - kotlinx.coroutines.delay(120) - if (!anyFocused && inputModeManager.inputMode == InputMode.Keyboard) runCatching { - if (frontFocus.primaryAttached > 0) frontFocus.paneEntry().requestFocus() - else frontFocus.menuToggle.requestFocus() - } - } + // A rail item picked with a controller moves on into its page, once the page is in. + LaunchedEffect(railPicks) { + if (railPicks == 0 || processSettingsPageVisible) return@LaunchedEffect + kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) + if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + // Waits for the new page's main button rather than a fixed guess at how long it takes. + withTimeoutOrNull(1_000) { snapshotFlow { frontFocus.primaryAttached > 0 }.first { it } } ?: return@LaunchedEffect + focusWithinFrames({ frontFocus.focusedRail == null && anyFocused }) { frontFocus.paneEntry() } } val paneFocus = Modifier .focusProperties { enter = { frontFocus.paneEntry() } } .focusGroup() - val content: @Composable (Modifier) -> Unit = { m -> - Pane( - s, selected, a, page, m.then(paneFocus), { selected = it }, - onAndroidAppClick = { app -> - if (s.secondScreenDisplays.isEmpty()) a.onAndroidApp(app, null) - else appToChooseDisplay = app - }, - onOpenDeveloperOptions = requestDeveloperOptions, - onRequestWirelessAdb = requestWirelessAdbFix, - ) - } - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { - LauncherTopBar(frontFocus.menuToggle, s.buildLabel, a.onCheckLatestBuild) { navOpen = true } - content(Modifier.weight(1f).fillMaxWidth()) - } - AnimatedVisibility( - visible = navOpen, - modifier = Modifier.fillMaxSize(), - enter = fadeIn(Motion.tw(150)) + slideInHorizontally(Motion.tw(210)) { -it / 5 }, - exit = fadeOut(Motion.tw(130)) + slideOutHorizontally(Motion.tw(170)) { -it / 5 }, - ) { - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier.fillMaxSize() - .background(Color.Black.copy(alpha = 0.62f)) - .clickable { navOpen = false }, + val railFocus = Modifier + .focusProperties { enter = { frontFocus.railFor(railSelection) } } + .focusGroup() + Row(modifier = Modifier.fillMaxSize()) { + SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight()) + Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + Pane( + s, selected, a, page, Modifier.weight(1f).fillMaxWidth().then(paneFocus), { selected = it }, + onAndroidAppClick = { app -> + if (s.secondScreenDisplays.isEmpty()) a.onAndroidApp(app, null) + else appToChooseDisplay = app + }, + onOpenDeveloperOptions = requestDeveloperOptions, + onRequestWirelessAdb = requestWirelessAdbFix, + ) + // Only while a pad or keyboard drives the launcher; a touch hides it again. + AnimatedVisibility( + inputModeManager.inputMode == InputMode.Keyboard, + enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), + ) { + val onGame = frontFocus.focusedRail == null && frontFocus.last?.startsWith("game:") == true + ControllerHints( + select = if (onGame) "Launch" else "Select", + tabs = railSelection == "setup" && s.pageKey == null && frontFocus.focusedRail == null, ) - Box( - modifier = Modifier.align(Alignment.CenterStart) - .width(drawerWidth).fillMaxHeight() - .focusProperties { exit = { FocusRequester.Cancel } } - .focusGroup().zIndex(1f), - ) { - Rail(s, railSelection, onRailSelect, a, Modifier.fillMaxSize()) { navOpen = false } - } } } } @@ -521,7 +471,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com status = s.phantomProcessStatus, onDismiss = a.onDismissPhantomGate, onOpenDeveloperOptions = requestDeveloperOptions, - onSetUpWirelessAdb = { requestWirelessAdbFix(false) }, + onFixWithWirelessDebugging = { + a.onStartWirelessAdbPairing() + requestDeveloperOptions() + }, + onEnterAddressManually = { requestWirelessAdbFix(false) }, + onCopyCommand = { a.onCopyPhantomCommand(false) }, + onOpenNotificationSettings = a.onOpenNotificationSettings, ) } } @@ -554,902 +510,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com ) } } - BackHandler(enabled = navOpen && !processSettingsPageVisible) { navOpen = false } val hasBackTarget = (s.pageKey != null && page != null) || ((s.pageKey == null || page == null) && - (selected.startsWith("app:") || selected.startsWith("emu:") || selected.startsWith("rom:"))) - BackHandler(enabled = !processSettingsPageVisible && !navOpen && !hasBackTarget) { navOpen = true } -} - - -@Composable -private fun LauncherTopBar( - menuRequester: FocusRequester, - buildLabel: String, - onCheckLatestBuild: () -> Unit, - onMenu: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - val palette = LocalPalette.current - var menuFocused by remember { mutableStateOf(false) } - Row( - modifier = Modifier.fillMaxWidth().height(48.dp).background(colors.surface), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = onMenu, - modifier = Modifier.size(48.dp).focusRequester(menuRequester) - .onFocusChanged { menuFocused = it.isFocused } - .border(2.dp, if (menuFocused) palette.signal else Color.Transparent, Shape10), - ) { - Icon(Icons.Filled.Menu, contentDescription = "Open navigation", tint = colors.onBackground) - } - Image(painterResource(R.drawable.logo), null, modifier = Modifier.size(24.dp)) - Spacer(Modifier.width(8.dp)) - Text("DroidDeck", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Spacer(Modifier.weight(1f)) - BuildStatus( - label = buildLabel, - onCheckLatest = onCheckLatestBuild, - modifier = Modifier.widthIn(max = 220.dp).padding(start = 12.dp, end = 8.dp), - ) - } -} - - -@Composable -private fun Rail( - s: FrontEndState, selected: String, - onSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier, onDismiss: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - Column( - modifier = modifier.background(colors.surface).padding(horizontal = 10.dp, vertical = 12.dp), - ) { - Column( - modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, bottom = 10.dp)) { - Image(painterResource(R.drawable.logo), null, modifier = Modifier.size(30.dp)) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text("DroidDeck", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) - val status = when { - s.busy -> if (s.percent >= 0) "${s.stage} ${s.percent}%" else s.stage - !s.ready -> "runtime not installed" - s.available != null && s.available != s.installed -> "runtime update available" - else -> null - } - if (status != null) Text(status, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) { - Icon(Icons.Filled.Close, contentDescription = "Close navigation", tint = colors.onSurfaceVariant) - } - } - AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(bottom = 8.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)) + slideInVertically(Motion.sp(0.6f)) { -it / 2 }, - exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)), - ) { RunningTile(lastRunning, a.onResume) } - - val pal = LocalPalette.current - var navOrigin by remember { mutableStateOf(Offset.Zero) } - val positions = remember { mutableStateMapOf() } - Box(modifier = Modifier.fillMaxWidth().onGloballyPositioned { val o = it.positionInRoot(); if (o != navOrigin) navOrigin = o }) { - val target = positions[selected]?.translate(-navOrigin) - val y by animateFloatAsState(target?.top ?: 0f, Motion.sp(0.72f, Spring.StiffnessLow), label = "indY") - val x by animateFloatAsState(target?.left ?: 0f, Motion.sp(0.9f), label = "indX") - val h by animateFloatAsState(target?.height ?: 0f, Motion.sp(1f, Spring.StiffnessMediumLow), label = "indH") - val w by animateFloatAsState(target?.width ?: 0f, Motion.sp(1f, Spring.StiffnessMediumLow), label = "indW") - val alpha by animateFloatAsState(if (target != null) 1f else 0f, Motion.tw(250), label = "indA") - val density = LocalDensity.current - Box( - modifier = Modifier - .offset { IntOffset(x.roundToInt(), y.roundToInt()) } - .width(with(density) { w.toDp() }).height(with(density) { h.toDp() }) - .alpha(alpha) - .graphicsLayer { shadowElevation = 6.dp.toPx(); shape = Shape10; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal } - .clip(Shape10) - .background(Brush.linearGradient(listOf(colors.primary, pal.primary2))), - ) - val register: (String, LayoutCoordinates) -> Unit = { key, c -> - val o = c.positionInRoot(); val r = Rect(o.x, o.y, o.x + c.size.width, o.y + c.size.height) - if (positions[key] != r) positions[key] = r - } - val unregister: (String) -> Unit = { positions.remove(it) } - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (s.isHomeApp) { - NavItem("Android apps", "android-apps", selected == "android-apps", count = s.androidApps.size, - register = register, unregister = unregister) { onSelect("android-apps") } - } - NavItem("Steam", "steam", selected == "steam", count = s.steamGames.size, register = register, unregister = unregister) { onSelect("steam") } - NavItem("Desktop", "desktop", selected == "desktop", count = s.emulators.count { it.installed }, register = register, unregister = unregister) { onSelect("desktop") } - NavItem("Setup", "setup", selected == "setup", register = register, unregister = unregister) { onSelect("setup") } - Spacer(Modifier.height(6.dp)) - } - } - - val creditsSrc = remember { MutableInteractionSource() } - val creditsHot = rememberHot(creditsSrc) - Text( - "credits", fontSize = 11.sp, color = if (creditsHot) LocalPalette.current.signal else colors.onSurfaceVariant, - modifier = Modifier.clip(Shape10) - .border(2.dp, if (creditsHot) LocalPalette.current.signal else Color.Transparent, Shape10) - .hoverable(creditsSrc).clickable(interactionSource = creditsSrc, indication = null, onClick = a.onCredits) - .padding(horizontal = 10.dp, vertical = 8.dp), - ) - } - } -} - -@Composable -private fun RunningTile(name: String, onResume: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value - val shift by animateFloatAsState(if (hot) 3f else 0f, Motion.sp(0.6f), label = "runShift") - val pulse = rememberInfiniteTransition(label = "pulse") - val ring by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring") - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp) - .graphicsLayer { translationX = shift.dp.toPx() } - .clip(Shape12) - .background(Brush.linearGradient(listOf(pal.signal.copy(alpha = 0.22f), pal.signal.copy(alpha = 0.06f)))) - .border(1.dp, pal.signal.copy(alpha = if (hot) 0.8f else 0.35f), Shape12) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onResume) - .padding(horizontal = 10.dp, vertical = 9.dp), - ) { - Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) { - Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ring; scaleY = ring; alpha = (1.6f - ring) / 1.2f }.border(1.5.dp, pal.good, CircleShape)) - Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good)) - } - Spacer(Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("Resume", fontSize = 11.sp, color = colors.onSurfaceVariant) - } - } -} - -@Composable -private fun NavItem( - label: String, key: String, current: Boolean, small: Boolean = false, tiny: Boolean = false, muted: Boolean = false, - caret: Boolean? = null, count: Int? = null, value: String? = null, i: Int = 0, - register: ((String, LayoutCoordinates) -> Unit)? = null, unregister: ((String) -> Unit)? = null, onClick: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - val src = remember { MutableInteractionSource() } - val focused by src.collectIsFocusedAsState() - val frontFocus = if (register != null && key != "x") LocalFrontFocus.current else null - val railRequester = frontFocus?.railFor(key) - if (frontFocus != null) LaunchedEffect(focused) { - if (focused) frontFocus.focusedRail = key - else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null - } - val hovered by src.collectIsHoveredAsState() - val pressed by src.collectIsPressedAsState() - val fg by animateColorAsState(if (current) colors.onPrimary else if (muted) colors.onSurfaceVariant else colors.onBackground, Motion.tw(280), label = "navFg") - val sub by animateColorAsState(if (current) colors.onPrimary else colors.onSurfaceVariant, Motion.tw(280), label = "navSub") - val ring by animateColorAsState(if (focused) LocalPalette.current.signal else Color.Transparent, Motion.tw(180), label = "navRing") - val scale by animateFloatAsState(if (pressed) 0.98f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "navScale") - val rot by animateFloatAsState(if (caret == true) 90f else 0f, Motion.sp(0.6f), label = "caret") - if (key != "x") DisposableEffect(key) { onDispose { unregister?.invoke(key) } } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - .then(if (register != null && key != "x") Modifier.onGloballyPositioned { register(key, it) } else Modifier) - .then(if (railRequester != null) Modifier.focusRequester(railRequester) else Modifier) - .then(if (small && i >= 0 && register != null) Modifier.staggerIn(i) else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape10) - .background(if (hovered && !current) Color.White.copy(alpha = 0.04f) else if (current && hovered) Color.White.copy(alpha = 0.08f) else Color.Transparent) - .border(1.5.dp, ring, Shape10) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) - .padding(horizontal = 10.dp, vertical = if (small) 7.dp else 9.dp), - ) { - if (caret != null) { - Text("โ€บ", fontSize = 16.sp, color = sub, modifier = Modifier.width(14.dp).rotate(rot)) - } - Text( - label, fontSize = if (tiny) 13.sp else if (small) 14.sp else 15.sp, - fontWeight = if (tiny) FontWeight.Normal else if (small) FontWeight.Medium else FontWeight.SemiBold, - color = fg, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - if (value != null) Text(value, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (count != null) Text( - count.toString(), fontSize = 11.sp, color = sub, - modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (current) colors.onPrimary.copy(alpha = 0.16f) else colors.background).padding(horizontal = 7.dp, vertical = 2.dp), - ) - } -} - - -@Composable -private 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, -) { - Box(modifier = modifier) { - val wash: Pair = when { - s.pageKey != null && page != null -> null to 250f - selected == "steam" -> null to 268f - selected == "desktop" -> null to 200f - selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }.let { it?.art to hueOf(it?.name ?: "") } - selected.startsWith("emu:") -> null to hueOf(selected) - selected.startsWith("rom:") -> romFor(s, selected).let { it?.second?.art to hueOf(it?.second?.name ?: "") } - selected == "android-apps" -> null to hueOf("android-apps") - else -> null to 268f - } - Backdrop(wash) - AnimatedContent( - targetState = if (page != null && s.pageKey != null) s.pageKey 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, key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) - } - } -} - -@Composable -private fun BuildStatus(label: String, onCheckLatest: () -> Unit, modifier: Modifier = Modifier) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val interaction = remember { MutableInteractionSource() } - val hot = rememberHot(interaction) - val contextDivider = label.indexOf(" ยท ") - val context = if (contextDivider >= 0) label.substring(0, contextDivider) else "" - val identity = if (contextDivider >= 0) label.substring(contextDivider + 3) else label - val identityDivider = identity.lastIndexOf(" at ") - val branch = (if (identityDivider >= 0) identity.substring(0, identityDivider) else identity).substringAfterLast('/') - val commit = if (identityDivider >= 0) identity.substring(identityDivider + 4) else "" - Column( - verticalArrangement = Arrangement.spacedBy(1.dp), - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .semantics { contentDescription = "Build $label. Check for newest build." } - .hoverable(interaction) - .clickable( - interactionSource = interaction, - indication = LocalIndication.current, - role = Role.Button, - onClick = onCheckLatest, - ) - .heightIn(min = 44.dp) - .padding(horizontal = 4.dp, vertical = 3.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Text(branch, modifier = Modifier.weight(1f), fontSize = 10.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, color = if (hot) colors.onSurfaceVariant else colors.onSurfaceVariant.copy(alpha = 0.82f), maxLines = 1, overflow = TextOverflow.Ellipsis) - if (commit.isNotEmpty()) Text("@$commit", fontSize = 9.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant.copy(alpha = 0.82f), maxLines = 1) - Icon(Icons.Filled.Refresh, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant.copy(alpha = 0.82f), modifier = Modifier.size(12.dp)) - } - if (context.isNotEmpty()) Text(context, fontSize = 9.sp, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, color = colors.onSurfaceVariant.copy(alpha = 0.82f), maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - -@Composable -private fun Backdrop(wash: Pair) { - val colors = MaterialTheme.colorScheme - Crossfade(targetState = wash, animationSpec = Motion.tw(700), label = "backdrop") { (art, hue) -> - val canBlur = Build.VERSION.SDK_INT >= 31 - Box(modifier = Modifier.fillMaxSize().alpha(if (canBlur) 0.28f else 0.18f).then(if (canBlur) Modifier.blur(70.dp) else Modifier)) { - if (art != null && canBlur) { - AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f }) - } else { - Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(tint(hue, 0.8f, 0.55f), Color.Transparent), center = Offset(0.3f, 0.3f), radius = 900f))) - Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(tint((hue + 60f) % 360f, 0.7f, 0.45f), Color.Transparent), center = Offset(1600f, 1400f), radius = 900f))) - } - } - } - Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f))))) -} - -private fun romFor(s: FrontEndState, selected: String): Pair? { - 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 - Column(modifier = modifier.padding(horizontal = 22.dp, vertical = 18.dp)) { - when { - selected == "android-apps" && s.isHomeApp -> { - Rise(0) { Eyebrow("Android apps") } - Rise(1) { Title("Installed apps") } - Rise(2) { SectionTitle("Apps", s.androidApps.size.toString()) } - if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") } - else Rise(3, Modifier.weight(1f).fillMaxWidth()) { - ArtGrid(s.androidApps.map { app -> - Tile( - app.label, - null, - null, - "android:${app.packageName}", - onClick = { onAndroidAppClick(app) }, - iconBitmap = app.icon, - ) - }) - } - } - selected == "steam" -> { - Rise(0) { Eyebrow("Steam") } - Rise(3) { - Actions { - // Enabled without a runtime: the session's loading screen installs it first. - PrimaryButton("Play", enabled = !s.busy, main = true, onClick = a.onPlay) - SecondaryButton("Steam Desktop UI", enabled = !s.busy, onClick = a.onPlayDesktopUi) - Cog(a.onSteamSettings) - } - } - Rise(4) { SectionTitle("Installed", "${s.steamGames.size} game${if (s.steamGames.size == 1) "" else "s"}") } - if (s.steamGames.isEmpty()) Rise(5) { Note("No games installed.") } - else Rise(5, Modifier.weight(1f).fillMaxWidth()) { ArtGrid(s.steamGames.map { g -> Tile(g.name, g.library, g.art, "steam:${g.appId}", null, showFooter = false) { onSelect("app:${g.appId}") } }) } - } - selected.startsWith("app:") -> { - val g = s.steamGames.firstOrNull { "app:${it.appId}" == selected } - if (g == null) Note("That game is no longer installed.") else { - Rise(0) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - "โ€น Steam", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { onSelect("steam") }.padding(horizontal = 6.dp, vertical = 4.dp), - ) - Eyebrow(g.library) - } - } - Rise(1) { Title(g.name) } - Rise(2) { - Row { - Column(modifier = Modifier.weight(1f)) { - Actions { - PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true) { a.onSteamGame(g) } - Cog(a.onSteamSettings) - Chip(if (s.ready) "โ— ready" else "runtime missing", ok = s.ready) - } - } - Poster(g.art, g.name, Modifier.width(detailPosterWidth)) - } - } - val others = s.steamGames.filter { it !== g } - if (others.isNotEmpty()) { - Rise(3) { SectionTitle("More from the library", null) } - Rise(4, Modifier.weight(1f).fillMaxWidth()) { ArtGrid(others.map { x -> Tile(x.name, x.library, x.art, "steam:${x.appId}", null, showFooter = false) { onSelect("app:${x.appId}") } }) } - } - } - } - selected == "desktop" -> { - Rise(0) { Eyebrow("Desktop") } - Rise(3) { - 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(a.onDesktopSettings) - } - } - Rise(4) { SectionTitle("Emulators", "${s.emulators.count { it.installed }} installed ยท ${s.emulators.count { !it.installed }} available") } - Rise(5, Modifier.weight(1f).fillMaxWidth()) { - ArtGrid(s.emulators.map { e -> Tile(e.name, if (e.installed) (if (e.id == "retroarch") null else "${e.games.size} game${if (e.games.size == 1) "" else "s"}") else "Select to install", null, "emu:${e.id}", e.iconRes, dim = !e.installed) { onSelect("emu:${e.id}") } }) - } - } - selected == "setup" -> SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb) - 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) { - Text( - "โ€น Desktop", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { onSelect("desktop") }.padding(horizontal = 6.dp, vertical = 4.dp), - ) - } - Rise(1) { Title(e.name) } - if (e.installed) { - Rise(3) { - Actions { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp)) - PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) } - SecondaryButton("ROMs folder", onClick = a.onRoms) - if (pkg != null) SecondaryButton( - if (pkg.kind == "appimage") "Remove" else "Forget", - 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.weight(1f).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 { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp)) - 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) Chip("Stop session to install", ok = false) - else if (!s.ready) Chip("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("Forgetting this package hides it from Desktop; its files remain 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)) { - Text( - "โ€น ${e.name}", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { onSelect("emu:${e.id}") }.padding(horizontal = 6.dp, vertical = 4.dp), - ) - 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.ready && !s.busy, main = true) { a.onRom(g) } - Chip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false) - } - } - if (g.art != null) 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.weight(1f).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("Select an item.") - } - } -} - -@Composable -private 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(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" - } - Rise(0, Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { - Rise(0) { Eyebrow("Setup") } - Rise(1) { Title("Setup") } - SettingsGroup("Android process limit") { - Text( - PhantomProcessLimit.title(s.phantomProcessStatus), - fontWeight = FontWeight.SemiBold, - color = if (PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - ) - Text( - "Enabling restores Androidโ€™s child-process limit. Disabling it is recommended for Steam sessions.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 14.dp), - ) - if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)) { - SecondaryButton("Enable limit", enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.ENABLED) { setProcessLimit(true) } - SecondaryButton("Disable limit", enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.DISABLED) { setProcessLimit(false) } - } - if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp)) - processLimitMessage?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) } - } - Text(PhantomProcessLimit.instructions(s.phantomProcessStatus), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) - Text(PhantomProcessLimit.ADB_COMMAND, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp)) { - SecondaryButton("Developer options", onClick = onOpenDeveloperOptions) - SecondaryButton("Check again", onClick = a.onRefreshPhantomStatus) - SecondaryButton("Copy ADB", onClick = { a.onCopyPhantomCommand(false) }) - } - } - SettingsGroup("Launcher tools") { - ActionRow("Files", "Browse and manage files", "Open", a.onFiles) - ActionRow("Compatibility tools", "Install ARM64 Proton builds", "Manage", a.onProtons) - ActionRow("Performance", "CPU core assignment", "Configure", a.onPerformance) - ActionRow("ROMs folder", s.romsDir ?: "Choose where emulator games are stored", "Choose", a.onRoms) - } - val controller = s.controller - if (controller != null && a.controller != null) SettingsGroup("Controller") { - ControllerRows(host, s.oscMode, controller, a.controller) - } - 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" } - AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus -> - val need = if (s.lsfgReady) null else "Install Lossless Scaling in Steam" - MenuItem("Off", checked = s.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 = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null } - for (m in 2..4) MenuItem("LSFG ${m}ร—", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null } - } - } - } - SettingsRow("Session logs", "${if (s.logsEnabled) "Enabled" else "Disabled"} ยท logs are saved after each session") { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SecondaryButton(if (s.logsEnabled) "Turn off" else "Turn on") { a.onLogs() } - SecondaryButton("Share latest") { a.onShareLogs() } - } - } - SettingsRow("Offline mode", s.offlineAccount?.let { if (s.offline) "Enabled for $it" else "Signed in as $it" } ?: "Sign in to Steam first") { - SecondaryButton(if (s.offline) "Turn off" else "Turn on", enabled = s.offlineAccount != null) { a.onOffline() } - } - } - SettingsGroup("Application") { - ActionRow("Linux runtime", "${s.installed ?: "Not installed"}${if (s.available != null && s.available != s.installed) " ยท update available" else ""}", runtime, a.onRuntime) - 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) } - if (s.homeScreenEnabled) { - ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp) - } - } - } - } -} - -private 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 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 = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.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 -private 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 = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp, color = colors.onSurfaceVariant) - if (detail != null) Text(detail, fontSize = 11.sp, color = colors.onBackground) - Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line)) - } -} -@Composable private 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)) -@Composable private fun Actions(content: @Composable () -> Unit) = Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { content() } - -@Composable -private fun Chip(t: String, ok: Boolean) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - Text( - t, fontSize = 11.sp, color = if (ok) pal.good else colors.onSurfaceVariant, - 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 -private 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, - 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") - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = track - .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier) - .graphicsLayer { 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, onClick = onClick) - .padding(horizontal = if (compact) 10.dp else 18.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 = if (enabled) colors.onPrimary else colors.onSurfaceVariant, maxLines = 1) - } -} - -@Composable -internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, 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( - modifier = Modifier.paneItem("btn:$text").downToFirstTile().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 -private fun Cog(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(42.dp).clip(Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, 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(18.dp).rotate(rot)) } -} - -@Composable -private 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 = 11.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) - } -} - - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ArtGrid(tiles: List, wide: Boolean = false) { - val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) } - // Thumbnails to recognise a game by, not posters; icon tiles are squares. - val minSize = if (square) 64.dp else if (wide) 92.dp else 70.dp - val gap = 8.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 scroll follows the focused one. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val avail = maxWidth - 8.dp - val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1) - val tileWidth = (avail - gap * (cols - 1)) / cols - Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).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(1.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) - androidx.compose.animation.AnimatedVisibility( - visible = hot, modifier = Modifier.align(Alignment.Center), - enter = scaleIn(Motion.sp(0.5f), initialScale = 0.5f) + fadeIn(Motion.tw(200)), - exit = scaleOut(Motion.tw(150), targetScale = 0.6f) + fadeOut(Motion.tw(150)), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(if (square) 26.dp else 32.dp).graphicsLayer { shadowElevation = 10.dp.toPx(); shape = CircleShape; clip = false; spotShadowColor = pal.signal }.clip(CircleShape).background(pal.signal), - ) { Text("โ€บ", fontSize = if (square) 16.sp else 20.sp, color = Color.White) } - } - } - if (t.showFooter) { - Column(modifier = Modifier.padding(horizontal = 6.dp, vertical = 5.dp)) { - Text(t.title, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (t.sub != null) Text(t.sub, fontSize = 8.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 = 9.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(5.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) - } - } + (selected.startsWith("emu:") || selected.startsWith("rom:"))) + // At the top of a section, Back goes to the rail - the launcher itself is never backed out of. + BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) { + if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) + runCatching { frontFocus.railFor(railSelection).requestFocus() } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt new file mode 100644 index 000000000..a8a225dfe --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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(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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt new file mode 100644 index 000000000..7d034a138 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -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(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)) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt new file mode 100644 index 000000000..c871ac847 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -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(null) } + var games by remember { mutableStateOf(emptyList>()) } + 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?>(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()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt b/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt new file mode 100644 index 000000000..59d257273 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt @@ -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? = null + + /** The button a flood is growing out of: it steps aside so only the flood shows. */ + var flooding by mutableStateOf(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? = 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, start: Pair, 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?, 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) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt b/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt new file mode 100644 index 000000000..3ddef26c3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Layout.kt @@ -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 + diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 1231828f5..f9b7e1757 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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? = null, val addedGames: List = 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 { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index 7fac4329b..7756cd72f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -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( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt index 0f9a4f96e..cae45fa48 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PhantomProcessGatePage.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt index 46b946c49..1d94234d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt @@ -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)) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt new file mode 100644 index 000000000..1dc793922 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt @@ -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 = 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?>(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()) { + 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, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 51bc203b0..be15bdbb1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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() - private val last = arrayOfNulls(3) + private val last = arrayOfNulls(DRAWER_PAGES) var focused by mutableStateOf(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:" 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(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(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(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(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 DrawerStackedChoice( + host: MenuHost, key: String, label: String, hint: String?, + options: List>, 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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 28bd316d2..1b36b1c4b 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -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, selected: Int, onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, focusRequesters: List? = 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(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 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()) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt new file mode 100644 index 000000000..7be01984c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -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, + 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) { + 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 = 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()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt index 16eb84857..331ad9507 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt @@ -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 = 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) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt b/app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt new file mode 100644 index 000000000..c58be35e2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt @@ -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 { + 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 = 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 = 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 = 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 = 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 = 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() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 3153092e1..2d944ab41 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -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, diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt index aeccd398c..6df5ec7f2 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index ffd1a28e5..dd29b6ad1 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -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(); diff --git a/app/src/main/res/drawable-nodpi/logo.png b/app/src/main/res/drawable-nodpi/logo.png index a9c737a39..f9cb240cc 100644 Binary files a/app/src/main/res/drawable-nodpi/logo.png and b/app/src/main/res/drawable-nodpi/logo.png differ diff --git a/app/src/main/res/drawable/ic_stat_session.xml b/app/src/main/res/drawable/ic_stat_session.xml index 3b3415de2..0efa80f6c 100644 --- a/app/src/main/res/drawable/ic_stat_session.xml +++ b/app/src/main/res/drawable/ic_stat_session.xml @@ -1,11 +1,8 @@ - - + android:viewportWidth="239.92" android:viewportHeight="239.92"> + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png index 8538bed39..4d4352381 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 6998da424..fac0ce159 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png index a92ccfafb..adcd0243a 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index a9a5f33dc..2e204d650 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png index 3ba37c9e9..1ef58344b 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 98c616ee8..6579c62e6 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png index 07f6de5a9..abac9697e 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 35a8ddbbc..cb9f1c443 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png index 5ae3008fe..8ab6e6ea4 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 91ae0e397..617b5db63 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f83701d7a..10cf334af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,12 @@ By default, leaving the app pauses Steam; it picks up where it left off when you come back. Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls. + + First run sets up the desktop. This may take a few minutes. + By default, leaving the app pauses the session; it picks up where it left off when you come back. + Touch works as a touchpad: drag to move the pointer, tap to click. + Your controller appears as an Xbox 360 pad. Otherwise, use the on-screen controls. + Session Shows while a Steam or desktop session is open Steam is running @@ -13,4 +19,71 @@ Resume Stop session Frame generation + Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games. + Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts. + Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk. + Game environment + Shared defaults and per-game overrides for games using Proton. Applies on the next game launch. + Edit variables + All Proton games + Game profile + Steam app ID or non-Steam compatibility ID + Open profile + Game %1$s + Add variable + Variable name + Value + Save + Cancel + Close + Remove + Restore inherited settings + Reset this profile + Unset for this profile + No variables set. Driver and Proton defaults apply. + Inherited + Custom variable + D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game. + Could not load or apply the settings. Check available storage and try again. + Use a valid variable name and a value without NUL characters (up to 8192 characters). + The standard Linux Proton runtime does not use this variable; it may require a custom component. + Loadingโ€ฆ + Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value. + D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection. + D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection. + Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching. + Maximum Mesa shader-cache size, such as 1G. Larger caches use more storage. + VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it. + Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage. + Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9โ€“11 compatibility testing. + Set to 0 to disable Protonโ€™s gamepad-navigation helper for this game. + DXVK overlay for D3D9โ€“11. fps displays frame rate; unset hides it. + DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play. + D3D12 frame-rate limit. 0 disables the limit. + Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game. + FEX preset + Applies on the next game launch. + Games + FEX defaults + Uses FEX defaults. + Stability + Safest, slowest settings. + Compatibility + Full TSO with multiblock. + Intermediate + Balanced TSO with reduced x87 precision. + Performance + All TSO disabled; multiblock. + Performance + TSO + Performance settings with main TSO enabled. + Extreme + Disables self-modifying-code checks. JIT games and DRM may break. + Extreme + TSO + Extreme settings with main TSO enabled. + Extreme-gn + Performance with TSC and metadata hints. Try before Extreme. + Denuvo + Performance with full code checks and the hypervisor bit hidden. + Custom value + Select value diff --git a/app/src/test/java/com/droiddeck/launcher/core/ArchivePathsTest.kt b/app/src/test/java/com/droiddeck/launcher/core/ArchivePathsTest.kt new file mode 100644 index 000000000..0f8ac08b4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/ArchivePathsTest.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.core + +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class ArchivePathsTest { + private lateinit var root: File + private lateinit var dest: File + + @Before fun setUp() { + root = Files.createTempDirectory("archive-paths").toFile() + dest = File(root, "dest").apply { mkdirs() } + } + + @After fun tearDown() { + root.deleteRecursively() + } + + @Test fun plainEntryLandsInside() { + assertEquals(File(dest, "usr/bin/sh").canonicalPath, ArchivePaths.inside(dest, "usr/bin/sh")!!.canonicalPath) + } + + @Test fun dotEntryIsTheDestination() { + assertEquals(dest.canonicalPath, ArchivePaths.inside(dest, "./")!!.canonicalPath) + } + + @Test fun parentTraversalIsRefused() { + assertNull(ArchivePaths.inside(dest, "../escape")) + assertNull(ArchivePaths.inside(dest, "usr/../../escape")) + } + + @Test fun siblingWithSharedPrefixIsRefused() { + File(root, "dest-other").mkdirs() + assertNull(ArchivePaths.inside(dest, "../dest-other/x")) + } + + @Test fun symlinkOutOfTheTreeIsRefused() { + Files.createSymbolicLink(File(dest, "link").toPath(), root.toPath()) + assertNull(ArchivePaths.inside(dest, "link/escape")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt new file mode 100644 index 000000000..320f57333 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt @@ -0,0 +1,75 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.* +import org.junit.Test + +class GameEnvironmentTest { + @Test fun defaultsIncludeRequestedGraphicsLevelsAndKeepCaching() { + val values = GameEnvironment.defaults("") + assertEquals("false", values["MESA_SHADER_CACHE_DISABLE"]) + assertEquals("12_2", values["VKD3D_FEATURE_LEVEL"]) + assertEquals("6_9", values["VKD3D_SHADER_MODEL"]) + } + + @Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() { + val config = GameEnvironment.Config( + shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"), + games = mapOf("42" to mapOf("FEX_MULTIBLOCK" to "1", "VKD3D_FEATURE_LEVEL" to null)), + ) + val game = GameEnvironment.effective(config, "COMPATIBILITY", "42") + assertEquals("1", game["FEX_MULTIBLOCK"]) + assertTrue(game.containsKey("VKD3D_FEATURE_LEVEL")) + assertNull(game["VKD3D_FEATURE_LEVEL"]) + val other = GameEnvironment.effective(config, "COMPATIBILITY", "43") + assertEquals("0", other["FEX_MULTIBLOCK"]) + assertEquals("12_2", other["VKD3D_FEATURE_LEVEL"]) + } + + @Test fun graphicsDefaultsRespectExistingEditsAndRemovals() { + val config = GameEnvironment.Config(shared = mapOf("VKD3D_FEATURE_LEVEL" to "12_0")) + .withEntries("42", mapOf("VKD3D_SHADER_MODEL" to null)) + val game = GameEnvironment.effective(config, "", "42") + assertEquals("12_0", game["VKD3D_FEATURE_LEVEL"]) + assertTrue(game.containsKey("VKD3D_SHADER_MODEL")) + assertNull(game["VKD3D_SHADER_MODEL"]) + val restored = GameEnvironment.effective(config.withEntries("42", emptyMap()), "", "42") + assertEquals("6_9", restored["VKD3D_SHADER_MODEL"]) + } + + @Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() { + assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes")) + assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps")) + assertEquals("", GameEnvironmentOptions.toggle("fps", "fps")) + assertEquals("fps", GameEnvironmentOptions.toggle("", "fps")) + } + + @Test fun switchingPresetDropsOldPresetOnlyVariables() { + val config = GameEnvironment.Config() + assertEquals("none", GameEnvironment.effective(config, "EXTREME", "")["FEX_SMCCHECKS"]) + assertFalse(GameEnvironment.effective(config, "COMPATIBILITY", "").containsKey("FEX_SMCCHECKS")) + assertFalse(GameEnvironment.effective(config, "", "").containsKey("FEX_MULTIBLOCK")) + } + + @Test fun resettingOneProfilePreservesOtherProfiles() { + val config = GameEnvironment.Config(shared = mapOf("CUSTOM" to "shared")) + .withEntries("42", mapOf("CUSTOM" to "game")) + .withEntries("43", mapOf("CUSTOM" to "other")) + .withEntries("42", emptyMap()) + assertEquals("shared", GameEnvironment.effective(config, "", "42")["CUSTOM"]) + assertEquals("other", GameEnvironment.effective(config, "", "43")["CUSTOM"]) + assertFalse(config.games.containsKey("42")) + } + + @Test fun validationPreservesLiteralValuesAndRejectsInvalidKeys() { + assertTrue(GameEnvironment.validName("mesa_glthread")) + assertFalse(GameEnvironment.validName("X=Y")) + assertFalse(GameEnvironment.validName("1VAR")) + assertFalse(GameEnvironment.validName("VAR\n")) + assertTrue(GameEnvironment.validValue("a=b 'quote' $(touch file) ; $")) + assertTrue(GameEnvironment.validValue("")) + assertFalse(GameEnvironment.validValue("bad\u0000value")) + assertFalse(GameEnvironment.validValue("x".repeat(8193))) + assertTrue(GameEnvironment.validScope("4294967295")) + for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/HashesTest.kt b/app/src/test/java/com/droiddeck/launcher/core/HashesTest.kt new file mode 100644 index 000000000..dda2151fe --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/HashesTest.kt @@ -0,0 +1,41 @@ +package com.droiddeck.launcher.core + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HashesTest { + private fun file(text: String): File = + Files.createTempFile("hashes", ".bin").toFile().apply { writeText(text); deleteOnExit() } + + @Test fun sha256OfKnownInput() { + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Hashes.sha256(file("abc"))) + } + + @Test fun sha512OfKnownInput() { + assertEquals( + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" + + "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f", + Hashes.sha512(file("abc")), + ) + } + + @Test fun githubDigestIsRecognised() { + val hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + assertTrue(Hashes.isGithubSha256("sha256:$hex")) + assertEquals(hex, Hashes.githubSha256("sha256:$hex")) + assertEquals(hex.uppercase(), Hashes.githubSha256("SHA256:${hex.uppercase()}")) + } + + @Test fun anythingElseIsNotADigest() { + assertFalse(Hashes.isGithubSha256(null)) + assertFalse(Hashes.isGithubSha256("")) + assertFalse(Hashes.isGithubSha256("sha256:abc")) + assertFalse(Hashes.isGithubSha256("sha512:" + "0".repeat(64))) + assertNull(Hashes.githubSha256("md5:0123")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt new file mode 100644 index 000000000..88f2a8703 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -0,0 +1,58 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LogRedactorTest { + @Test fun labelledJwtIsRedacted() { + val out = LogRedactor.redact("Using JWT 25484942796017334") + assertFalse(out.contains("25484942796017334")) + assertTrue(out.contains("")) + } + + @Test fun secretPairKeepsItsKey() { + val out = LogRedactor.redact("GET /x?sessionid=abcdef123456 200") + assertFalse(out.contains("abcdef123456")) + assertTrue(out.contains("sessionid=")) + } + + @Test fun plainKeyValueSurvives() { + assertEquals("language key=english", LogRedactor.redact("language key=english")) + } + + @Test fun steamIdIsMaskedNotDeleted() { + assertEquals("user 76561********1234", LogRedactor.redact("user 76561198012341234")) + } + + @Test fun webApiKeyIsRedactedButDepotChunkIsKept() { + val key = "0123456789abcdef0123456789abcdef" + val chunk = "0123456789abcdef0123456789abcdef01234567" + assertFalse(LogRedactor.redact("key $key").contains(key)) + assertTrue(LogRedactor.redact("chunk $chunk").contains(chunk)) + } + + @Test fun emailIsRedacted() { + assertEquals("login ok", LogRedactor.redact("login someone@example.com ok")) + } + + @Test fun clockTimeIsNotAnAddress() { + val line = "[2026-09-27 10:03:05] Startup" + assertEquals(line, LogRedactor.redact(line)) + } + + @Test fun redactingTwiceChangesNothing() { + val lines = listOf( + "Using JWT 25484942796017334", + "token=abcdefghijklmnop sessionid=qrstuvwxyz123", + "user 76561198012341234 [U:1:52075506]", + "external address 2607:f8b0:4005:80a::200e", + "OnLoginStateChange someaccount 2 1 0 0", + ) + for (line in lines) { + val once = LogRedactor.redact(line) + assertEquals(once, LogRedactor.redact(once)) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/PhantomProcessLimitTest.kt b/app/src/test/java/com/droiddeck/launcher/core/PhantomProcessLimitTest.kt new file mode 100644 index 000000000..e930f85e5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/PhantomProcessLimitTest.kt @@ -0,0 +1,60 @@ +package com.droiddeck.launcher.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhantomProcessLimitTest { + @Test + fun developerOptionsToggleAloneCountsAsOff() { + assertEquals(PhantomProcessStatus.DISABLED, PhantomProcessLimit.status(null, "false")) + } + + @Test + fun globalSettingWinsOverTheProperty() { + assertEquals(PhantomProcessStatus.DISABLED, PhantomProcessLimit.status("false", "true")) + assertEquals(PhantomProcessStatus.ENABLED, PhantomProcessLimit.status("true", "false")) + } + + @Test + fun emptyGlobalFallsThroughToTheProperty() { + assertEquals(PhantomProcessStatus.ENABLED, PhantomProcessLimit.status("", "true")) + assertEquals(PhantomProcessStatus.DISABLED, PhantomProcessLimit.status(" ", "0")) + } + + @Test + fun neitherSetIsUnset() { + assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status(null, null)) + assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status("", "")) + } + + @Test + fun android12UsesDeviceConfig() { + assertEquals( + listOf( + "device_config set_sync_disabled_for_tests persistent", + "device_config put activity_manager max_phantom_processes 2147483647", + ), + PhantomProcessLimit.shellCommands(enabled = false, sdk = 31), + ) + assertTrue(PhantomProcessLimit.verified("2147483647\n", enabled = false, sdk = 31)) + assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 31)) + } + + @Test + fun android12LAndLaterUseTheFlag() { + assertEquals( + listOf("settings put global settings_enable_monitor_phantom_procs false"), + PhantomProcessLimit.shellCommands(enabled = false, sdk = 33), + ) + assertTrue(PhantomProcessLimit.verified("false", enabled = false, sdk = 34)) + assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 33)) + } + + @Test + fun developerToggleStartsAtAndroid14() { + assertFalse(PhantomProcessLimit.hasDeveloperToggle(33)) + assertTrue(PhantomProcessLimit.hasDeveloperToggle(34)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt new file mode 100644 index 000000000..1064ab2b8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt @@ -0,0 +1,191 @@ +package com.droiddeck.launcher.input + +import kotlin.math.sqrt +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, qualifiers = "mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class OnScreenControlsTest { + private lateinit var context: Context + private lateinit var view: OnScreenControls + private var time = 1000L + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit() + ControllerPrefs.setLayout(context, 1200, 800, mapOf("ls" to (0.45f to 0.5f), "rs" to (0.55f to 0.5f))) + view = OnScreenControls(context, null) + view.layout(0, 0, 1200, 800) + } + + @Test fun adaptiveStickHidesUntilTouchedAndReturnsToNeutralOnRelease() { + val left = control("ls") + assertEquals(0, pixel(value(left, "cx"), value(left, "cy"))) + assertTrue(touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f))) + assertEquals(7, field(left, "pressedBy")) + assertEquals(540f, value(left, "ax"), 0f) + assertEquals(400f, value(left, "ay"), 0f) + assertNotEquals(0, pixel(540f, 400f)) + touch(MotionEvent.ACTION_MOVE, 7 to (580f to 420f)) + assertTrue(value(left, "kx") > 0f) + assertTrue(value(left, "ky") > 0f) + touch(MotionEvent.ACTION_UP, 7 to (580f to 420f)) + assertEquals(-1, field(left, "pressedBy")) + assertEquals(0f, value(left, "kx"), 0f) + assertEquals(0f, value(left, "ky"), 0f) + assertEquals(0, pixel(540f, 400f)) + } + + @Test fun simultaneousSticksKeepTheirPointersAndReleaseIndependently() { + val left = control("ls") + val right = control("rs") + touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f)) + touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (540f to 400f), 9 to (660f to 400f)) + assertEquals(7, field(left, "pressedBy")) + assertEquals(9, field(right, "pressedBy")) + touch(MotionEvent.ACTION_POINTER_DOWN or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f)) + assertEquals(7, field(left, "pressedBy")) + touch(MotionEvent.ACTION_POINTER_UP or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f)) + touch(MotionEvent.ACTION_MOVE, 7 to (700f to 400f), 9 to (500f to 400f)) + assertTrue(value(left, "kx") > 0f) + assertTrue(value(right, "kx") < 0f) + touch(MotionEvent.ACTION_POINTER_UP, 7 to (700f to 400f), 9 to (500f to 400f)) + assertEquals(-1, field(left, "pressedBy")) + assertEquals(9, field(right, "pressedBy")) + touch(MotionEvent.ACTION_CANCEL, 9 to (500f to 400f)) + for (stick in listOf(left, right)) { + assertEquals(-1, field(stick, "pressedBy")) + assertEquals(0f, value(stick, "kx"), 0f) + } + } + + @Test fun cancelledTouchDoesNotBecomeAStickClick() { + touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f)) + touch(MotionEvent.ACTION_CANCEL, 1 to (540f to 400f)) + touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f)) + assertEquals(false, field(control("ls"), "clicked")) + touch(MotionEvent.ACTION_UP, 1 to (540f to 400f)) + touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f)) + assertEquals(true, field(control("ls"), "clicked")) + } + + @Test fun buttonsTakePriorityOverAdaptiveRegions() { + val button = control("a") + touch(MotionEvent.ACTION_DOWN, 3 to (value(button, "cx") to value(button, "cy"))) + assertEquals(3, field(button, "pressedBy")) + assertEquals(-1, field(control("rs"), "pressedBy")) + } + + @Test fun fixedSticksAndEditorRemainVisibleAtSavedPositions() { + ControllerPrefs.setAdaptiveSticks(context, false) + view.reload() + val left = control("ls") + val x = value(left, "cx") + val y = value(left, "cy") + assertNotEquals(0, pixel(x, y)) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f))) + touch(MotionEvent.ACTION_DOWN, 1 to (x + 10f to y)) + assertEquals(x, value(left, "ax"), 0f) + assertEquals(10f, value(left, "kx"), 0f) + ControllerPrefs.setAdaptiveSticks(context, true) + view = OnScreenControls(context, null, editing = true) + view.layout(0, 0, 1200, 800) + assertNotEquals(0, pixel(value(control("ls"), "cx"), value(control("ls"), "cy"))) + } + + @Test fun buttonsOnlyAndReloadReleaseAdaptiveSticks() { + touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f)) + touch(MotionEvent.ACTION_MOVE, 1 to (570f to 400f)) + view.reload() + assertEquals(-1, field(control("ls"), "pressedBy")) + assertEquals(0f, value(control("ls"), "kx"), 0f) + view.setButtonsOnly(true) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f))) + } + + @Test fun adaptiveActivationIsLimitedToExpandedSavedCircle() { + val left = control("ls") + val x = value(left, "cx") + val y = value(left, "cy") + val reach = value(left, "radius") * 1.4f * sqrt(1.5f) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach - 1f))) + assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach + 1f))) + assertEquals(y - reach + 1f, value(left, "ay"), 0.001f) + touch(MotionEvent.ACTION_UP, 1 to (x to y - reach + 1f)) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x - reach to y - reach))) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f))) + } + + @Test fun touchesOutsideActivationAreasStayWithTrackpad() { + val received = mutableListOf() + val trackpad = View(context).apply { + setOnTouchListener { _, event -> received.add(event.actionMasked); true } + } + val root = FrameLayout(context).apply { + addView(trackpad, FrameLayout.LayoutParams(1200, 800)) + addView(view, FrameLayout.LayoutParams(1200, 800)) + measure(View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY)) + layout(0, 0, 1200, 800) + } + for ((action, position) in listOf( + MotionEvent.ACTION_DOWN to (600f to 200f), + MotionEvent.ACTION_MOVE to (540f to 400f), + MotionEvent.ACTION_UP to (540f to 400f), + )) { + val event = MotionEvent.obtain(1000L, time++, action, position.first, position.second, 0) + try { assertTrue(root.dispatchTouchEvent(event)) } finally { event.recycle() } + } + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), received) + assertEquals(-1, field(control("ls"), "pressedBy")) + } + + @Test fun adaptiveActivationLeavesFivePixelsAroundButtonHitTargets() { + for (id in listOf("a", "rb")) { + val button = control(id) + val radius = value(button, "radius") + val edge = value(button, "cy") - radius * if (id == "rb") 1.05f else 1.25f + val x = value(button, "cx") + val left = control("ls") + left.javaClass.getDeclaredField("cx").apply { isAccessible = true }.setFloat(left, x) + left.javaClass.getDeclaredField("cy").apply { isAccessible = true }.setFloat(left, edge - 10f) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 4f))) + assertEquals(-1, field(left, "pressedBy")) + assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 6f))) + assertEquals(1, field(left, "pressedBy")) + touch(MotionEvent.ACTION_UP, 1 to (x to edge - 6f)) + } + } + + private fun control(id: String): Any = (field(view, "controls") as List<*>).first { field(it!!, "id") == id }!! + private fun field(owner: Any, name: String): Any = owner.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(owner)!! + private fun value(owner: Any, name: String) = field(owner, name) as Float + + private fun pixel(x: Float, y: Float): Int { + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + val color = bitmap.getPixel(x.toInt(), y.toInt()) + bitmap.recycle() + return color + } + + private fun touch(action: Int, vararg pointers: Pair>): Boolean { + val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray() + val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0) + return try { view.onTouchEvent(event) } finally { event.recycle() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt new file mode 100644 index 000000000..1fb648e73 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.core.GameEnvironment +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class GameEnvironmentStoreTest { + private lateinit var context: Context + private lateinit var guest: File + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json") + File(context.filesDir, "game-environment.json").delete() + } + + @Test fun savedSettingsAndPublishedSettingsPreserveUnsetsAndLiteralValues() { + val config = GameEnvironment.Config( + shared = mapOf("LITERAL" to "a=b 'quoted' $(echo test)", "EMPTY" to ""), + games = mapOf("42" to mapOf("LITERAL" to null, "VKD3D_SHADER_MODEL" to "6_9")), + ) + GameEnvironmentStore.save(context, config) + assertEquals(config, GameEnvironmentStore.read(context)) + val published = GameEnvironmentStore.decode(JSONObject(guest.readText())) + assertEquals("false", published.shared["MESA_SHADER_CACHE_DISABLE"]) + assertEquals("12_2", published.shared["VKD3D_FEATURE_LEVEL"]) + assertEquals("6_9", published.shared["VKD3D_SHADER_MODEL"]) + assertEquals(config.games, published.games) + assertEquals(config.shared["LITERAL"], published.shared["LITERAL"]) + assertEquals("", published.shared["EMPTY"]) + } + + @Test fun changingFexPresetPublishesForNextGameLaunch() { + GameEnvironmentStore.save(context, GameEnvironment.Config()) + SessionPrefs.setFexPreset(context, "EXTREME") + assertEquals("none", JSONObject(guest.readText()).getJSONObject("shared").getString("FEX_SMCCHECKS")) + SessionPrefs.setFexPreset(context, "") + assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS")) + } + + @Test fun invalidDataCannotReplaceSavedConfiguration() { + val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok")) + GameEnvironmentStore.save(context, original) + assertTrue(runCatching { GameEnvironmentStore.save(context, GameEnvironment.Config(shared = mapOf("BAD=NAME" to "x"))) }.isFailure) + assertEquals(original, GameEnvironmentStore.read(context)) + } + + @Test fun customComponentVariablesRemainEditable() { + val config = GameEnvironment.Config(shared = mapOf("DXVK_ASYNC" to "1")) + GameEnvironmentStore.save(context, config) + assertEquals(config, GameEnvironmentStore.read(context)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GuestHostnameTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GuestHostnameTest.kt new file mode 100644 index 000000000..23329a6af --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GuestHostnameTest.kt @@ -0,0 +1,30 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GuestHostnameTest { + @Test fun validLabelsAreKept() { + assertEquals("localhost", SessionPrefs.validGuestHostname("localhost")) + assertEquals("Fold8-Steam", SessionPrefs.validGuestHostname("Fold8-Steam")) + assertEquals("a", SessionPrefs.validGuestHostname("a")) + assertEquals("x".repeat(63), SessionPrefs.validGuestHostname("x".repeat(63))) + } + + @Test fun surroundingSpaceIsTrimmed() { + assertEquals("localhost", SessionPrefs.validGuestHostname(" localhost \n")) + } + + @Test fun anythingElseIsRejected() { + assertNull(SessionPrefs.validGuestHostname(null)) + assertNull(SessionPrefs.validGuestHostname("")) + assertNull(SessionPrefs.validGuestHostname(" ")) + assertNull(SessionPrefs.validGuestHostname("-leading")) + assertNull(SessionPrefs.validGuestHostname("trailing-")) + assertNull(SessionPrefs.validGuestHostname("two words")) + assertNull(SessionPrefs.validGuestHostname("dotted.name")) + assertNull(SessionPrefs.validGuestHostname("back\\slash")) + assertNull(SessionPrefs.validGuestHostname("x".repeat(64))) + } +} diff --git a/artwork/README.md b/artwork/README.md index 9379fc54f..6b2f7de2e 100644 --- a/artwork/README.md +++ b/artwork/README.md @@ -1,5 +1,5 @@ # DroidDeck mark -`droiddeck-mark.svg` is the editable source for the split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette. +`droiddeck-mark.svg` is the editable source for the white split-D mark. The blue orb is `#1A9FFF`; its halo and the two separator bars are transparent cutouts. The bars are centered on x=63.97, the endpoint of the curved half in the original Steam Deck symbol, and use the halo's 15.83-unit thickness. The mark is optically centered in its view box with a small rightward offset to balance its asymmetric silhouette. Place it on black when a background is needed. -Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are white. `drawable-nodpi/logo.png` composes the same mark on a white circular badge for use on the app's dark surfaces. +Android currently consumes raster resources for the launcher layers and the Compose logo. The `mipmap-*/ic_launcher_foreground.png` files are density-sized exports of the SVG, with the mark kept inside the adaptive icon safe area. The matching backgrounds are black. `drawable-nodpi/logo.png` composes the same mark on a black circular badge. diff --git a/artwork/boot-animation-source/README.md b/artwork/boot-animation-source/README.md index e1915c39a..4caa7aa72 100644 --- a/artwork/boot-animation-source/README.md +++ b/artwork/boot-animation-source/README.md @@ -1,13 +1,17 @@ # DroidDeck boot animation This source accompanies the single Steam startup movie bundled by the app: -`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1920ร—1080, pitch black background, VP9 video +`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1280ร—720 at 30 fps, pitch black background, VP8 video with Opus audio). `droiddeck-boot.html` is the editable animation; the scripts and WAV below -reproduce the selected render. No alternate MP4 or WebM exports are kept in the repository. +render the animation at the bundled playback profile. No alternate MP4 or WebM exports are kept in the repository. From the repository root, render the movie with: - python3 artwork/boot-animation-source/render.py 1920 1080 60 app/src/main/assets/steam-startup/droiddeck-startup.webm dark + python3 artwork/boot-animation-source/render.py 1280 720 30 app/src/main/assets/steam-startup/droiddeck-startup.webm dark + +The bundled profile reduces decoded pixels per second by 78% compared with 1080p/60. +VP8 also lowers software decode cost while Steam is starting. Keep the bundled movie at +720p/30; higher-resolution exports are intended for sharing. The app stages that file into Steam's `config/uioverrides/movies` folder before each session. If Steam still has its built-in startup movie selected, DroidDeck sets this movie as the device's @@ -27,13 +31,12 @@ it down or step frame by frame (arrow keys). ## Re-render the video - pip install playwright && playwright install chromium # ffmpeg with libvpx-vp9 also required - python3 render.py 1280 800 60 droiddeck-boot-1280x800.webm - python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.webm + pip install playwright && playwright install chromium # ffmpeg with libvpx also required + python3 render.py 1280 720 30 droiddeck-boot-1280x720.webm python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.mp4 # .mp4 = H.264 for sharing - python3 render.py 1280 800 60 droiddeck-boot-dark-1280x800.webm dark # dark version + python3 render.py 1280 720 30 droiddeck-boot-dark-1280x720.webm dark # dark version -Each frame gets real motion blur (180ยฐ shutter, adaptive sub-frames). Output is VP9 for .webm or H.264 for .mp4, both BT.709, +Each frame gets real motion blur (180ยฐ shutter, adaptive sub-frames). Output is VP8 for .webm or H.264 for .mp4, both BT.709, so the orb stays #1A9FFF after compression. ## Sound diff --git a/artwork/boot-animation-source/render.py b/artwork/boot-animation-source/render.py index 9c55b9b10..ee85ea06a 100644 --- a/artwork/boot-animation-source/render.py +++ b/artwork/boot-animation-source/render.py @@ -2,11 +2,11 @@ """Render the DroidDeck boot animation to .webm (Steam) or .mp4 (H.264). Usage: python3 render.py [WIDTH] [HEIGHT] [FPS] [OUT] [light|dark] - python3 render.py 1280 800 60 droiddeck-boot.webm + python3 render.py 1280 720 30 droiddeck-boot.webm python3 render.py 1920 1080 60 droiddeck-boot.mp4 - python3 render.py 1280 800 60 droiddeck-boot-dark.webm dark + python3 render.py 1280 720 30 droiddeck-boot-dark.webm dark -Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx-vp9. +Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx. The animation is drawn by droiddeck-boot.html (same folder); each frame gets real motion blur (180-degree shutter, up to 48 sub-frames when things move fast). """ @@ -15,8 +15,8 @@ from pathlib import Path from playwright.sync_api import sync_playwright W = int(sys.argv[1]) if len(sys.argv) > 1 else 1280 -H = int(sys.argv[2]) if len(sys.argv) > 2 else 800 -FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 60 +H = int(sys.argv[2]) if len(sys.argv) > 2 else 720 +FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 30 OUT = sys.argv[4] if len(sys.argv) > 4 else f"droiddeck-boot-{W}x{H}.webm" THEME = sys.argv[5] if len(sys.argv) > 5 else "light" # light | dark ONLY = [int(x) for x in os.environ.get("FRAMES", "").split(",") if x] # debug: render some frames only @@ -54,9 +54,9 @@ cmd += ["-vf", "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+ful if OUT.lower().endswith(".mp4"): # H.264 for sharing, previews and anything that won't play webm cmd += ["-c:v", "libx264", "-preset", "slow", "-crf", "15", "-profile:v", "high", "-tune", "animation", "-g", str(FPS * 2), "-movflags", "+faststart"] -else: # VP9 webm for Steam's startup movie - cmd += ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "18", "-deadline", "good", "-cpu-used", "2", - "-row-mt", "1", "-g", str(FPS * 2)] +else: # VP8 keeps software decoding inexpensive during Steam startup + cmd += ["-c:v", "libvpx", "-b:v", "2M", "-crf", "10", "-deadline", "good", "-cpu-used", "2", + "-g", str(FPS * 2)] cmd += COLOR + ([] if SOUND.exists() else ["-an"]) + [OUT] subprocess.run(cmd, check=True) if not os.environ.get("FRAMEDIR"): diff --git a/artwork/droiddeck-banner-dark.svg b/artwork/droiddeck-banner-dark.svg new file mode 100644 index 000000000..020d3a7ba --- /dev/null +++ b/artwork/droiddeck-banner-dark.svg @@ -0,0 +1,16 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpjNDEzNmY0YS0xYWE5LTQ4MzEtYTgwNS1iOGJkYTkzOWM3OWMAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaOdgBuqxauxA5Ft/U5O/L/0AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo2YTI3MjgxMC1iYjQ3LTQyZmItOWE4ZC1kOGJhM2QxOTg2MzJscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo7FQMclkxxkOM9r48dH+p5QAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggoYQr3GKI/CBS/tjJfXabQnWGTIXEWN2Q4HY/bRZcAd2kZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaCmdTerrFFCgrujAStdxo3AAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDdCEqOYsAzpUmsE8GjJyHfVbPv34OtUVI9Zxt08oNaTWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBi3Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmM0MTM2ZjRhLTFhYTktNDgzMS1hODA1LWI4YmRhOTM5Yzc5Yy9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjVkZjQ2ZTE0LTQzNjQtNGZmZC1hMWVhLTBlNmQ3M2NjMmM0ZXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCChhCvcYoj8IFL+2Ml9dptCdYZMhcRY3ZDgdj9tFlwB3aJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggEranykZZVM3ediUytxX7Gk1wpijJyo8W1hbSxY8qGHSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggd9tQukbXKUDL9GyrZINf6cuup075WZSflrCFunu5uDB0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQK+n1Ttyjch+bVyf7QrlrvTFOi8LNiH5UenvXuwI77BxG0qXIH9iDN/X2/itrBOfVmRj0aMgbNuVzL9YoJ2f9Jg= +DroidDeck + + + + + + + + + + + + + + \ No newline at end of file diff --git a/artwork/droiddeck-banner-light.svg b/artwork/droiddeck-banner-light.svg new file mode 100644 index 000000000..e74565f94 --- /dev/null +++ b/artwork/droiddeck-banner-light.svg @@ -0,0 +1,16 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTozODE2MThiZi02NDJmLTQ2MjYtODBmOS0zODNkMTg1MTg1YzYAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaNJTd2RbvEwJ3CejXuiiUa8AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpiZWIyNDdlNC0zMGYzLTQwNTMtOGRiMy02YjM5ZDFiMDE5MjVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoH5LUfnG8pzZTvyp93dxAjAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggp0bIyQE4HprwqtllS1/PuFew+dPPCPuezvtPD9UACASkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaCjAw2/dKHURaCMsIpPL4O0AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCEQ7KUH92rkmFULm/paDTTakKh2Kq0tGvmqok6E4sdumRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBi3Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjM4MTYxOGJmLTY0MmYtNDYyNi04MGY5LTM4M2QxODUxODVjNi9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjM4NDAyMDFkLWVjMTEtNGExYS1iYWU3LTFjYzgzNzUwMzY5YnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCnRsjJATgemvCq2WVLX8+4V7D5088I+57O+08P1QAIBKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggg3t3BJoakmzmieZvRQ5KFPJxvI/zSgQVZG0rhdu4JumiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggZjySb2zXGJyOOu+/uDpQ8CRteSnMgEk64l3gBu0UM5t0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQMunPCLgDgFIRYv+fDV/h3Y+IOj+XT4PhNK1qgdtdeSxKdaicBIktBVMPMOuIILAQQPu8Nwf2Rx6TxsFexEvmlo= +DroidDeck + + + + + + + + + + + + + + \ No newline at end of file diff --git a/artwork/droiddeck-mark.svg b/artwork/droiddeck-mark.svg index 6b071bde2..d748fc326 100644 --- a/artwork/droiddeck-mark.svg +++ b/artwork/droiddeck-mark.svg @@ -8,6 +8,6 @@ - + diff --git a/artwork/droiddeck-social-preview.png b/artwork/droiddeck-social-preview.png new file mode 100644 index 000000000..a7b0962ee Binary files /dev/null and b/artwork/droiddeck-social-preview.png differ diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md new file mode 100644 index 000000000..5b9244a66 --- /dev/null +++ b/docs/development/game-environment.md @@ -0,0 +1,81 @@ +# Steam game environment + +Open **Games โ†’ Game environment**, directly below **FEX preset**, in Steam +settings or the in-session drawer. Choose all Proton games, an installed game, or enter a Steam app ID. For a +non-Steam shortcut, use its numeric `compatdata` directory ID (unsigned 32-bit). +Changes apply on the next game launch, including when Steam is already running. +A running game must be restarted. Native Linux games and Steam itself are outside +this editor's scope. + +The order is inherited process environment, selected FEX preset and built-in +settings, shared edits, then game-specific edits. Editor entries therefore win +over matching Steam launch-option variables. **Remove** explicitly unsets a +variable. **Restore inherited settings** removes the override. **Reset this +profile** clears only that profile's overrides. Values are literal strings, with +no shell expansion; quotes are only needed when the consuming program expects them. + +## Defaults and available suggestions + +The initial configuration enables Mesa shader caching with +`MESA_SHADER_CACHE_DISABLE=false`, sets `VKD3D_FEATURE_LEVEL=12_2` and +`VKD3D_SHADER_MODEL=6_9`, and preserves the selected FEX preset. Existing edits +and explicit removals take precedence over these defaults. FEX's own default +remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray +tracing and diagnostic logging otherwise retain the runtime's defaults. + +The variable-name picker offers 24 predefined variables and a Custom entry. Like +WinNative, known variables use toggles, value dropdowns, multi-select lists, or +numeric/text fields. Feature-level and shader-model dropdowns also accept custom +values. Custom variables have editable names and literal values. + +| Variable | Use | +| --- | --- | +| `VKD3D_FEATURE_LEVEL` | D3D12 capability override; defaults to `12_2`. | +| `VKD3D_SHADER_MODEL` | Shader-model override; defaults to `6_9`. | +| `VKD3D_CONFIG` | Per-game workarounds such as `nodxr`; not a universal performance preset. | +| `MESA_SHADER_CACHE_MAX_SIZE` | Storage budget for Mesa's shader cache, for example `1G`. | +| `mesa_glthread` | OpenGL threading; test with the affected game. | +| `DXVK_CONFIG` | DXVK configuration options; recent builds accept `dxvk.maxFrameRate = 60`. | +| `VKD3D_FRAME_RATE` | D3D12 frame-rate limit. | +| `DXVK_HUD`, `PROTON_LOG` | Optional diagnostics; leave unset for ordinary play. | +| `PROTON_USE_WINED3D` | D3D9โ€“11 OpenGL fallback for compatibility testing. | +| `PROTON_USE_XALIA` | Proton's gamepad-navigation helper toggle. | + +Forcing `12_2` or `6_9` changes reported capabilities; it cannot implement a missing +Vulkan feature. Remove either entry to use automatic detection for that capability. +Other picker suggestions are editable starting values and are enabled only when +added and saved. Older or +custom components can support a different set of options. Android Wine wrapper, +ALSA-server, Box64 and patched async-DXVK options are identified in the editor as +requiring a different/custom component; custom entries remain allowed. The old +`DXVK_FRAME_RATE` environment variable was removed from current DXVK, so it is +not offered as a suggestion. Prefer the session frame limiter for ordinary play. + +## Launch path and references + +WinNative's Linux session filters Android-only options, merges user variables +over the FEX preset, and passes the result to `env -i` before starting Steam. Its +Windows/Wine graphics helper also maps the selected feature level to +`VKD3D_FEATURE_LEVEL`. Reviewed source: [WinNative Linux session](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/runtime/display/XServerDisplayActivity.java), +[variable editor](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/shared/ui/widget/EnvVarsView.java), +[graphics configuration](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/feature/settings/drivers/DXVKConfigUtils.java). + +DroidDeck instead publishes an atomic JSON snapshot at +`/root/.config/droiddeck/game-environment.json`. Both its Valve ARM64 Proton +wrapper and adopted third-party Proton wrappers execute `bannerlator-game-env`, +which reads that snapshot for each real game launch and uses `execvpe` to start +Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed +configuration falls back to the inherited environment without evaluating its +contents. Signed non-Steam prefix IDs are normalized to unsigned IDs. + +Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-Work/vkd3d-proton/blob/master/libs/vkd3d/device.c), +[VKD3D options](https://github.com/HansKristian-Work/vkd3d-proton#environment-variables), +[Proton runtime options](https://github.com/ValveSoftware/Proton/tree/proton_11.0#runtime-config-options), +[Mesa variables](https://docs.mesa3d.org/envvars.html), +[DXVK variables](https://github.com/doitsujin/dxvk#environment-variables), +[DXVK frame-limiter changes](https://github.com/doitsujin/dxvk/releases). + +Validation: `./gradlew testDebugUnitTest` and +`python3 -m unittest discover -s tools/tests -p test_game_environment.py`. +Actual game compatibility depends on the installed Proton, VKD3D and Vulkan driver; +these checks do not establish that every device supports feature level 12_2 or SM 6_9. diff --git a/docs/development/steam-game-imports.md b/docs/development/steam-game-imports.md new file mode 100644 index 000000000..4639afa5b --- /dev/null +++ b/docs/development/steam-game-imports.md @@ -0,0 +1,62 @@ +# Steam game imports + +Second Library still registers the selected Steam library. It now also scans Windows +executables in its immediate game subfolders and `steamapps/common`, using the same +importer as Added Games. Existing Steam manifests are preserved. + +`bannerlator-steam-games watch` runs beside the client. It identifies a game by an +existing manifest's install directory, a `steam_appid.txt` beside the game or selected +executable, or a unique normalized title from Steam's store search with an explicit +country. Matching also accepts the terminal Windows Edition and INTERGRADE suffixes; +multiple matches still remain unresolved. Artwork's fuzzy matching is deliberately not +used for identity. Ambiguous or unresolved titles remain non-Steam games. + +Ownership comes from the running native ARM64 client's `BIsSubscribedApp`, through +versioned SteamClient020 / SteamUser021 / STEAMAPPS_INTERFACE_VERSION008 interfaces. The +helper gets the account from that connection, not from a public profile. No API key, +password, profile visibility change, or CEF debugging port is needed. Each probe runs in +a bounded subprocess so library failures cannot crash the importer or client. + +The resulting snapshot is stored under that account's `userdata//config` and +refreshed every 30 seconds while logged in. Title lookups retry every five minutes and +when the source changes, so an early network failure does not end the watcher. Changed +status and probe failures are written to the session log. Startup and post-exit import +use the last successful snapshot for the selected account. A newly identified title +therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts +again. An unavailable client or offline first launch falls back to shortcuts; existing +snapshots remain usable offline. Steam still enforces the current license at launch. + +For a confirmed game without a manifest, the importer links the source folder into the +internal Steam library and writes an installed manifest with `Universe=1` and +`StateFlags=4`. Steam ignores manifests without the universe field. This registers +existing files; it does not assert a verified build ID or depot manifest. Steam can +still require verification, updates or redistributables. Steam updates and uninstall +operate on the linked game files, so imported folders must be writable. Existing Steam +manifests and directories are preserved. Only the exact minimal manifests generated by +the earlier importer are migrated, with the matching source symlink still present. If +registration fails, the game remains a shortcut. Once Steam takes over an install, +manage it in Steam; forgetting an Added Games folder only removes its shortcuts. + +The account's `.droiddeck-routes.json` maps shortcut IDs to real Steam app IDs so the +Android launcher uses the same title and Proton prefix as Steam. Other accounts do not +inherit those routes. Managed shortcuts are replaced; user-created shortcuts and +unreadable shortcut files are preserved. Both routes use the existing ARM64 Proton +registration. + +## Validation + +Run `python3 -m unittest discover -s tools/tests -p 'test_*.py'` and Android unit tests. +Tests cover identity and edition matching, network recovery, account isolation, +owned/unowned/unknown routing, legacy manifest migration, manifest preservation, +filesystem collisions, shortcut preservation, and the versioned IPC call/cleanup +contract. On 2026-09-28, the ARM64 Steam client (1790545198) confirmed ownership and +installed status through ISteamApps for Final Fantasy VII Rebirth (2909400), Final +Fantasy VII Remake Intergrade (1462040), and Final Fantasy XV (637650), with all three +install paths resolving to the original second-library folders. The old managed +shortcuts were removed. This verifies library registration, not complete game launches. +Unowned fallback and account isolation are covered by automated tests. + +References: [Steam Apps +API](https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp), [Valve's +versioned interfaces](https://github.com/ValveSoftware/Proton/tree/proton_11.0/lsteamcli +ent/steamworks_sdk_154). diff --git a/docs/releases/0.2.0.md b/docs/releases/0.2.0.md new file mode 100644 index 000000000..4ac55777d --- /dev/null +++ b/docs/releases/0.2.0.md @@ -0,0 +1,172 @@ +# DroidDeck 0.2.0 (beta) + +**Valve's native ARM64 Steam client on your Android handheld.** Windows games run through Valve's ARM64 Proton and FEX, and an optional Linux desktop brings emulators along. + +![Tapping DroidDeck on the Android home screen and landing in Steam Big Picture](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/launch-into-steam.gif) + +**Needs:** Android 9+, Adreno 730 or newer (or 8xx), ~3 GB free for the runtime (+1.1 GB for Desktop & apps). + +This is the first release from the [Droid-Deck](https://github.com/Droid-Deck) org, so it covers everything DroidDeck does, not just what changed since 0.1.7. `legacy#N` links go to pull requests in [The412Banner/DroidDeck](https://github.com/The412Banner/DroidDeck), from before the move. + +--- + +## Highlights + +### Steam, running natively +The real Steam client in Big Picture, fetched from Valve on your device. Turn on **Run Steam when DroidDeck starts** and the app icon takes you straight in, as above. + +### A launcher made for handhelds +Your Steam library as a capsule wall, and a Games tab with per-game settings. + +

+Steam tab with the capsule wall +Games tab with a game's launch settings +

+ +### Your Android home screen +Set DroidDeck as the Home app and your Android apps live in the launcher too, and in the session menu, so you can open one without leaving your game. + +![Android apps listed in the session menu over Steam](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/drawer-android-apps.jpg) + +### Made for two screens +On a dual-screen handheld, launch an Android app on the second screen while Steam keeps the first, or turn the second screen into a keyboard and trackpad, or a Linux terminal. + +

+Launching Geometry Wars 3 on the second screen from the session menu +Second screen as a trackpad and keyboard +Second screen as a Linux terminal under Big Picture +

+ +### A session menu over any game +Press Back mid-game for display, controls, components and session settings, paged with LB/RB. + +![Paging through the session menu's tabs](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/session-drawer.gif) + +### Swap FEX, DXVK and VKD3D-Proton per Proton build +Download from the nightlies or import a `.wcp`, and see what the running game is using. + +![Components manager](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/components.jpg) + +### A Linux desktop, with emulators one tap away +LXQt on the GPU, plus RPCS3, ARMSX2, Dolphin, DuckStation, melonDS, Cemu, PPSSPP and RetroArch. + +

+LXQt desktop with a file manager and terminal +Desktop tab with emulators to install +

+ +### On-screen controls you can lay out yourself +Drag every button where your thumbs want it, with adaptive sticks and phone-sized layouts. + +![On-screen controller layout editor](https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/docs/releases/media/0.2.0/controller-editor.jpg) + +--- + +## Everything in 0.2.0 + +### Steam + +- Native ARM64 Steam client under gamescope, on DroidDeck's own Vulkan compositor (@The412Banner) +- Valve's ARM64 Proton and FEX, ready at first sign-in (@The412Banner, [legacy#22](https://github.com/The412Banner/DroidDeck/pull/22), #33) +- Start offline after one sign-in (@The412Banner) +- Steam Deck mode: battery and NIS in Quick Access, MangoHud overlay (@The412Banner, [legacy#9](https://github.com/The412Banner/DroidDeck/pull/9), [legacy#16](https://github.com/The412Banner/DroidDeck/pull/16)) +- Games from your own folders appear in Steam, with store art (@The412Banner, [legacy#9](https://github.com/The412Banner/DroidDeck/pull/9)) +- Components manager for FEX, DXVK and VKD3D-Proton, applied on every launch (@The412Banner, #29) +- GE-Proton and proton-cachyos as extra compatibility tools (@The412Banner, @xXJSONDeruloXx, [legacy#18](https://github.com/The412Banner/DroidDeck/pull/18)) +- FEX presets per session, Stability to Extreme + TSO (@The412Banner) +- Decky Loader, with plugins in Big Picture (@xXJSONDeruloXx, #16) +- Steam's Internet settings and connection indicator work (@maxjivi05) +- Steam sees the device as "DroidDeck" (@xXJSONDeruloXx, #15) +- Steam launchers on the Linux desktop (@The412Banner, #10) +- DroidDeck startup movie, and optionally straight into Steam when the app opens (@xXJSONDeruloXx, #25, #27, #36, #38) + +### Launcher + +- Side rail, Games tab, capsule wall, graphite theme, controller hints, phone layouts (@xXJSONDeruloXx, #13, #35, #41) +- Launch buttons flood into the loading screen (@xXJSONDeruloXx, #46) +- Game files and Proton prefix shortcuts on each game's page (@xXJSONDeruloXx, #39) +- Controller navigation everywhere, remembered across the rail (@The412Banner, [legacy#29](https://github.com/The412Banner/DroidDeck/pull/29), [legacy#34](https://github.com/The412Banner/DroidDeck/pull/34), [legacy#38](https://github.com/The412Banner/DroidDeck/pull/38)) +- Built-in file manager, a ROMs folder anywhere (SD cards too), app files in Android's picker (@The412Banner, [legacy#41](https://github.com/The412Banner/DroidDeck/pull/41)) +- Themes and the split-D logo (@The412Banner, @xXJSONDeruloXx, [legacy#4](https://github.com/The412Banner/DroidDeck/pull/4), [legacy#37](https://github.com/The412Banner/DroidDeck/pull/37), #34) +- Use DroidDeck as your Android home app, with Android apps in the front end (@xXJSONDeruloXx, @The412Banner, [legacy#17](https://github.com/The412Banner/DroidDeck/pull/17), [legacy#44](https://github.com/The412Banner/DroidDeck/pull/44)) +- Build identity and latest-build link in the top bar (@xXJSONDeruloXx, [legacy#24](https://github.com/The412Banner/DroidDeck/pull/24), #26) + +### Sessions + +- Sessions keep running when you leave the app (@The412Banner) +- Pause policy (Auto, Manual, Never), changeable mid-session, audio recovers after a pause (@xXJSONDeruloXx, [legacy#7](https://github.com/The412Banner/DroidDeck/pull/7), #14) +- Paged session menu with Display, Controls and Settings tabs (@xXJSONDeruloXx, @The412Banner, [legacy#40](https://github.com/The412Banner/DroidDeck/pull/40), #6, #24) +- Back opens the menu; a double press opens Steam's Quick Access (@xXJSONDeruloXx, [legacy#3](https://github.com/The412Banner/DroidDeck/pull/3), [legacy#20](https://github.com/The412Banner/DroidDeck/pull/20)) +- First run sets itself up from the loading screen (@The412Banner, [legacy#12](https://github.com/The412Banner/DroidDeck/pull/12), [legacy#15](https://github.com/The412Banner/DroidDeck/pull/15)) +- Launch on any connected display; a second screen can be a keyboard, trackpad or terminal (@xXJSONDeruloXx, [legacy#13](https://github.com/The412Banner/DroidDeck/pull/13), [legacy#33](https://github.com/The412Banner/DroidDeck/pull/33)) +- Share session logs from the app or the ended-session screen (@xXJSONDeruloXx, @The412Banner, [legacy#11](https://github.com/The412Banner/DroidDeck/pull/11), #17, #41) + +### Controls + +- Any controller shows up as an Xbox 360 pad; an on-screen pad fills in when none is connected (@The412Banner) +- On-screen controller editor with adaptive sticks (@maxjivi05, #9) +- On-screen sticks that use the letterbox bars, plus Steam and QAM touch buttons (@The412Banner, @xXJSONDeruloXx, [legacy#6](https://github.com/The412Banner/DroidDeck/pull/6)) +- Real touch in Big Picture; a desktop touchpad that hovers, clicks, scrolls and two-finger drags (@The412Banner, #10, #22) +- Cursor shapes, hidden while you play on a pad (@The412Banner, #18) +- Soft keyboard and a full PC keyboard (@The412Banner, [legacy#25](https://github.com/The412Banner/DroidDeck/pull/25)) + +### Display and performance + +- Frame generation: built-in Win-FG, or LSFG from your own Lossless Scaling, 2ร—โ€“4ร— (@The412Banner) +- Import or download Turnip drivers per mode, from Banners-Turnip and WinNative (@The412Banner) +- Resolution and shape per mode, including "Exactly this panel" for 4:3 and 3:2 screens (@The412Banner, [legacy#14](https://github.com/The412Banner/DroidDeck/pull/14)) +- Stretch games to fill; Quake-engine games windowed to fit (@The412Banner, [legacy#32](https://github.com/The412Banner/DroidDeck/pull/32)) +- HDR10 on supported panels (experimental) (@The412Banner) +- Runs as a game on Android: sustained performance, fastest panel mode, ADPF hints (@The412Banner) +- Steam's interface stays on the GPU (@maxjivi05) +- Pin Steam and games to chosen CPU cores (@The412Banner) +- Faster proot with WinNative's seccomp and tracing fixes (@maxjivi05, #4, #37) + +### Desktop and emulators + +- LXQt on labwc, on the GPU with Vulkan by default (@The412Banner, [legacy#25](https://github.com/The412Banner/DroidDeck/pull/25)) +- Emulators downloaded on request: RetroArch, PPSSPP, RPCS3, DuckStation, ARMSX2, Dolphin, melonDS, Cemu and more (@The412Banner) +- Emulator games launch full screen from the front end, one tile per disc, with box art (@The412Banner, [legacy#29](https://github.com/The412Banner/DroidDeck/pull/29), [legacy#34](https://github.com/The412Banner/DroidDeck/pull/34), [legacy#38](https://github.com/The412Banner/DroidDeck/pull/38)) +- ARMSX2 replaces PCSX2 for PS2: 60 fps where PCSX2 managed 17 (@The412Banner, [legacy#26](https://github.com/The412Banner/DroidDeck/pull/26)) +- Dolphin, melonDS and Cemu set up out of the box (@The412Banner, #10, #18, #42) +- Emulator controllers preconfigured, and RPCS3 shader fixes (@The412Banner, [legacy#25](https://github.com/The412Banner/DroidDeck/pull/25)) + +### Audio + +- DirectAudio for games, straight to Android (@The412Banner, [legacy#9](https://github.com/The412Banner/DroidDeck/pull/9), [legacy#11](https://github.com/The412Banner/DroidDeck/pull/11)) +- Microphone for voice chat, on by default (@The412Banner, [legacy#11](https://github.com/The412Banner/DroidDeck/pull/11)) + +### Fixed + +- Steam updates failing on read-only files (@The412Banner, #33) +- A dropped finger-up blocking later touches (@The412Banner, #22) +- Stopped sessions leaving stray processes and log spam (@The412Banner, #43) +- Cemu and shadPS4 crashing on the input shim; no cursor on the GPU desktop (@The412Banner, #42) +- Leftovers from crashed or killed sessions cleaned up at the next start (@The412Banner, [legacy#16](https://github.com/The412Banner/DroidDeck/pull/16)) +- The menu's dim layer staying over Steam after switching apps (@xXJSONDeruloXx, #45) +- Steam library capsule art (@xXJSONDeruloXx, #12) +- The menu's Desktop button wrapping (@xXJSONDeruloXx, #28) +- Launcher restarting when a controller connects (@xXJSONDeruloXx, #40) +- Runtime installs racing, or stopping when you leave the app (@xXJSONDeruloXx, #40) +- Steam blocked by Android's child-process limit; Setup lifts it over wireless ADB (@xXJSONDeruloXx, #21) + +### Security + +- SHA-256 checked on every catalog, release and Proton download (@xXJSONDeruloXx, #40) +- Runtime archives can't link outside the rootfs (@xXJSONDeruloXx, #40) +- Logs scrub account and persona names, public IPs and device IDs (@The412Banner, #19) +- Signed with DroidDeck's own release key (@The412Banner, [legacy#41](https://github.com/The412Banner/DroidDeck/pull/41)) + +### Behind the scenes + +- Every PR's APK published to DroidDeck-CI (@maxjivi05, #3), with signed builds gated on review (@xXJSONDeruloXx, #5) +- Local builds matching CI, and a debug-only ADB agent bridge (@xXJSONDeruloXx, [legacy#5](https://github.com/The412Banner/DroidDeck/pull/5), [legacy#35](https://github.com/The412Banner/DroidDeck/pull/35), #7, #20) +- CI hardening, branch protection and copy clean-ups (@The412Banner, @xXJSONDeruloXx, [legacy#8](https://github.com/The412Banner/DroidDeck/pull/8), [legacy#10](https://github.com/The412Banner/DroidDeck/pull/10), [legacy#41](https://github.com/The412Banner/DroidDeck/pull/41), #8) + +--- + +**Built on** WinNative by @maxjivi05 (the gamescope runtime, proot session and input interposer), Winlator by brunodev85 (audio and gamepad model), Bannerlator by @The412Banner (file manager, first sign-in flow), lsfg-vk by PancakeTAS, MangoHud by flightlessmango, and Vauzi's Adreno 710/720/722 device entries. + +Not affiliated with Valve. Steam, Proton and Steam Deck are Valve's; the client and Proton are fetched from Valve's servers on your device. + +**Contributors:** @The412Banner, @xXJSONDeruloXx, @maxjivi05 diff --git a/docs/releases/media/0.2.0/components.jpg b/docs/releases/media/0.2.0/components.jpg new file mode 100644 index 000000000..c044fd6be Binary files /dev/null and b/docs/releases/media/0.2.0/components.jpg differ diff --git a/docs/releases/media/0.2.0/controller-editor.jpg b/docs/releases/media/0.2.0/controller-editor.jpg new file mode 100644 index 000000000..14dd9a226 Binary files /dev/null and b/docs/releases/media/0.2.0/controller-editor.jpg differ diff --git a/docs/releases/media/0.2.0/desktop-apps.jpg b/docs/releases/media/0.2.0/desktop-apps.jpg new file mode 100644 index 000000000..1dc0d018c Binary files /dev/null and b/docs/releases/media/0.2.0/desktop-apps.jpg differ diff --git a/docs/releases/media/0.2.0/drawer-android-apps.jpg b/docs/releases/media/0.2.0/drawer-android-apps.jpg new file mode 100644 index 000000000..932f57b28 Binary files /dev/null and b/docs/releases/media/0.2.0/drawer-android-apps.jpg differ diff --git a/docs/releases/media/0.2.0/dual-screen-app.gif b/docs/releases/media/0.2.0/dual-screen-app.gif new file mode 100644 index 000000000..964bc59e9 Binary files /dev/null and b/docs/releases/media/0.2.0/dual-screen-app.gif differ diff --git a/docs/releases/media/0.2.0/launch-into-steam.gif b/docs/releases/media/0.2.0/launch-into-steam.gif new file mode 100644 index 000000000..ecddf2d28 Binary files /dev/null and b/docs/releases/media/0.2.0/launch-into-steam.gif differ diff --git a/docs/releases/media/0.2.0/launcher-games.jpg b/docs/releases/media/0.2.0/launcher-games.jpg new file mode 100644 index 000000000..27fad9cdf Binary files /dev/null and b/docs/releases/media/0.2.0/launcher-games.jpg differ diff --git a/docs/releases/media/0.2.0/launcher-steam.jpg b/docs/releases/media/0.2.0/launcher-steam.jpg new file mode 100644 index 000000000..8cb34ae65 Binary files /dev/null and b/docs/releases/media/0.2.0/launcher-steam.jpg differ diff --git a/docs/releases/media/0.2.0/linux-desktop.jpg b/docs/releases/media/0.2.0/linux-desktop.jpg new file mode 100644 index 000000000..db70bc23b Binary files /dev/null and b/docs/releases/media/0.2.0/linux-desktop.jpg differ diff --git a/docs/releases/media/0.2.0/second-screen-terminal.jpg b/docs/releases/media/0.2.0/second-screen-terminal.jpg new file mode 100644 index 000000000..36f25aae9 Binary files /dev/null and b/docs/releases/media/0.2.0/second-screen-terminal.jpg differ diff --git a/docs/releases/media/0.2.0/second-screen-trackpad.gif b/docs/releases/media/0.2.0/second-screen-trackpad.gif new file mode 100644 index 000000000..8345d811b Binary files /dev/null and b/docs/releases/media/0.2.0/second-screen-trackpad.gif differ diff --git a/docs/releases/media/0.2.0/session-drawer.gif b/docs/releases/media/0.2.0/session-drawer.gif new file mode 100644 index 000000000..d8f064170 Binary files /dev/null and b/docs/releases/media/0.2.0/session-drawer.gif differ diff --git a/tools/build_local.sh b/tools/build_local.sh index 445d8b850..d777f561b 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -41,7 +41,8 @@ fi ndk_version=${DROIDDECK_NDK_VERSION:-} if [[ -z "${ndk_version}" ]]; then - ndk_path=$(find "${sdk_dir}/ndk" -mindepth 1 -maxdepth 1 -type d -print | sort -V | tail -1) + # Only complete NDKs: an interrupted sdkmanager install leaves a directory without source.properties. + ndk_path=$(find "${sdk_dir}/ndk" -mindepth 2 -maxdepth 2 -name source.properties -print | xargs -n1 dirname | sort -V | tail -1) ndk_version=${ndk_path##*/} fi if [[ -z "${ndk_version}" || ! -d "${sdk_dir}/ndk/${ndk_version}" ]]; then @@ -100,7 +101,7 @@ docker run --rm --platform linux/amd64 \ 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 \ @@ -311,6 +312,29 @@ grep -qi 'CN=Android, OU=Android, O=Android' <<<"${signature_output}" || { exit 1 } +# With DroidDeck's own key at hand, sign as CI's main builds are (tools/release/sign-apk.sh), so +# the apk installs over a release instead of being refused as a different signer. The key's +# location and password live in an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD, +# RELEASE_KEY_ALIAS), looked for in this checkout and then in the main one, which worktrees share. +signing_env=${DROIDDECK_SIGNING_ENV:-} +if [[ -z "${signing_env}" ]]; then + for candidate in "${repo_root}/.signing.env" \ + "$(dirname "$(git -C "${repo_root}" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo .)")/.signing.env"; do + [[ -f "${candidate}" ]] && { signing_env=${candidate}; break; } + done +fi +if [[ -n "${signing_env}" ]]; then + echo "Signing with DroidDeck's key (${signing_env})" + ( + set -a + # shellcheck disable=SC1090 + . "${signing_env}" + set +a + BUILD_TOOLS="${build_tools}" "${repo_root}/tools/release/sign-apk.sh" "${apk}" standard "${apk}.release" + ) + mv "${apk}.release" "${apk}" +fi + printf 'APK: %s\n' "${apk}" printf 'SHA-256: ' shasum -a 256 "${apk}" | awk '{print $1}' diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 55adbdf07..6e1d17595 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -55,6 +55,10 @@ if [ -n "${BL_DEBUG_DIR:-}" ]; then echo "== droiddeck-desktop $(date -u '+%Y-%m-%d %H:%M:%S UTC') renderer=$WLR_RENDERER" fi export WLR_BACKENDS=wayland +# The cursor goes to the app's compositor as a wl_pointer cursor surface ("hardware" cursor on the +# wayland backend), which the app draws as its pointer. Drawn into the desktop's own frames instead +# (WLR_NO_HARDWARE_CURSORS=1), it cost every frame a full-screen composite: a software cursor +# rules out direct scan-out, so a full-screen game was never handed to the app as it is. mkdir -p "$HOME/.config/labwc" "$HOME/.config/lxqt" # Our defaults for labwc and the panel, refreshed every session. What a user changes in the # panel's own settings lands in panel.conf and is kept; only rc.xml and autostart are ours. diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env b/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env new file mode 100755 index 000000000..ff9e87449 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env @@ -0,0 +1,59 @@ +#!/usr/bin/python3 +import json +import os +import re +import sys +from pathlib import Path + + +def game_id(env): + prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") + if not prefix: + return None + value = prefix.rsplit("/", 1)[-1] + if not re.fullmatch(r"-?[0-9]+", value): + return None + number = int(value) + if not -(2**31) <= number < 2**32 or number == 0: + return None + return str(number % 2**32) + + +def apply_config(env, config, app_id): + if config.get("version") != 1: + raise ValueError("unsupported environment configuration") + result = env.copy() + entries = dict(config.get("shared", {})) + entries.update(config.get("games", {}).get(app_id, {})) + for name, value in entries.items(): + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + raise ValueError("invalid environment name") + if value is None: + result.pop(name, None) + elif isinstance(value, str) and "\0" not in value and len(value) <= 8192: + result[name] = value + else: + raise ValueError("invalid environment value") + return result + + +def main(): + command = sys.argv[1:] + if not command: + return 2 + env = os.environ.copy() + app_id = game_id(env) + if len(command) > 1 and command[1] == "waitforexitandrun" and app_id: + path = Path.home() / ".config/droiddeck/game-environment.json" + try: + with path.open(encoding="utf-8") as source: + env = apply_config(env, json.load(source), app_id) + except FileNotFoundError: + pass + except (OSError, ValueError, TypeError, AttributeError): + print("bannerlator-game-env: invalid configuration; using inherited environment", file=sys.stderr) + os.execvpe(command[0], command, env) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 5b254a223..adba13d6a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -717,8 +717,36 @@ CEMU_XML = """ """ +# Cemu's settings, so its Getting Started dialog (shown while settings.xml is missing, and whose +# folder picker cannot reach ROMs) never blocks a launch: the Wii U games folder, Vulkan +# (Graphic api 1), and audio through Cubeb (the only backend on Linux; api 3) on the default +# device for both the TV and the GamePad speaker - some titles play everything through the +# GamePad - at full volume (Cemu's own default is 20 %). Keys from config/CemuConfig.cpp. +CEMU_SETTINGS = """ + + + + {games} + + + 1 + + + +""" + + def cemu(): seed(os.path.join(CONFIG, "Cemu/controllerProfiles/controller0.xml"), CEMU_XML, "Cemu") + games = system_folder(["wiiu", "wii u"], "wiiu") + seed(os.path.join(CONFIG, "Cemu/settings.xml"), CEMU_SETTINGS.format(games=games), "Cemu", + done=f"games in {games}, Vulkan, Cubeb audio on the default device") # melonDS 1.x: raw SDL joystick indices. The session's pad has the kernel's Xbox 360 layout diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 568889881..6eb19a177 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -19,7 +19,10 @@ if [ -n "${BL_LOG:-}" ] && [ -z "${BL_INSIDE:-}" ]; then if [ "$(stat -c %s "$BL_LOG" 2>/dev/null || echo 0)" -gt 8388608 ]; then mv -f "$BL_LOG" "$BL_LOG.1" fi - exec >>"$BL_LOG" 2>&1 + # A guest looping on an error writes gigabytes in minutes (an Xwayland proot had lost track of + # managed 9 GB of one line in 19 min): the log keeps its first BL_LOG_CAP bytes and the rest is + # drained, not refused - a writer that filled it must not die of SIGPIPE. + exec > >({ head -c "${BL_LOG_CAP:-268435456}" >>"$BL_LOG"; cat >/dev/null; }) 2>&1 echo "== bannerlator-session $(date) mode=$mode $*" echo "== uid=$(id -u) size=${BL_WIDTH:-?}x${BL_HEIGHT:-?} fps=${BL_FPS:-0} icd=${VK_ICD_FILENAMES:-none}" echo "== gamescope: $(gamescope --version 2>&1 | head -1) at $(command -v gamescope)" @@ -321,18 +324,35 @@ case $mode in # in yet - so the install dialog only appeared after the user restarted the client by hand. # With no remembered sign-in, ask again once the sign-in has happened: the client binary # started a second time with a URL hands it to the running instance and exits. + # + # The request is also dropped while the client is still in its post-logon compat phase (it + # probes every registered tool then): with publicbeta 1790380355 that phase took 27 s and a + # request sent 15 s after sign-in vanished (2026-09-26, no dialog; 1790121765 was done in + # time). So wait for the client to say the phase is over ("Waiting for compat in post-logon + # took" in its console log; at most 90 s if it never does), then ask - and ask again, twice + # at most, if no install has started a minute later. if ! grep -q '"RememberPassword"[[:space:]]*"1"' "$steam_root/config/loginusers.vdf" 2>/dev/null; then ( + _bl_acf="$steam_root/steamapps/appmanifest_4427310.acf" _bl_conn="$steam_root/logs/connection_log.txt" + _bl_cons="$steam_root/logs/console_log.txt" _bl_from=$(stat -c %s "$_bl_conn" 2>/dev/null || echo 0) + _bl_cfrom=$(stat -c %s "$_bl_cons" 2>/dev/null || echo 0) for _ in $(seq 1 180); do sleep 10 - [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0 + [ -f "$_bl_acf" ] && exit 0 if tail -c +$((_bl_from + 1)) "$_bl_conn" 2>/dev/null | grep -q "RecvMsgClientLogOnResponse() : processing complete"; then - sleep 15 - [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0 - step "signed in: asking the client again for the ARM64 Proton" - "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true + for _ in $(seq 1 45); do + tail -c +$((_bl_cfrom + 1)) "$_bl_cons" 2>/dev/null | grep -q "Waiting for compat in post-logon took" && break + sleep 2 + done + for _bl_try in 1 2 3; do + sleep 10 + [ -f "$_bl_acf" ] && exit 0 + step "signed in: asking the client again for the ARM64 Proton (try $_bl_try of 3)" + "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true + sleep 50 + done exit 0 fi done @@ -490,20 +510,19 @@ BUSCONF # desktop's launchers ask for; gamescope shows one window at a time and the game takes over. _bl_ui=(-gamepadui) [ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=() - # Deck mode (BL_STEAMDECK=1, the Performance page): -steamdeck is the flag that puts the Quick - # Access Menu, the performance overlay and the Deck settings pages on screen. Only -steamdeck, - # never -steamos3: that one makes the client manage itself as SteamOS does - it calls - # steamos-select-branch and opts the install into the steamdeck_stable client branch, writes - # that to package/beta, and from then on the branch on disk loses to the -clientbeta below on - # every launch; the updater finds "installed version 0", marks an update pending and exits 42 - # to apply it, which never happens, so the client restarts for ever - under a running game, - # taking Steam Input with it each time. (Bannerlator 38996ea1, on device.) The SteamOS helpers - # the client calls in this mode are staged as no-op stubs under /usr/bin. + # Deck mode (BL_STEAMDECK=1, the Steam page's Client settings): -steamdeck is the flag that puts + # the Quick Access Menu and the Deck settings pages on screen; the QAM's performance overlay + # controls stay greyed out without -steamos3, as SteamOS and Armada launch it. -steamos3 makes + # the client manage itself as SteamOS does - it calls steamos-select-branch and writes + # steamdeck_stable to package/beta, and a branch on disk that loses to the -clientbeta below + # is an update that never installs: exit 42, a restart, for ever (Bannerlator 38996ea1). The + # loop below puts package/beta back before every start. The SteamOS helpers the client calls + # in this mode are staged as no-op stubs under /usr/bin. if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ]; then - _bl_ui+=(-steamdeck) + _bl_ui+=(-steamdeck -steamos3) # The QAM's Scaling Filter lists NVIDIA Image Scaling only when told gamescope has it (it does). export STEAM_GAMESCOPE_NIS_SUPPORTED=1 - echo "== steam: Deck mode on (-steamdeck)" + echo "== steam: Deck mode on (-steamdeck -steamos3)" fi # What a killed session leaves behind. Android ends sessions without warning (backgrounding, the # low-memory killer), and the client then finds .crash - opening its update UI on the spinner - @@ -528,10 +547,31 @@ BUSCONF # again on the branch it just wrote. steam_channel=${BL_STEAM_CHANNEL:-publicbeta} steam_beta=$steam_root/package/beta + if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then + bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & + games_watcher=$! + fi echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. steam_updates=0 + rm -f "$HOME/.bl-stop-requested" + if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then + rm -f "$BL_LAUNCH_DIR/steam-stop" + ( + while :; do + if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then + rm -f "$BL_LAUNCH_DIR/steam-stop" + : > "$HOME/.bl-stop-requested" + echo "== steam: the app asked for a clean exit" + "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true + exit 0 + fi + sleep 0.5 + done + ) & + stop_watcher=$! + fi while :; do rm -f "$HOME/.bl-restart-once" if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then @@ -545,6 +585,13 @@ BUSCONF rc=$? echo "== steam exited rc=$rc" collect_steam_logs + if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then + bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + fi + if [ -f "$HOME/.bl-stop-requested" ]; then + rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once" + break + fi # The session asked for this exit (the ARM64 Proton landed): start again, without the URL # arguments, which were acted on. Not counted against the update cap. if [ -f "$HOME/.bl-restart-once" ]; then @@ -561,6 +608,14 @@ BUSCONF # The URL arguments were acted on the first time; a restart must not repeat them. set -- done + if [ -n "${stop_watcher:-}" ]; then + kill "$stop_watcher" 2>/dev/null || true + wait "$stop_watcher" 2>/dev/null || true + fi + if [ -n "${games_watcher:-}" ]; then + kill "$games_watcher" 2>/dev/null || true + wait "$games_watcher" 2>/dev/null || true + fi if [ -n "${BL_DESKTOP:-}" ]; then # Started from the desktop (bannerlator-steam-launch): the desktop's own programs share this # script's parent, so reap_orphans - right under gamescope, where everything is the client's - @@ -580,6 +635,8 @@ BUSCONF wait "$decky_supervisor" 2>/dev/null || true echo "== Decky: session supervisor stopped" fi + _bl_jobs=$(jobs -p) + [ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null reap_orphans fi exit "$rc" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 4156846a5..7040f2dde 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -70,6 +70,60 @@ TOOL_MANIFEST_VDF = """"manifest" } """ % LAUNCHER + +# Steam runs its own graphics probe through EVERY registered tool at once, ~40 s after logging in, +# all with STEAM_COMPAT_DATA_PATH=.../compatdata/0, and those concurrent Protons can write their +# builtin DLLs through each other's prefix links into each other's trees (seen: GE's FEX DLLs became +# Proton Experimental's). Moving those runs to a prefix per tool was tried and crashed the Steam +# client right after the probe started (segfault, rc 139, first ever in 25 sessions), so the client +# relies on compatdata/0 itself. The launch-time check below is the protection instead. + +# The Components page's choices, enforced in the one place every game launch passes: the app keeps +# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of +# each package in use. A file that no longer matches - whatever changed it - is put back before +# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build +# differs and the Proton's own new files are left alone until the user chooses again. +BL_COMPONENTS_SETUP = """ +bl_components() { + base=$1 + cm_dir="${HOME:-/root}/.local/share/bannerlator-components" + cm_list="$cm_dir/desired.tsv" + # A real game launch only: the verb the client starts a game with and the game's own prefix. Every + # other run - above all the client's own probes right after login, which all share compatdata/0 - + # is left exactly as it was: with this check running through them, the client segfaulted (rc 139) + # within seconds of starting its probes, four sessions out of four. The prefix decides rather than + # the app id because a non-Steam shortcut launches with app id 0 but still gets a prefix of its own. + [ "${verb:-}" = waitforexitandrun ] || return 0 + case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac + [ -f "$cm_list" ] || return 0 + cm_app=${STEAM_COMPAT_DATA_PATH%/} + cm_app=${cm_app##*/} + cm_tool=${base##*/} + cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null) + cm_fixed=0 + cm_checked=0 + while IFS=$'\\t' read -r t want comp rel sum src; do + [ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue + cm_checked=$((cm_checked + 1)) + f="$base/$rel" + if [ -f "$f" ] && [ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ]; then continue; fi + if [ ! -f "$src" ]; then + echo "bannerlator-proton: components: $comp source missing ($src), left as it is" >&2 + continue + fi + mkdir -p "$(dirname "$f")" + cp -f "$src" "$f.bl-new" && mv -f "$f.bl-new" "$f" || continue + case "$rel" in *.so) chmod 755 "$f" ;; *) chmod 644 "$f" ;; esac + cm_fixed=$((cm_fixed + 1)) + done < "$cm_list" + [ "$cm_checked" -gt 0 ] && echo "bannerlator-proton: components: checked $cm_checked file(s) in $cm_tool for app $cm_app" >&2 + if [ "$cm_fixed" -gt 0 ]; then + echo "bannerlator-proton: components: re-applied $cm_fixed of $cm_checked file(s) in $cm_tool before this launch" >&2 + printf '%s\\t%s\\t%s\\n' "$(date +%s)" "$cm_tool" "$cm_fixed" >> "$cm_dir/reapplied.log" + fi +} +""" + # Resolves the depot at launch time, so the tool can be registered before the depot exists and # survives the client updating or replacing it. Proton takes its base directory from # dirname(sys.argv[0]) without resolving it, so it is run from the depot itself. @@ -175,10 +229,11 @@ fi for name in %s; do depot="$steam_root/steamapps/common/$name" if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then + bl_components "$depot" bl_directaudio "$depot" bl_game_cpus bl_idtech3 "$@" - exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" + exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" fi done echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 @@ -188,7 +243,8 @@ exit 1 # and a % format would eat that into a single %, leaving a shell expansion that silently does # something else. LAUNCHER_SH = LAUNCHER_SH.replace( - "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP, 1) + "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP + + BL_COMPONENTS_SETUP, 1) # A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of # that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot @@ -220,12 +276,13 @@ if [ -f "$fake" ]; then *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; esac fi +bl_components "$here" bl_directaudio "$here" bl_game_cpus bl_idtech3 "$@" # Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be # started from its own tree. -exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" +exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" """ EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools" { @@ -384,7 +441,8 @@ def adopt_extras(tools): # ${major%%.*} of its own, and putting it through the format would eat both. body = (EXTRA_WRAPPER_SH % entry_point).replace( 'here=$(dirname "$0")', - 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP, 1) + 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP + + BL_COMPONENTS_SETUP, 1) with open(wrapper, "w", encoding="utf-8") as f: f.write(body) os.chmod(wrapper, 0o755) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games new file mode 100755 index 000000000..1e6f2a1fd --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Resolve added games against the signed-in Steam client's licenses.""" +import ctypes as C +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time +import unicodedata +import urllib.parse +import urllib.request + +STATE = ".droiddeck-games.json" +BASE_ID = 76561197960265728 + + +def read_json(path, default): + try: + return json.loads(Path(path).read_text()) + except (OSError, ValueError): + return default + + +def save_json(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_suffix(".tmp") + temp.write_text(json.dumps(value)) + os.replace(temp, path) + + +def title_key(name): + return "".join(c for c in unicodedata.normalize("NFKC", name.replace("โ„ข", "").replace("ยฎ", "")).casefold() if c.isalnum()) + + +def edition_key(name): + name = re.sub(r"(?i)\s+(?:windows edition|intergrade)\s*$", "", name) + return title_key(name) + + +def source_stamp(game): + try: + exe = Path(game["exe"]) + stat = exe.stat() + hints = [] + for parent in sorted({Path(game["folder"]), exe.parent}): + hint = parent / "steam_appid.txt" + hints.append(hint.read_text().strip() if hint.is_file() else "") + return [game["name"], str(exe), stat.st_size, stat.st_mtime_ns, hints] + except (OSError, ValueError): + return None + + +def identify(game): + folder = Path(game["folder"]) + steamapps = folder.parent.parent + if folder.parent.name == "common" and steamapps.name == "steamapps": + matches = [] + for manifest in steamapps.glob("appmanifest_*.acf"): + try: + text = manifest.read_text() + install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I) + if install and install[1] == folder.name: + matches.append(int(manifest.stem.removeprefix("appmanifest_"))) + except (OSError, ValueError): + pass + if matches: + return matches[0] if len(matches) == 1 else None + ids = set() + for parent in {folder, Path(game["exe"]).parent}: + try: + appid = int((parent / "steam_appid.txt").read_text().strip()) + if 0 < appid < 0x80000000: + ids.add(appid) + except (OSError, ValueError): + pass + if ids: + return next(iter(ids)) if len(ids) == 1 else None + url = "https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=" + urllib.parse.quote(game["name"]) + try: + with urllib.request.urlopen(url, timeout=5) as response: + items = json.loads(response.read(1024 * 1024)).get("items", []) + for key in (title_key, edition_key): + ids = {int(item["id"]) for item in items + if key(item.get("name", "")) == key(game["name"]) and key(game["name"])} + if ids: + return next(iter(ids)) if len(ids) == 1 else None + return None + except (OSError, ValueError, KeyError): + return None + + +def method(obj, slot, result, *args): + if not obj: + raise RuntimeError("Steam interface unavailable") + table = C.cast(obj, C.POINTER(C.POINTER(C.c_void_p))).contents + return C.CFUNCTYPE(result, C.c_void_p, *args)(table[slot]) + + +def probe(steam_root, ids): + # Slots belong to Valve's SteamClient020, SteamUser021 and STEAMAPPS_INTERFACE_VERSION008. + library = C.CDLL(str(Path(steam_root) / "steamrtarm64/steamclient.so")) + library.CreateInterface.argtypes = [C.c_char_p, C.c_void_p] + library.CreateInterface.restype = C.c_void_p + client = library.CreateInterface(b"SteamClient020", None) + pipe = method(client, 0, C.c_int)(client) + if not pipe: + raise RuntimeError("Steam pipe unavailable") + user = 0 + try: + user = method(client, 2, C.c_int, C.c_int)(client, pipe) + if not user: + raise RuntimeError("No signed-in Steam user") + get_interface = lambda slot, version: method(client, slot, C.c_void_p, C.c_int, C.c_int, C.c_char_p)(client, user, pipe, version) + account = get_interface(5, b"SteamUser021") + if not method(account, 1, C.c_bool)(account): + raise RuntimeError("Steam is offline") + steamid = method(account, 2, C.c_uint64)(account) + apps = get_interface(15, b"STEAMAPPS_INTERFACE_VERSION008") + subscribed = method(apps, 6, C.c_bool, C.c_uint32) + return {"account": str(steamid - BASE_ID), "checked": time.time(), + "owned": {str(appid): bool(subscribed(apps, appid)) for appid in ids}} + finally: + if user: + method(client, 4, None, C.c_int, C.c_int)(client, pipe, user) + method(client, 1, C.c_bool, C.c_int)(client, pipe) + + +def watch(steam_root, listing): + games = read_json(listing, []) + if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games: + return + candidates, sources = {}, {} + next_lookup = 0 + last_status = None + while True: + now = time.monotonic() + for game in games: + folder = game["folder"] + stamp = source_stamp(game) + if now >= next_lookup or stamp != sources.get(folder): + sources[folder] = stamp + appid = identify(game) + if appid: + candidates[folder] = appid + else: + candidates.pop(folder, None) + if now >= next_lookup: + next_lookup = now + 300 + status = "waiting for a matching Steam title" + if candidates: + try: + result = subprocess.run([sys.executable, __file__, "probe", steam_root, + *map(str, sorted(set(candidates.values())))], + capture_output=True, text=True, timeout=15) + if result.returncode != 0: + status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode)) + else: + line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP=")) + snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP=")) + if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0: + raise ValueError("invalid Steam account") + snapshot["candidates"] = candidates + snapshot["sources"] = sources + save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot) + status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values())) + except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error: + status = "ownership check unavailable: " + str(error) + if status != last_status: + print("bannerlator-steam-games: " + status, flush=True) + last_status = status + time.sleep(30) + + +def seed_install(steam_root, game, appid): + steamapps = Path(steam_root) / "steamapps" + common = steamapps / "common" + name = f"DroidDeck-{appid}" + target = common / name + source = Path(game["folder"]) + title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ") + content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n' + + f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n') + legacy = content.replace(' "Universe" "1"\n', '') + manifest = steamapps / f"appmanifest_{appid}.acf" + for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]: + existing = root / "steamapps" / manifest.name + if existing.is_file(): + if (existing == manifest and source.is_dir() and target.is_symlink() + and os.readlink(target) == str(source) + and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))): + temp = manifest.with_suffix(".droiddeck") + temp.write_text(content) + os.replace(temp, manifest) + return True + if not source.is_dir(): + return False + common.mkdir(parents=True, exist_ok=True) + if os.path.lexists(target): + if not target.is_symlink() or os.readlink(target) != str(source): + return False + else: + target.symlink_to(source, target_is_directory=True) + try: + with manifest.open("x") as output: + output.write(content) + except FileExistsError: + pass + return True + + +def route(steam_root, acct, games): + snapshot = read_json(Path(acct) / "config" / STATE, {}) + if not isinstance(snapshot, dict): + snapshot = {} + owned = snapshot.get("owned", {}) if snapshot.get("account") == Path(acct).name else {} + candidates = snapshot.get("candidates", {}) + sources = snapshot.get("sources", {}) + if not all(isinstance(value, dict) for value in (owned, candidates, sources)): + owned, candidates, sources = {}, {}, {} + shortcuts, routes = [], {} + for game in games: + appid = candidates.get(game.get("folder")) + stamp = source_stamp(game) + confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000 + and owned.get(str(appid)) is True and stamp is not None + and sources.get(game.get("folder")) == stamp) + if confirmed: + try: + if seed_install(steam_root, game, appid): + routes[str(game["appid"])] = appid + continue + except OSError as error: + print(f"bannerlator-steam-games: import unavailable ({error})") + shortcuts.append(game) + save_json(Path(acct) / "config/.droiddeck-routes.json", routes) + return shortcuts, routes + + +if __name__ == "__main__": + if len(sys.argv) < 4: + sys.exit(2) + try: + if sys.argv[1] == "probe": + print("DROIDDECK_OWNERSHIP=" + json.dumps(probe(sys.argv[2], [int(value) for value in sys.argv[3:]])), flush=True) + elif sys.argv[1] == "watch": + watch(sys.argv[2], sys.argv[3]) + else: + sys.exit(2) + except (OSError, RuntimeError, ValueError) as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts index 7f7606912..c3fcd4ae3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts @@ -1,15 +1,7 @@ #!/usr/bin/env python3 -"""Puts the app's added games (Windows games in the user's own folders) into the Steam client's -library as non-Steam shortcuts, each mapped to the ARM64 Proton, before the client starts. +"""Import added games while Steam is closed, preserving user-created shortcuts. - bannerlator-steam-shortcuts - -The JSON is a list the app writes each session: {"name", "exe", "dir", "appid"} with guest paths. -The client keeps non-Steam games in userdata//config/shortcuts.vdf, a binary VDF it -reads at start-up and rewrites at exit, so this runs while it is down. Entries the app wrote -carry the tag "droiddeck-app" and are replaced wholesale; anything the user added inside the -client is kept as it is. The appid is the one the client itself derives (crc32 of exe+name with -the top bit set), so the launch URL the app uses matches what the client shows. +Usage: bannerlator-steam-shortcuts """ import importlib.machinery import importlib.util @@ -207,8 +199,15 @@ def main(): try: with open(path, "rb") as f: existing = parse(f.read()).get("shortcuts", {}) - except (ValueError, IndexError) as e: - print("bannerlator-steam-shortcuts: could not read the existing file (%s); starting over" % e) + except (ValueError, IndexError, struct.error) as e: + print("bannerlator-steam-shortcuts: could not read the existing file (%s); keeping it unchanged" % e) + return 1 + helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bannerlator-steam-games") + loader = importlib.machinery.SourceFileLoader("steam_games", helper) + spec = importlib.util.spec_from_loader(loader.name, loader) + routing = importlib.util.module_from_spec(spec) + loader.exec_module(routing) + games, routes = routing.route(steam_root, acct, games) kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()] ours = [shortcut(g) for g in games if g.get("exe") and g.get("name")] entries = {str(i): v for i, v in enumerate(kept + ours)} @@ -226,8 +225,8 @@ def main(): compat = importlib.util.module_from_spec(spec) spec.loader.exec_module(compat) cfg = os.path.join(steam_root, "config", "config.vdf") - if os.path.isfile(cfg) and ours: - compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")]) + if os.path.isfile(cfg) and (ours or routes): + compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())]) print("bannerlator-steam-shortcuts: mapped them to %s" % compat.TOOL) except Exception as e: # noqa: BLE001 - the games still show; the client falls back to the default tool print("bannerlator-steam-shortcuts: compat mapping skipped (%s)" % e) diff --git a/tools/linuxfs/preload/inputudev.c b/tools/linuxfs/preload/inputudev.c index b3dffd1f3..5600538ea 100644 --- a/tools/linuxfs/preload/inputudev.c +++ b/tools/linuxfs/preload/inputudev.c @@ -131,6 +131,9 @@ static struct pad *pad_for_syslink(const char *path) { static void *next_symbol(const char *name) { return dlsym(RTLD_NEXT, name); } +char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *)) + __attribute__((visibility("hidden"))); + /* Wine reaches libudev from its HID bus driver, which is the only caller these pads are for. */ static int caller_is_winebus(void *caller) { Dl_info info; @@ -152,7 +155,7 @@ char *realpath(const char *path, char *resolved) { } if (real_realpath == NULL) real_realpath = next_symbol("realpath"); if (real_realpath == NULL) return NULL; - return real_realpath(path, resolved); + return bl_cached_realpath(path, resolved, real_realpath); } void *udev_device_new_from_syspath(void *udev, const char *syspath) { diff --git a/tools/linuxfs/preload/opens.c b/tools/linuxfs/preload/opens.c index 52d52ec4c..4c8b6e165 100644 --- a/tools/linuxfs/preload/opens.c +++ b/tools/linuxfs/preload/opens.c @@ -34,6 +34,7 @@ #include int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden"))); +int bl_writable_retry(int dirfd, const char *path, int flags) __attribute__((visibility("hidden"))); static int retry_without_noatime(int fd, int flags) { return fd < 0 && errno == EPERM && (flags & O_NOATIME); @@ -67,6 +68,8 @@ int open(const char *path, int flags, ...) { } fd = real_open(path, flags, mode); if (retry_without_noatime(fd, flags)) fd = real_open(path, flags & ~O_NOATIME, mode); + /* A read-only file the Steam client rewrites (perms.c). */ + if (fd < 0 && bl_writable_retry(AT_FDCWD, path, flags)) fd = real_open(path, flags & ~O_NOATIME, mode); return fd; } @@ -91,6 +94,7 @@ int openat(int dirfd, const char *path, int flags, ...) { } fd = real_openat(dirfd, path, flags, mode); if (retry_without_noatime(fd, flags)) fd = real_openat(dirfd, path, flags & ~O_NOATIME, mode); + if (fd < 0 && bl_writable_retry(dirfd, path, flags)) fd = real_openat(dirfd, path, flags & ~O_NOATIME, mode); return fd; } diff --git a/tools/linuxfs/preload/pathcache.c b/tools/linuxfs/preload/pathcache.c new file mode 100644 index 000000000..2f2cfd3d4 --- /dev/null +++ b/tools/linuxfs/preload/pathcache.c @@ -0,0 +1,189 @@ +/* + * Path lookups the Steam client repeats, answered from a short-lived cache. + * + * While it registers its compatibility tools after logon, the client resolves the same few + * directories - its local tools under compatibilitytools.d and its own platform directory - with + * realpath() and access() thousands of times a second. Each realpath() is one readlinkat() per + * path component, and under proot every one of those is a trapped syscall with a round trip + * through the tracer. Measured on an AYN Thor, that loop was 23 of the client's 31 seconds of + * startup, with proot busy for 16 of them. + * + * Only the client itself uses the cache, never a game or a helper. Only successful answers are + * kept, and only for a moment: a path that stops existing is noticed within PATH_CACHE_TTL_NS, and + * one that starts existing is never hidden, since failures always go to the filesystem. + * BL_NO_PATH_CACHE=1 turns it off. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define PATH_CACHE_SLOTS 256 +#define PATH_CACHE_KEY_MAX 512 +#define PATH_CACHE_TTL_NS 2000000000LL + +enum { KIND_REALPATH, KIND_ACCESS }; + +struct entry { + long long stamp; + unsigned hash; + int kind; + int mode; + int flags; + char key[PATH_CACHE_KEY_MAX]; + char *value; +}; + +static struct entry table[PATH_CACHE_SLOTS]; +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; + +static long long now_ns(void) { + struct timespec t; + + clock_gettime(CLOCK_MONOTONIC_COARSE, &t); + return (long long)t.tv_sec * 1000000000LL + t.tv_nsec; +} + +static int enabled(void) { + static int cached = -1; + const char *off; + + if (cached >= 0) return cached; + off = getenv("BL_NO_PATH_CACHE"); + cached = strcmp(program_invocation_short_name, "steam") == 0 && + strstr(program_invocation_name, "steamrtarm64") != NULL && + !(off != NULL && off[0] == '1'); + return cached; +} + +static unsigned hash_of(const char *s, int kind, int mode, int flags) { + unsigned h = 2166136261u ^ (unsigned)(kind * 31 + mode * 7 + flags); + + while (*s) h = (h ^ (unsigned char)*s++) * 16777619u; + return h; +} + +static int cacheable(const char *path) { + return path != NULL && path[0] == '/' && strlen(path) < PATH_CACHE_KEY_MAX && enabled(); +} + +static struct entry *find(const char *path, int kind, int mode, int flags, unsigned h) { + struct entry *e = &table[h % PATH_CACHE_SLOTS]; + + if (e->stamp == 0 || e->hash != h || e->kind != kind || e->mode != mode || e->flags != flags) return NULL; + if (now_ns() - e->stamp > PATH_CACHE_TTL_NS || strcmp(e->key, path) != 0) return NULL; + return e; +} + +static void store(const char *path, int kind, int mode, int flags, unsigned h, const char *value) { + struct entry *e = &table[h % PATH_CACHE_SLOTS]; + char *copy = value != NULL ? strdup(value) : NULL; + + if (value != NULL && copy == NULL) return; + free(e->value); + e->value = copy; + e->hash = h; + e->kind = kind; + e->mode = mode; + e->flags = flags; + memcpy(e->key, path, strlen(path) + 1); + e->stamp = now_ns(); +} + +__attribute__((visibility("hidden"))) +char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *)) { + char out[PATH_MAX]; + unsigned h; + struct entry *e; + + if (!cacheable(path)) return real(path, resolved); + h = hash_of(path, KIND_REALPATH, 0, 0); + pthread_mutex_lock(&lock); + e = find(path, KIND_REALPATH, 0, 0, h); + if (e != NULL && e->value != NULL) { + size_t n = strlen(e->value) + 1; + char *result = resolved != NULL ? resolved : malloc(n); + if (result != NULL) memcpy(result, e->value, n); + pthread_mutex_unlock(&lock); + return result; + } + pthread_mutex_unlock(&lock); + if (real(path, out) == NULL) return NULL; + pthread_mutex_lock(&lock); + store(path, KIND_REALPATH, 0, 0, h, out); + pthread_mutex_unlock(&lock); + if (resolved != NULL) { + memcpy(resolved, out, strlen(out) + 1); + return resolved; + } + return strdup(out); +} + +static int cached_access(const char *path, int mode, int flags, int (*check)(const char *, int, int)) { + unsigned h; + int hit; + int result; + + if (!cacheable(path)) return check(path, mode, flags); + h = hash_of(path, KIND_ACCESS, mode, flags); + pthread_mutex_lock(&lock); + hit = find(path, KIND_ACCESS, mode, flags, h) != NULL; + pthread_mutex_unlock(&lock); + if (hit) return 0; + result = check(path, mode, flags); + if (result == 0) { + pthread_mutex_lock(&lock); + store(path, KIND_ACCESS, mode, flags, h, NULL); + pthread_mutex_unlock(&lock); + } + return result; +} + +static int (*next_faccessat(void))(int, const char *, int, int) { + static int (*real)(int, const char *, int, int); + + if (real == NULL) real = (int (*)(int, const char *, int, int))dlsym(RTLD_NEXT, "faccessat"); + return real; +} + +static int check_cwd(const char *path, int mode, int flags) { + int (*real)(int, const char *, int, int) = next_faccessat(); + + if (real == NULL) { + errno = ENOSYS; + return -1; + } + return real(AT_FDCWD, path, mode, flags); +} + +int access(const char *path, int mode) { + return cached_access(path, mode, 0, check_cwd); +} + +int euidaccess(const char *path, int mode) { + return cached_access(path, mode, AT_EACCESS, check_cwd); +} + +int eaccess(const char *path, int mode) { + return cached_access(path, mode, AT_EACCESS, check_cwd); +} + +int faccessat(int dirfd, const char *path, int mode, int flags) { + int (*real)(int, const char *, int, int); + + if (dirfd == AT_FDCWD) { + return cached_access(path, mode, flags, check_cwd); + } + real = next_faccessat(); + if (real == NULL) { + errno = ENOSYS; + return -1; + } + return real(dirfd, path, mode, flags); +} diff --git a/tools/linuxfs/preload/perms.c b/tools/linuxfs/preload/perms.c new file mode 100644 index 000000000..96f1540c2 --- /dev/null +++ b/tools/linuxfs/preload/perms.c @@ -0,0 +1,82 @@ +/* + * Root's way past a read-only file, for the Steam client. + * + * The client runs as "root" inside the session - the rootfs names the app's own uid root, and + * proot (-i) shows it that uid - but the kernel treats it as the ordinary user it is. The client + * leaves many content files read-only (r-x------, + * ~290 files of Proton Experimental (ARM64) alone), and when an update later rewrites one of them + * it opens the file for writing expecting root's permission to override the mode. The kernel + * refuses (EACCES), the client reports "Missing file permissions" and cancels the update + * (2026-09-26: Proton Experimental (ARM64), "CFileWriter: errno: 13 ... wineopenxr.dll"). + * + * So for the client's own processes, a refused write-open of a file this user owns is answered the + * way root's would be: the owner's write bit is added - to the file, or to the directory a new + * file is being created in - and the open is tried once more. chmod only succeeds on what the app + * owns, so nothing it could not already change is touched. Games, Wine and everything else keep + * ordinary permissions: only the processes started from the client's own directories take this. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static int in_steam_dirs(const char *path) { + return path != NULL && (strstr(path, "/Steam/steamrtarm64/") != NULL + || strstr(path, "/Steam/ubuntu12_64/") != NULL || strstr(path, "/Steam/linuxarm64/") != NULL); +} + +/* + * The client and its helpers run from the Steam root's platform directories. The kernel's + * /proc/self/exe names proot's loader for every process in the session, so the program's own + * name - the path it was started by - is asked as well. + */ +static int is_steam_client(void) { + static int cached = -1; + char exe[PATH_MAX]; + ssize_t n; + + if (cached >= 0) return cached; + n = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (n > 0) exe[n] = '\0'; + cached = (n > 0 && in_steam_dirs(exe)) || in_steam_dirs(program_invocation_name); + return cached; +} + +static int grant_owner_write(int dirfd, const char *path) { + struct stat st; + + if (fstatat(dirfd, path, &st, 0) != 0 || (st.st_mode & S_IWUSR)) return 0; + return fchmodat(dirfd, path, (st.st_mode & 07777) | S_IWUSR, 0) == 0; +} + +/* + * After an open refused with EACCES: returns 1 when a write bit was added and the open is worth + * one more try. errno is left as the open set it. + */ +__attribute__((visibility("hidden"))) +int bl_writable_retry(int dirfd, const char *path, int flags) { + int saved = errno; + int granted = 0; + char parent[PATH_MAX]; + const char *slash; + + if (saved != EACCES || path == NULL || (flags & O_ACCMODE) == O_RDONLY || !is_steam_client()) return 0; + if (faccessat(dirfd, path, F_OK, 0) == 0) { + granted = grant_owner_write(dirfd, path); + } else if (flags & O_CREAT) { + /* A new file in a read-only directory. */ + slash = strrchr(path, '/'); + if (slash == NULL) { + granted = grant_owner_write(dirfd, "."); + } else if ((size_t) (slash - path) < sizeof(parent)) { + memcpy(parent, path, slash - path); + parent[slash - path] = '\0'; + granted = grant_owner_write(dirfd, parent[0] ? parent : "/"); + } + } + errno = saved; + return granted; +} diff --git a/tools/linuxfs/preload/sysv.c b/tools/linuxfs/preload/sysv.c index 6b9a21dc8..36d7bdd70 100644 --- a/tools/linuxfs/preload/sysv.c +++ b/tools/linuxfs/preload/sysv.c @@ -7,7 +7,8 @@ * segment is a plain file that shmat maps. Objects are found by the inode of their file, which is * what the id is. semop applies its operations one by one (no all-or-nothing across a set) and * SEM_UNDO is accepted but not performed - enough for the client, which uses single-counter sets. - * Message queues stay unsupported. */ + * A message queue is a table of slots in its file: gamescope hands mangoapp (Deck mode's performance + * overlay) each frame's timings through one. */ #define _GNU_SOURCE #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -396,6 +399,126 @@ int shmctl(int shmid, int cmd, struct shmid_ds *buf) { } } +/* ------------------------------------------------------------------ message queues */ + +/* Only what gamescope and mangoapp use: msgtyp 0 or > 0, IPC_NOWAIT and IPC_RMID. */ +#define MSG_MAGIC 0x574e4d51u /* "WNMQ" */ +#define MSG_SLOTS 32 + +struct msg_queue { + uint32_t magic; + _Atomic int32_t lock; /* 0 free, else the holder's pid */ + _Atomic int32_t changed; /* bumped on every send and receive; waiters sleep on it */ + uint64_t next_seq; + struct { uint64_t seq; long mtype; size_t len; char data[2048]; } slot[MSG_SLOTS]; /* seq 0: free */ +}; + +static struct msg_queue *msg_map(int id) { + int fd = open_by_id("msg", id); + if (fd < 0) return NULL; + struct msg_queue *q = mmap(NULL, sizeof(*q), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + close(fd); + if (q == MAP_FAILED) return NULL; + if (q->magic == MSG_MAGIC) return q; + munmap(q, sizeof(*q)); + errno = EINVAL; + return NULL; +} + +/* A process killed while holding the lock (Android ends sessions without warning) loses it. */ +static void msg_lock(struct msg_queue *q) { + for (int32_t owner = 0; !atomic_compare_exchange_strong(&q->lock, &owner, getpid()); owner = 0) { + if (kill(owner, 0) != 0 && errno == ESRCH) atomic_compare_exchange_strong(&q->lock, &owner, 0); + else futex(&q->lock, FUTEX_WAIT, owner, &(struct timespec){0, 20000000}); + } +} + +static void msg_unlock(struct msg_queue *q, int changed) { + atomic_store(&q->lock, 0); + futex(&q->lock, FUTEX_WAKE, 1, NULL); + if (changed) { + atomic_fetch_add(&q->changed, 1); + futex(&q->changed, FUTEX_WAKE, INT_MAX, NULL); + } +} + +int msgget(key_t key, int msgflg) { + int created; + int fd = open_object("msg", key, msgflg, &created); + if (fd < 0) return -1; + uint32_t magic = MSG_MAGIC; + if (created && (ftruncate(fd, sizeof(struct msg_queue)) != 0 || pwrite(fd, &magic, sizeof(magic), 0) != sizeof(magic))) { + close(fd); + return -1; + } + int id = object_id(fd); + close(fd); + return id; +} + +int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg) { + struct msg_queue *q = msg_map(msqid); + if (!q) return -1; + if (msgsz > sizeof(q->slot[0].data) || *(const long *)msgp < 1) { munmap(q, sizeof(*q)); errno = EINVAL; return -1; } + for (;;) { + msg_lock(q); + int32_t seen = atomic_load(&q->changed); + for (int i = 0; i < MSG_SLOTS; i++) { + if (q->slot[i].seq) continue; + q->slot[i].mtype = *(const long *)msgp; + q->slot[i].len = msgsz; + memcpy(q->slot[i].data, (const long *)msgp + 1, msgsz); + q->slot[i].seq = ++q->next_seq; + msg_unlock(q, 1); + munmap(q, sizeof(*q)); + return 0; + } + msg_unlock(q, 0); + if ((msgflg & IPC_NOWAIT) || (futex(&q->changed, FUTEX_WAIT, seen, NULL) != 0 && errno == EINTR)) { + munmap(q, sizeof(*q)); + if (msgflg & IPC_NOWAIT) errno = EAGAIN; + return -1; + } + } +} + +ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg) { + if (msgtyp < 0) { errno = EINVAL; return -1; } + struct msg_queue *q = msg_map(msqid); + if (!q) return -1; + for (;;) { + msg_lock(q); + int32_t seen = atomic_load(&q->changed); + int best = -1; + for (int i = 0; i < MSG_SLOTS; i++) + if (q->slot[i].seq && (!msgtyp || q->slot[i].mtype == msgtyp) && (best < 0 || q->slot[i].seq < q->slot[best].seq)) + best = i; + if (best >= 0) { + size_t n = q->slot[best].len < msgsz ? q->slot[best].len : msgsz; + *(long *)msgp = q->slot[best].mtype; + memcpy((long *)msgp + 1, q->slot[best].data, n); + q->slot[best].seq = 0; + msg_unlock(q, 1); + munmap(q, sizeof(*q)); + return (ssize_t)n; + } + msg_unlock(q, 0); + if ((msgflg & IPC_NOWAIT) || (futex(&q->changed, FUTEX_WAIT, seen, NULL) != 0 && errno == EINTR)) { + munmap(q, sizeof(*q)); + if (msgflg & IPC_NOWAIT) errno = ENOMSG; + return -1; + } + } +} + +int msgctl(int msqid, int cmd, struct msqid_ds *buf) { + if (cmd != IPC_RMID) { errno = EINVAL; return -1; } + pthread_mutex_lock(&g_lock); + int rc = unlink_by_id("msg", msqid); + pthread_mutex_unlock(&g_lock); + return rc; +} + /* * fork() carries over only the calling thread, so a lock another thread was holding at that * instant stays held in the child by a thread that is not there to release it. Taking it before diff --git a/tools/linuxfs/preload/tracefs.c b/tools/linuxfs/preload/tracefs.c new file mode 100644 index 000000000..17d45a01a --- /dev/null +++ b/tools/linuxfs/preload/tracefs.c @@ -0,0 +1,101 @@ +/* + * A trace instance for mangoapp where the kernel's tracefs is closed to the app. + * + * Valve's mangoapp (Deck mode's performance overlay) reads the GPU memory total on a Qualcomm + * display driver (msm_dpu / msm_drm) from the gpu_mem_total event, through a tracefs instance of + * its own - and aborts when tracefs_instance_create() fails, taking the whole overlay with it. + * Android mounts tracefs for root and the readtracefs group only, so it always fails here. + * + * The real calls run first. Only when they fail is the instance placed in an empty directory of + * the session's own, and reading its events answers with one gpu_mem_total record carrying the + * total KGSL - the GPU driver of every Adreno under Android - reports to the app. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +#define STANDIN_DIR "/tmp/droiddeck-tracefs" +#define KGSL_TOTAL "/sys/class/kgsl/kgsl/page_alloc" + +struct tracefs_instance; +struct tep_handle; +struct tep_event; + +/* The head of libtraceevent 1.8's struct tep_record (the version tools/mangoapp/packages.txt pins). */ +struct tep_record { + unsigned long long ts, offset; + long long missed_events; + int record_size, size; + void *data; + int cpu, ref_count, locked; + void *priv; +}; + +/* The event as Android kernels declare it (trace/events/gpu_mem.h). */ +static const char gpu_mem_total_format[] = + "name: gpu_mem_total\n" + "ID: 1\n" + "format:\n" + "\tfield:unsigned short common_type;\toffset:0;\tsize:2;\tsigned:0;\n" + "\tfield:unsigned char common_flags;\toffset:2;\tsize:1;\tsigned:0;\n" + "\tfield:unsigned char common_preempt_count;\toffset:3;\tsize:1;\tsigned:0;\n" + "\tfield:int common_pid;\toffset:4;\tsize:4;\tsigned:1;\n" + "\n" + "\tfield:uint32_t gpu_id;\toffset:8;\tsize:4;\tsigned:0;\n" + "\tfield:uint32_t pid;\toffset:12;\tsize:4;\tsigned:0;\n" + "\tfield:uint64_t size;\toffset:16;\tsize:8;\tsigned:0;\n" + "\n" + "print fmt: \"gpu_id=%u pid=%u size=%llu\", REC->gpu_id, REC->pid, REC->size\n"; + +static struct tracefs_instance *standin; + +struct tracefs_instance *tracefs_instance_create(const char *name) { + struct tracefs_instance *(*real)(const char *) = dlsym(RTLD_NEXT, "tracefs_instance_create"); + struct tracefs_instance *instance = real ? real(name) : NULL; + if (instance || !name) return instance; + struct tracefs_instance *(*alloc)(const char *, const char *) = dlsym(RTLD_NEXT, "tracefs_instance_alloc"); + if (!alloc) return NULL; + char path[PATH_MAX]; + mkdir(STANDIN_DIR, 0700); + mkdir(STANDIN_DIR "/instances", 0700); + snprintf(path, sizeof(path), STANDIN_DIR "/instances/%s", name); + mkdir(path, 0700); + return standin = alloc(STANDIN_DIR, name); +} + +struct tep_handle *tracefs_local_events(const char *tracing_dir) { + if (!tracing_dir || strncmp(tracing_dir, STANDIN_DIR "/", sizeof(STANDIN_DIR))) { + struct tep_handle *(*real)(const char *) = dlsym(RTLD_NEXT, "tracefs_local_events"); + return real ? real(tracing_dir) : NULL; + } + struct tep_handle *(*tep_alloc)(void) = dlsym(RTLD_DEFAULT, "tep_alloc"); + int (*parse)(struct tep_handle *, const char *, unsigned long, const char *) = dlsym(RTLD_DEFAULT, "tep_parse_event"); + struct tep_handle *tep = tep_alloc ? tep_alloc() : NULL; + if (tep && parse) parse(tep, gpu_mem_total_format, sizeof(gpu_mem_total_format) - 1, "gpu_mem"); + return tep; +} + +int tracefs_iterate_raw_events(struct tep_handle *tep, struct tracefs_instance *instance, void *cpus, int cpu_size, + int (*callback)(struct tep_event *, struct tep_record *, int, void *), void *context) { + if (!standin || instance != standin) { + int (*real)(struct tep_handle *, struct tracefs_instance *, void *, int, + int (*)(struct tep_event *, struct tep_record *, int, void *), void *) = + dlsym(RTLD_NEXT, "tracefs_iterate_raw_events"); + return real ? real(tep, instance, cpus, cpu_size, callback, context) : -1; + } + struct tep_event *(*find)(struct tep_handle *, int) = dlsym(RTLD_DEFAULT, "tep_find_event"); + struct tep_event *event = find && tep ? find(tep, 1) : NULL; + unsigned long long total; + FILE *f = fopen(KGSL_TOTAL, "r"); + int have = f && fscanf(f, "%llu", &total) == 1; + if (f) fclose(f); + if (!event || !have || !callback) return -1; + struct { uint16_t type; uint8_t flags, preempt; int32_t common_pid; uint32_t gpu_id, pid; uint64_t size; } data = { + .type = 1, .size = total}; + struct tep_record record = {.record_size = sizeof(data), .size = sizeof(data), .data = &data}; + return callback(event, &record, 0, context); +} diff --git a/tools/mangoapp/mangoapp b/tools/mangoapp/mangoapp index 6557ed559..e912be1a8 100644 --- a/tools/mangoapp/mangoapp +++ b/tools/mangoapp/mangoapp @@ -1,4 +1,21 @@ #!/bin/sh # gamescope --mangoapp runs `mangoapp` from PATH; this puts Valve's build beside the libraries the # runtime lacks (fmt 10, spdlog 1.13, glfw, libtracefs, libtraceevent), staged by the app. +# mangoapp reads its window through GLFW's X11 API. GLFW 3.4 takes XDG_SESSION_TYPE=wayland with a +# WAYLAND_DISPLAY as Wayland-only, and unset, libwayland falls back to wayland-0 - DroidDeck's own +# compositor, which shares XDG_RUNTIME_DIR - so both fail; x11 makes GLFW use gamescope's X server. +export XDG_SESSION_TYPE=x11 +# gamescope's reaper restarts mangoapp the moment it exits, with no backoff. One that dies at start +# (a stale session preload without the System V queue, a driver it crashes on) was started about +# once a second for the whole session, each start a full exec under proot - the Steam menu itself +# lagged. More than 5 starts in 60 s leaves the overlay off: this waits instead of exiting, so the +# reaper has nothing to restart until the session ends. +starts=/tmp/droiddeck-mangoapp-starts +now=$(date +%s) +recent=$(awk -v now="$now" '$1 > now - 60' "$starts" 2>/dev/null) +printf '%s\n%s\n' "$recent" "$now" | sed '/^$/d' > "$starts" +if [ "$(wc -l < "$starts")" -gt 5 ]; then + echo "mangoapp: started $(wc -l < "$starts") times in 60 s; the performance overlay stays off for this session" >&2 + exec sleep infinity +fi LD_LIBRARY_PATH=/usr/local/lib/mangoapp${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} exec /usr/local/lib/mangoapp/mangoapp "$@" diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index 64b5857dc..503e4fbed 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -19,3 +19,39 @@ Ported from WinNative (`feature/proot-enhancements`, e7af0f24): workaround is active. - `0003-clone3-flags-read-guard.patch` - a thread created with `clone3` keeps the flags it inherited when `struct clone_args` cannot be read, instead of being tracked as a fork. + +Ported from WinNative (`main`, 53836ca9, "Fix/performance and vac"): + +- `0004-seccomp-filter-by-argument.patch` - the seccomp filter traces `prctl` only for + `PR_SET_DUMPABLE`, `setrlimit` only for `RLIMIT_STACK` and `prlimit64` only when it sets a new + `RLIMIT_STACK`, the only cases proot acts on; every other call runs without a stop. `uname` is + traced by the core only on x86_64, the one arch it rewrites; kompat still adds it for + `--kernel-release`. +- `0005-seccomp-skip-unneeded-sysexit.patch` - a seccomp stop fetches the registers once and + looks the filter flags up by syscall number, instead of a `PTRACE_GETEVENTMSG` and a second + register fetch. The flags table is built from proot's list merged with the enabled extensions' + (kompat and fake_id0 here), so it is exactly what the filter reports. `wait4`/`waitpid` left to + the kernel and `accept`/`accept4` without a sockaddr skip their exit stop, unless an extension + replaced the syscall (kompat turning `accept4` into `accept`). +- `0006-clone3-exit-signal.patch` - `clone3` flags and exit signal are read as the 64-bit fields + of `struct clone_args`, so a `clone3` fork is told apart from a thread when proot decides how the + child is traced. +- `0007-proc-self-thread-group.patch` - tracees track their thread group, `/proc/self` names it + instead of the calling thread, and `/proc/thread-self` resolves to `/proc//task/`. +- `0008-fchmodat2-openat2.patch` - `fchmodat2` paths are translated (honouring + `AT_SYMLINK_NOFOLLOW`), `openat2` answers `ENOSYS` so callers fall back to the translated + `openat`, and syscall numbers past the end of a table are rejected instead of read. +- `0009-tracee-relatives-sweep.patch` - tracees count their children, so a terminating thread + with no children or ptracees no longer walks every tracee; the per-stop memory collector is + emptied instead of freed and reallocated. + +Added by DroidDeck: + +- `0011-kompat-utsname-only.patch` - `--kernel-release` (the guest's `DroidDeck` hostname) loads + kompat, whose filter traps `futex`, `fcntl`, `epoll_pwait`, `pselect6`, `pipe2`, `eventfd2`, + `socket` and more, and which strips `AT_SYSINFO_EHDR` on every `execve`, so glibc runs without + the vDSO. When the virtual release is not older than the real kernel and the hwcap is left + alone, every one of those handlers is a no-op: kompat now traces only `uname`, `sethostname` + and `setdomainname` and leaves the auxv as the kernel wrote it. On an SD 8 Gen 2 guest this + took a futex ping-pong from 467 to 97 us, `epoll_pwait` from 60 to 0.8 us and `fcntl` from + 40-107 to 0.4 us, and `clock_gettime` back to the vDSO. diff --git a/tools/proot/patches/0004-seccomp-filter-by-argument.patch b/tools/proot/patches/0004-seccomp-filter-by-argument.patch new file mode 100644 index 000000000..302a97eba --- /dev/null +++ b/tools/proot/patches/0004-seccomp-filter-by-argument.patch @@ -0,0 +1,209 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: maxjivi05 +Date: Sun, 27 Sep 2026 07:56:54 -0400 +Subject: [PATCH] seccomp: trace prctl, setrlimit and prlimit64 only for the + arguments PRoot acts on + +--- + src/syscall/seccomp.c | 106 +++++++++++++++++++++++++++++++++++------- + 1 file changed, 89 insertions(+), 17 deletions(-) + +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 6d1ba47..8557d4a 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -37,6 +37,7 @@ + #include /* memcpy(3), */ + #include /* offsetof(3), */ + #include /* uint*_t, UINT*_MAX, */ ++#include + #include /* assert(3), */ + + #include "syscall/seccomp.h" +@@ -121,13 +122,86 @@ static int add_trace_syscall(struct sock_fprog *program, word_t syscall, int fla + return 0; + } + ++static int add_trace_syscall_if_arg(struct sock_fprog *program, word_t syscall, ++ size_t arg, uint32_t value, int flag) ++{ ++ const size_t arg_offset = offsetof(struct seccomp_data, args[arg]); ++ ++ #define LENGTH_TRACE_SYSCALL_IF_ARG 5 ++ struct sock_filter statements[LENGTH_TRACE_SYSCALL_IF_ARG] = { ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, syscall, 0, 4), ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, arg_offset), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, value, 0, 1), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRACE + flag), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW) ++ }; ++ ++ DEBUG_FILTER("FILTER: trace if syscall == %ld and arg %zd == %u\n", ++ syscall, arg, value); ++ ++ return add_statements(program, LENGTH_TRACE_SYSCALL_IF_ARG, statements); ++} ++ ++static int add_trace_prlimit_stack(struct sock_fprog *program, word_t syscall, int flag) ++{ ++ const size_t resource_offset = offsetof(struct seccomp_data, args[1]); ++ const size_t limit_offset = offsetof(struct seccomp_data, args[2]); ++ ++ #define LENGTH_TRACE_PRLIMIT_STACK 9 ++ struct sock_filter statements[LENGTH_TRACE_PRLIMIT_STACK] = { ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, syscall, 0, 8), ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, resource_offset), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, RLIMIT_STACK, 0, 5), ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, limit_offset), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, 0, 0, 2), ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, limit_offset + sizeof(uint32_t)), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, 0, 1, 0), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRACE + flag), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW) ++ }; ++ ++ DEBUG_FILTER("FILTER: trace if syscall == %ld sets RLIMIT_STACK\n", syscall); ++ ++ return add_statements(program, LENGTH_TRACE_PRLIMIT_STACK, statements); ++} ++ ++static size_t trace_length(Sysnum sysnum) ++{ ++ switch (sysnum) { ++ case PR_prctl: ++ case PR_setrlimit: ++ return LENGTH_TRACE_SYSCALL_IF_ARG; ++ case PR_prlimit64: ++ return LENGTH_TRACE_PRLIMIT_STACK; ++ default: ++ return LENGTH_TRACE_SYSCALL; ++ } ++} ++ ++static int add_trace(struct sock_fprog *program, Sysnum sysnum, word_t syscall, int flag) ++{ ++ if (syscall > UINT32_MAX) ++ return -ERANGE; ++ ++ switch (sysnum) { ++ case PR_prctl: ++ return add_trace_syscall_if_arg(program, syscall, 0, PR_SET_DUMPABLE, flag); ++ case PR_setrlimit: ++ return add_trace_syscall_if_arg(program, syscall, 0, RLIMIT_STACK, flag); ++ case PR_prlimit64: ++ return add_trace_prlimit_stack(program, syscall, flag); ++ default: ++ return add_trace_syscall(program, syscall, flag); ++ } ++} ++ + /** + * Append to @program->filter the statements that allow anything (if +- * unfiltered). Note that @nb_traced_syscalls is used to make a +- * sanity check. This function returns -errno if an error occurred, ++ * unfiltered). Note that @section_length is used to make a sanity ++ * check. This function returns -errno if an error occurred, + * otherwise 0. + */ +-static int end_arch_section(struct sock_fprog *program, size_t nb_traced_syscalls) ++static int end_arch_section(struct sock_fprog *program, size_t section_length) + { + int status; + +@@ -143,8 +217,7 @@ static int end_arch_section(struct sock_fprog *program, size_t nb_traced_syscall + return status; + + /* Sanity check, see start_arch_section(). */ +- if ( talloc_array_length(program->filter) - program->len +- != LENGTH_END_SECTION + nb_traced_syscalls * LENGTH_TRACE_SYSCALL) ++ if (talloc_array_length(program->filter) - program->len != section_length) + return -ERANGE; + + return 0; +@@ -152,16 +225,14 @@ static int end_arch_section(struct sock_fprog *program, size_t nb_traced_syscall + + /** + * Append to @program->filter the statements that check the current +- * @architecture. Note that @nb_traced_syscalls is used to make a ++ * @architecture. Note that @section_length is used to make a + * sanity check. This function returns -errno if an error occurred, + * otherwise 0. + */ +-static int start_arch_section(struct sock_fprog *program, uint32_t arch, size_t nb_traced_syscalls) ++static int start_arch_section(struct sock_fprog *program, uint32_t arch, size_t section_length) + { + const size_t arch_offset = offsetof(struct seccomp_data, arch); + const size_t syscall_offset = offsetof(struct seccomp_data, nr); +- const size_t section_length = LENGTH_END_SECTION + +- nb_traced_syscalls * LENGTH_TRACE_SYSCALL; + int status; + + /* Sanity checks. */ +@@ -191,7 +262,7 @@ static int start_arch_section(struct sock_fprog *program, uint32_t arch, size_t + }; + + DEBUG_FILTER("FILTER: if arch == %ld, up to %zdth statement\n", +- arch, nb_traced_syscalls); ++ arch, section_length); + + status = add_statements(program, LENGTH_START_SECTION, statements); + if (status < 0) +@@ -256,7 +327,7 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + size_t nb_archs = sizeof(seccomp_archs) / sizeof(SeccompArch); + + struct sock_fprog program = { .len = 0, .filter = NULL }; +- size_t nb_traced_syscalls; ++ size_t section_length; + size_t i, j, k; + int status; + +@@ -268,19 +339,18 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + for (i = 0; i < nb_archs; i++) { + word_t syscall; + +- nb_traced_syscalls = 0; ++ section_length = LENGTH_END_SECTION; + +- /* Pre-compute the number of traced syscalls for this architecture. */ + for (j = 0; j < seccomp_archs[i].nb_abis; j++) { + for (k = 0; sysnums[k].value != PR_void; k++) { + syscall = detranslate_sysnum(seccomp_archs[i].abis[j], sysnums[k].value); + if (syscall != SYSCALL_AVOIDER) +- nb_traced_syscalls++; ++ section_length += trace_length(sysnums[k].value); + } + } + + /* Filter: if handled architecture */ +- status = start_arch_section(&program, seccomp_archs[i].value, nb_traced_syscalls); ++ status = start_arch_section(&program, seccomp_archs[i].value, section_length); + if (status < 0) + goto end; + +@@ -292,14 +362,14 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + continue; + + /* Filter: trace if handled syscall */ +- status = add_trace_syscall(&program, syscall, sysnums[k].flags); ++ status = add_trace(&program, sysnums[k].value, syscall, sysnums[k].flags); + if (status < 0) + goto end; + } + } + + /* Filter: allow untraced syscalls for this architecture */ +- status = end_arch_section(&program, nb_traced_syscalls); ++ status = end_arch_section(&program, section_length); + if (status < 0) + goto end; + } +@@ -411,7 +481,9 @@ static FilteredSysnum proot_sysnums[] = { + { PR_truncate64, 0 }, + { PR_umount, 0 }, + { PR_umount2, 0 }, ++#if defined(ARCH_X86_64) + { PR_uname, FILTER_SYSEXIT }, ++#endif + { PR_unlink, 0 }, + { PR_unlinkat, 0 }, + { PR_uselib, 0 }, diff --git a/tools/proot/patches/0005-seccomp-skip-unneeded-sysexit.patch b/tools/proot/patches/0005-seccomp-skip-unneeded-sysexit.patch new file mode 100644 index 000000000..9e4c0bf21 --- /dev/null +++ b/tools/proot/patches/0005-seccomp-skip-unneeded-sysexit.patch @@ -0,0 +1,242 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: maxjivi05 +Date: Sun, 27 Sep 2026 07:57:46 -0400 +Subject: [PATCH] seccomp: answer a stop's flags from its registers and skip + the sysexit stop when nothing is left for it + +--- + src/syscall/enter.c | 3 +++ + src/syscall/seccomp.c | 51 +++++++++++++++++++++++++++++++++++++------ + src/syscall/seccomp.h | 1 + + src/syscall/syscall.c | 17 ++++++++++++--- + src/tracee/event.c | 14 +++++++----- + src/tracee/tracee.h | 2 ++ + 6 files changed, 73 insertions(+), 15 deletions(-) + +diff --git a/src/syscall/enter.c b/src/syscall/enter.c +index 8c3eb2e..b84fdc3 100644 +--- a/src/syscall/enter.c ++++ b/src/syscall/enter.c +@@ -149,6 +149,8 @@ int translate_syscall_enter(Tracee *tracee) + case PR_wait4: + case PR_waitpid: + status = translate_wait_enter(tracee); ++ tracee->sysexit_unneeded = status >= 0 ++ && tracee->as_ptracer.waits_in == WAITS_IN_KERNEL; + break; + + case PR_brk: +@@ -271,6 +273,7 @@ int translate_syscall_enter(Tracee *tracee) + case PR_accept4: + /* Nothing special to do if no sockaddr was specified. */ + if (peek_reg(tracee, ORIGINAL, SYSARG_2) == 0) { ++ tracee->sysexit_unneeded = true; + status = 0; + break; + } +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 8557d4a..cec4330 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -37,6 +37,7 @@ + #include /* memcpy(3), */ + #include /* offsetof(3), */ + #include /* uint*_t, UINT*_MAX, */ ++#include + #include + #include /* assert(3), */ + +@@ -542,6 +543,9 @@ static int merge_filtered_sysnums(TALLOC_CTX *context, FilteredSysnum **sysnums, + return 0; + } + ++static int collect_filtered_sysnums(TALLOC_CTX *context, const Tracee *tracee, ++ FilteredSysnum **filtered_sysnums); ++ + /** + * Tell the kernel to trace only syscalls handled by PRoot and its + * extensions. This filter will be enabled for the given @tracee and +@@ -551,14 +555,30 @@ static int merge_filtered_sysnums(TALLOC_CTX *context, FilteredSysnum **sysnums, + int enable_syscall_filtering(const Tracee *tracee) + { + FilteredSysnum *filtered_sysnums = NULL; +- Extension *extension; + int status; + + assert(tracee != NULL && tracee->ctx != NULL); + ++ status = collect_filtered_sysnums(tracee->ctx, tracee, &filtered_sysnums); ++ if (status < 0) ++ return status; ++ ++ status = set_seccomp_filters(filtered_sysnums); ++ if (status < 0) ++ return status; ++ ++ return 0; ++} ++ ++static int collect_filtered_sysnums(TALLOC_CTX *context, const Tracee *tracee, ++ FilteredSysnum **filtered_sysnums) ++{ ++ Extension *extension; ++ int status; ++ + /* Add the sysnums required by PRoot to the list of filtered + * sysnums. TODO: only if path translation is required. */ +- status = merge_filtered_sysnums(tracee->ctx, &filtered_sysnums, proot_sysnums); ++ status = merge_filtered_sysnums(context, filtered_sysnums, proot_sysnums); + if (status < 0) + return status; + +@@ -569,20 +589,37 @@ int enable_syscall_filtering(const Tracee *tracee) + if (extension->filtered_sysnums == NULL) + continue; + +- status = merge_filtered_sysnums(tracee->ctx, &filtered_sysnums, ++ status = merge_filtered_sysnums(context, filtered_sysnums, + extension->filtered_sysnums); + if (status < 0) + return status; + } + } + +- status = set_seccomp_filters(filtered_sysnums); +- if (status < 0) +- return status; +- + return 0; + } + ++word_t filtered_sysnum_flags(const Tracee *tracee, Sysnum sysnum) ++{ ++ static word_t flags[PR_NB_SYSNUM]; ++ static bool initialized = false; ++ FilteredSysnum *filtered_sysnums = NULL; ++ size_t i; ++ ++ if (!initialized) { ++ if (collect_filtered_sysnums(NULL, tracee, &filtered_sysnums) < 0) { ++ TALLOC_FREE(filtered_sysnums); ++ return FILTER_SYSEXIT; ++ } ++ for (i = 0; filtered_sysnums[i].value != PR_void; i++) ++ flags[filtered_sysnums[i].value] |= filtered_sysnums[i].flags; ++ TALLOC_FREE(filtered_sysnums); ++ initialized = true; ++ } ++ ++ return sysnum < PR_NB_SYSNUM ? flags[sysnum] : FILTER_SYSEXIT; ++} ++ + #else + + #include "tracee/tracee.h" +diff --git a/src/syscall/seccomp.h b/src/syscall/seccomp.h +index 3d5ba40..67053b5 100644 +--- a/src/syscall/seccomp.h ++++ b/src/syscall/seccomp.h +@@ -44,5 +44,6 @@ typedef struct { + #define FILTER_SYSEXIT 0x1 + + extern int enable_syscall_filtering(const Tracee *tracee); ++extern word_t filtered_sysnum_flags(const Tracee *tracee, Sysnum sysnum); + + #endif /* SECCOMP_H */ +diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c +index 0761a26..9ab063c 100644 +--- a/src/syscall/syscall.c ++++ b/src/syscall/syscall.c +@@ -107,9 +107,13 @@ void translate_syscall(Tracee *tracee) + + assert(tracee->exe != NULL); + +- status = fetch_regs(tracee); +- if (status < 0) +- return; ++ if (tracee->regs_fresh) ++ tracee->regs_fresh = false; ++ else { ++ status = fetch_regs(tracee); ++ if (status < 0) ++ return; ++ } + + int suppressed_syscall_status = 0; + +@@ -136,9 +140,16 @@ void translate_syscall(Tracee *tracee) + * requested by the tracee, it is not a syscall + * chained by PRoot. */ + if (tracee->chain.syscalls == NULL) { ++ tracee->sysexit_unneeded = false; + save_current_regs(tracee, ORIGINAL); + status = translate_syscall_enter(tracee); + save_current_regs(tracee, MODIFIED); ++ ++ if (status >= 0 && tracee->sysexit_unneeded ++ && tracee->seccomp == ENABLED && !tracee->sysexit_pending ++ && !tracee->restore_original_regs_after_seccomp_event ++ && get_sysnum(tracee, MODIFIED) == get_sysnum(tracee, ORIGINAL)) ++ tracee->restart_how = PTRACE_CONT; + } + else { + if (tracee->chain.sysnum_workaround_state != SYSNUM_WORKAROUND_PROCESS_REPLACED_CALL) { +diff --git a/src/tracee/event.c b/src/tracee/event.c +index ec79ef3..41afacd 100644 +--- a/src/tracee/event.c ++++ b/src/tracee/event.c +@@ -38,6 +38,7 @@ + #include "tracee/event.h" + #include "tracee/seccomp.h" + #include "tracee/mem.h" ++#include "tracee/reg.h" + #include "cli/note.h" + #include "path/path.h" + #include "path/binding.h" +@@ -576,18 +577,20 @@ int handle_tracee_event(Tracee *tracee, int tracee_status) + if (tracee->seccomp != ENABLED) + break; + +- status = ptrace(PTRACE_GETEVENTMSG, tracee->pid, NULL, &flags); ++ status = fetch_regs(tracee); + if (status < 0) + break; ++ tracee->regs_fresh = true; ++ flags = filtered_sysnum_flags(tracee, get_sysnum(tracee, CURRENT)); + + /* Use the common ptrace flow when + * sysexit has to be handled. */ + if ((flags & FILTER_SYSEXIT) != 0 || sysexit_necessary) { +- if (seccomp_after_ptrace_enter) { +- tracee->restart_how = PTRACE_SYSCALL; +- translate_syscall(tracee); +- } + tracee->restart_how = PTRACE_SYSCALL; ++ if (seccomp_after_ptrace_enter) ++ translate_syscall(tracee); ++ else ++ tracee->regs_fresh = false; + break; + } + +@@ -720,6 +723,7 @@ bool restart_tracee(Tracee *tracee, int signal) + + tracee->last_restart_how = tracee->restart_how; + tracee->restart_how = 0; ++ tracee->regs_fresh = false; + tracee->running = true; + + return true; +diff --git a/src/tracee/tracee.h b/src/tracee/tracee.h +index e4be3ff..c444a50 100644 +--- a/src/tracee/tracee.h ++++ b/src/tracee/tracee.h +@@ -161,6 +161,8 @@ typedef struct tracee { + bool _regs_were_changed; + bool restore_original_regs; + bool restore_original_regs_after_seccomp_event; ++ bool regs_fresh; ++ bool sysexit_unneeded; + + /* State for the special handling of SIGSTOP. */ + enum { diff --git a/tools/proot/patches/0006-clone3-exit-signal.patch b/tools/proot/patches/0006-clone3-exit-signal.patch new file mode 100644 index 000000000..8424a63dd --- /dev/null +++ b/tools/proot/patches/0006-clone3-exit-signal.patch @@ -0,0 +1,31 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: maxjivi05 +Date: Sun, 27 Sep 2026 07:58:07 -0400 +Subject: [PATCH] tracee: take a clone3 child's exit signal from struct + clone_args + +--- + src/tracee/tracee.c | 10 +++++++--- + 1 file changed, 7 insertions(+), 3 deletions(-) + +diff --git a/src/tracee/tracee.c b/src/tracee/tracee.c +index 7c0c50c..25eeb57 100644 +--- a/src/tracee/tracee.c ++++ b/src/tracee/tracee.c +@@ -424,9 +424,13 @@ int new_child(Tracee *parent, word_t clone_flags) + if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone) + clone_flags = peek_reg(parent, CURRENT, SYSARG_1); + else if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone3) { +- word_t flags = peek_word(parent, peek_reg(parent, CURRENT, SYSARG_1)); +- if (errno == 0) +- clone_flags = flags; ++ word_t args = peek_reg(parent, CURRENT, SYSARG_1); ++ word_t flags = peek_uint64(parent, args); ++ if (errno == 0) { ++ word_t exit_signal = peek_uint64(parent, args + 4 * sizeof(uint64_t)); ++ if (errno == 0) ++ clone_flags = (flags & ~(word_t) 0xFF) | (exit_signal & 0xFF); ++ } + } + + /* Get the pid of the parent's new child. */ diff --git a/tools/proot/patches/0007-proc-self-thread-group.patch b/tools/proot/patches/0007-proc-self-thread-group.patch new file mode 100644 index 000000000..03c3dbfb5 --- /dev/null +++ b/tools/proot/patches/0007-proc-self-thread-group.patch @@ -0,0 +1,72 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: maxjivi05 +Date: Sun, 27 Sep 2026 07:58:16 -0400 +Subject: [PATCH] proc: name a thread's /proc/self by its thread group and + resolve /proc/thread-self + +--- + src/path/proc.c | 7 +++++-- + src/tracee/tracee.c | 3 +++ + src/tracee/tracee.h | 1 + + 3 files changed, 9 insertions(+), 2 deletions(-) + +diff --git a/src/path/proc.c b/src/path/proc.c +index 1c816df..62650f6 100644 +--- a/src/path/proc.c ++++ b/src/path/proc.c +@@ -58,10 +58,13 @@ Action readlink_proc(const Tracee *tracee, char result[PATH_MAX], + switch (comparison) { + case PATHS_ARE_EQUAL: + /* Substitute "/proc/self" with "/proc/". */ +- if (strcmp(component, "self") != 0) ++ if (strcmp(component, "self") == 0) ++ status = snprintf(result, PATH_MAX, "/proc/%d", tracee->tgid); ++ else if (strcmp(component, "thread-self") == 0) ++ status = snprintf(result, PATH_MAX, "/proc/%d/task/%d", tracee->tgid, tracee->pid); ++ else + return DEFAULT; + +- status = snprintf(result, PATH_MAX, "/proc/%d", tracee->pid); + if (status < 0 || status >= PATH_MAX) + return -EPERM; + +diff --git a/src/tracee/tracee.c b/src/tracee/tracee.c +index 25eeb57..d6e414d 100644 +--- a/src/tracee/tracee.c ++++ b/src/tracee/tracee.c +@@ -234,6 +234,7 @@ static Tracee *new_tracee(pid_t pid) + talloc_set_destructor(tracee, remove_tracee); + + tracee->pid = pid; ++ tracee->tgid = pid; + tracee->vpid = next_vpid++; + + LIST_INSERT_HEAD(&tracees, tracee, link); +@@ -355,6 +356,7 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create) + void set_tracee_pid(Tracee *tracee, pid_t pid) + { + tracee->pid = pid; ++ tracee->tgid = pid; + LIST_REMOVE(tracee, bucket_link); + LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link); + } +@@ -511,6 +513,7 @@ int new_child(Tracee *parent, word_t clone_flags) + * specificity (ie. when a thread calls execve(2), its pid + * gets replaced by the pid of its thread group leader). */ + child->clone = ((clone_flags & CLONE_THREAD) != 0); ++ child->tgid = child->clone ? parent->tgid : child->pid; + + /* Depending on how the new process is created, it may be + * automatically traced by the parent's tracer. */ +diff --git a/src/tracee/tracee.h b/src/tracee/tracee.h +index c444a50..527f428 100644 +--- a/src/tracee/tracee.h ++++ b/src/tracee/tracee.h +@@ -83,6 +83,7 @@ typedef struct tracee { + + /* Process identifier. */ + pid_t pid; ++ pid_t tgid; + + /* Unique tracee identifier. */ + uint64_t vpid; diff --git a/tools/proot/patches/0008-fchmodat2-openat2.patch b/tools/proot/patches/0008-fchmodat2-openat2.patch new file mode 100644 index 000000000..e900561bb --- /dev/null +++ b/tools/proot/patches/0008-fchmodat2-openat2.patch @@ -0,0 +1,120 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: maxjivi05 +Date: Sun, 27 Sep 2026 07:58:30 -0400 +Subject: [PATCH] syscall: translate fchmodat2, send openat2 callers to openat, + bound sysnum lookups by the table length + +--- + src/syscall/enter.c | 18 ++++++++++++++++++ + src/syscall/seccomp.c | 2 ++ + src/syscall/sysnum.c | 2 +- + src/syscall/sysnums-arm.h | 2 ++ + src/syscall/sysnums-arm64.h | 2 ++ + src/syscall/sysnums.list | 2 ++ + 6 files changed, 27 insertions(+), 1 deletion(-) + +diff --git a/src/syscall/enter.c b/src/syscall/enter.c +index b84fdc3..1d3b3fd 100644 +--- a/src/syscall/enter.c ++++ b/src/syscall/enter.c +@@ -447,6 +447,24 @@ int translate_syscall_enter(Tracee *tracee) + status = translate_path2(tracee, dirfd, path, SYSARG_2, REGULAR); + break; + ++ case PR_fchmodat2: ++ dirfd = peek_reg(tracee, CURRENT, SYSARG_1); ++ flags = peek_reg(tracee, CURRENT, SYSARG_4); ++ ++ status = get_sysarg_path(tracee, path, SYSARG_2); ++ if (status < 0) ++ break; ++ ++ if ((flags & AT_SYMLINK_NOFOLLOW) != 0) ++ status = translate_path2(tracee, dirfd, path, SYSARG_2, SYMLINK); ++ else ++ status = translate_path2(tracee, dirfd, path, SYSARG_2, REGULAR); ++ break; ++ ++ case PR_openat2: ++ status = -ENOSYS; ++ break; ++ + case PR_inotify_add_watch: + flags = peek_reg(tracee, CURRENT, SYSARG_3); + +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index cec4330..0b381bb 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -419,6 +419,7 @@ static FilteredSysnum proot_sysnums[] = { + { PR_faccessat2, FILTER_SYSEXIT }, + { PR_fchdir, FILTER_SYSEXIT }, + { PR_fchmodat, 0 }, ++ { PR_fchmodat2, 0 }, + { PR_fchownat, 0 }, + { PR_fstatat64, 0 }, + { PR_futimesat, 0 }, +@@ -455,6 +456,7 @@ static FilteredSysnum proot_sysnums[] = { + { PR_oldstat, 0 }, + { PR_open, 0 }, + { PR_openat, 0 }, ++ { PR_openat2, 0 }, + { PR_pivot_root, 0 }, + { PR_prctl, 0 }, + { PR_prlimit64, FILTER_SYSEXIT }, +diff --git a/src/syscall/sysnum.c b/src/syscall/sysnum.c +index b5440a6..785593e 100644 +--- a/src/syscall/sysnum.c ++++ b/src/syscall/sysnum.c +@@ -92,7 +92,7 @@ static Sysnum translate_sysnum(Abi abi, word_t sysnum) + index = sysnum - sysnums.offset; + + /* Sanity checks. */ +- if (index > sysnums.length) ++ if (index >= sysnums.length) + return PR_void; + + return sysnums.table[index]; +diff --git a/src/syscall/sysnums-arm.h b/src/syscall/sysnums-arm.h +index 0503fdd..e3f6f6b 100644 +--- a/src/syscall/sysnums-arm.h ++++ b/src/syscall/sysnums-arm.h +@@ -358,4 +358,6 @@ static const Sysnum sysnums_arm[] = { + [ 397 ] = PR_statx, + [ 439 ] = PR_faccessat2, + [ 435 ] = PR_clone3, ++ [ 437 ] = PR_openat2, ++ [ 452 ] = PR_fchmodat2, + }; +diff --git a/src/syscall/sysnums-arm64.h b/src/syscall/sysnums-arm64.h +index 751382f..b257339 100644 +--- a/src/syscall/sysnums-arm64.h ++++ b/src/syscall/sysnums-arm64.h +@@ -279,5 +279,7 @@ static const Sysnum sysnums_arm64[] = { + [ 290 ] = PR_pkey_free, + [ 291 ] = PR_statx, + [ 435 ] = PR_clone3, ++ [ 437 ] = PR_openat2, + [ 439 ] = PR_faccessat2, ++ [ 452 ] = PR_fchmodat2, + }; +diff --git a/src/syscall/sysnums.list b/src/syscall/sysnums.list +index 8170b6e..c221ddd 100644 +--- a/src/syscall/sysnums.list ++++ b/src/syscall/sysnums.list +@@ -72,6 +72,7 @@ SYSNUM(fanotify_mark) + SYSNUM(fchdir) + SYSNUM(fchmod) + SYSNUM(fchmodat) ++SYSNUM(fchmodat2) + SYSNUM(fchown) + SYSNUM(fchown32) + SYSNUM(fchownat) +@@ -221,6 +222,7 @@ SYSNUM(olduname) + SYSNUM(open) + SYSNUM(open_by_handle_at) + SYSNUM(openat) ++SYSNUM(openat2) + SYSNUM(pause) + SYSNUM(pciconfig_iobase) + SYSNUM(pciconfig_read) diff --git a/tools/proot/patches/0009-tracee-relatives-sweep.patch b/tools/proot/patches/0009-tracee-relatives-sweep.patch new file mode 100644 index 000000000..6aea9411e --- /dev/null +++ b/tools/proot/patches/0009-tracee-relatives-sweep.patch @@ -0,0 +1,83 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: maxjivi05 +Date: Sun, 27 Sep 2026 07:58:52 -0400 +Subject: [PATCH] tracee: sweep relatives only for tracees that have some, + reuse the memory collector + +--- + src/path/path.h | 2 +- + src/tracee/tracee.c | 16 ++++++++++------ + src/tracee/tracee.h | 1 + + 3 files changed, 12 insertions(+), 7 deletions(-) + +diff --git a/src/path/path.h b/src/path/path.h +index d455894..7d0e68d 100644 +--- a/src/path/path.h ++++ b/src/path/path.h +@@ -48,9 +48,9 @@ typedef enum { + + /* Path with cached attributes. */ + typedef struct { +- char path[PATH_MAX]; + size_t length; + Side side; ++ char path[PATH_MAX]; + } Path; + + /* Path ending type. */ +diff --git a/src/tracee/tracee.c b/src/tracee/tracee.c +index d6e414d..8bb2929 100644 +--- a/src/tracee/tracee.c ++++ b/src/tracee/tracee.c +@@ -105,12 +105,16 @@ static int remove_tracee(Tracee *tracee) + * span. */ + talloc_report_depth_cb(tracee->life_context, 0, 100, clean_life_span_object, tracee); + +- /* This could be optimize by using a dedicated list of +- * children and ptracees. */ ++ if (tracee->parent != NULL) ++ tracee->parent->nb_children--; ++ ++ if (tracee->nb_children != 0 || tracee->as_ptracer.nb_ptracees != 0) + LIST_FOREACH(relative, &tracees, link) { + /* Its children are now orphan. */ +- if (relative->parent == tracee) ++ if (relative->parent == tracee) { + relative->parent = NULL; ++ tracee->nb_children--; ++ } + + /* Its tracees are now free. */ + if (relative->as_ptracee.ptracer == tracee) { +@@ -342,9 +346,7 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create) + + LIST_FOREACH(tracee, bucket_of(pid), bucket_link) { + if (tracee->pid == pid) { +- /* Flush then allocate a new memory collector. */ +- TALLOC_FREE(tracee->ctx); +- tracee->ctx = talloc_new(tracee); ++ talloc_free_children(tracee->ctx); + + return tracee; + } +@@ -506,6 +508,8 @@ int new_child(Tracee *parent, word_t clone_flags) + child->parent = parent->parent; + else + child->parent = parent; ++ if (child->parent != NULL) ++ child->parent->nb_children++; + + /* Remember if this child belongs to the same thread group as + * its parent. This is currently useful for ptrace emulation +diff --git a/src/tracee/tracee.h b/src/tracee/tracee.h +index 527f428..1fca85f 100644 +--- a/src/tracee/tracee.h ++++ b/src/tracee/tracee.h +@@ -101,6 +101,7 @@ typedef struct tracee { + + /* Parent of this tracee, NULL if none. */ + struct tracee *parent; ++ size_t nb_children; + + /* Is it a "clone", i.e has the same parent as its creator. */ + bool clone; diff --git a/tools/proot/patches/0011-kompat-utsname-only.patch b/tools/proot/patches/0011-kompat-utsname-only.patch new file mode 100644 index 000000000..9c1949cce --- /dev/null +++ b/tools/proot/patches/0011-kompat-utsname-only.patch @@ -0,0 +1,64 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 00:00:00 +0000 +Subject: [PATCH] kompat: only trace the utsname calls when no older kernel is + emulated + +--- + src/extension/kompat/kompat.c | 29 +++++++++++++++++++++++++++-- + 1 file changed, 27 insertions(+), 2 deletions(-) + +diff --git a/src/extension/kompat/kompat.c b/src/extension/kompat/kompat.c +index 5c39942..4cb7dec 100644 +--- a/src/extension/kompat/kompat.c ++++ b/src/extension/kompat/kompat.c +@@ -985,6 +985,29 @@ static FilteredSysnum filtered_sysnums[] = { + FILTERED_SYSNUM_END, + }; + ++/* When the virtual kernel is not older than the actual one and the ++ * hwcap is left alone, the only thing left to emulate is the utsname ++ * itself: every other handler above returns without a change. */ ++static FilteredSysnum utsname_only_sysnums[] = { ++ { PR_setdomainname, FILTER_SYSEXIT }, ++ { PR_sethostname, FILTER_SYSEXIT }, ++ { PR_uname, FILTER_SYSEXIT }, ++ FILTERED_SYSNUM_END, ++}; ++ ++/** ++ * Return whether @config only renames the kernel identity (nodename, ++ * domainname, version string) without emulating an older kernel or ++ * another hwcap, in which case futex, fcntl, epoll_pwait, ... don't ++ * have to stop in the tracer and the vDSO can be left in place. ++ */ ++static bool utsname_only(const Config *config) ++{ ++ return config->actual_release != 0 ++ && config->virtual_release >= config->actual_release ++ && config->hwcap == (word_t) -1; ++} ++ + /** + * Handler for this @extension. It is triggered each time an @event + * occured. See ExtensionEvent for the meaning of @data1 and @data2. +@@ -1007,7 +1030,9 @@ int kompat_callback(Extension *extension, ExtensionEvent event, + if (status < 0) + return -1; + +- extension->filtered_sysnums = filtered_sysnums; ++ extension->filtered_sysnums = utsname_only(config) ++ ? utsname_only_sysnums ++ : filtered_sysnums; + return 0; + } + +@@ -1038,7 +1063,7 @@ int kompat_callback(Extension *extension, ExtensionEvent event, + + /* Note: this can be done only before PRoot pushes the + * load script into tracee's stack. */ +- if ((int) result >= 0 && sysnum == PR_execve) ++ if ((int) result >= 0 && sysnum == PR_execve && !utsname_only(config)) + adjust_elf_auxv(tracee, config); + return 0; + } diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py new file mode 100644 index 000000000..8c37d1123 --- /dev/null +++ b/tools/tests/test_game_environment.py @@ -0,0 +1,92 @@ +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +MODULE = runpy.run_path(str(BIN / "bannerlator-game-env")) +COMPAT = runpy.run_path(str(BIN / "bannerlator-steam-compat")) + + +class GameEnvironmentTest(unittest.TestCase): + def test_profile_precedence_and_unset(self): + env = {"KEEP": "inherited", "REMOVE": "inherited", "CUSTOM": "launch option"} + config = {"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"REMOVE": None, "CUSTOM": "game", "EMPTY": ""}}} + self.assertEqual(MODULE["apply_config"](env, config, "42"), {"KEEP": "inherited", "CUSTOM": "game", "EMPTY": ""}) + self.assertEqual(env["REMOVE"], "inherited") + self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared") + + def test_invalid_configuration_is_atomic(self): + env = {"ORIGINAL": "unchanged"} + for entries in ({"A": "ok", "BAD=KEY": "x"}, {"A": "bad\0value"}, {"A": 1}): + with self.assertRaises(ValueError): + MODULE["apply_config"](env, {"version": 1, "shared": entries}, "42") + self.assertEqual(env, {"ORIGINAL": "unchanged"}) + + def test_game_ids_and_probes(self): + for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"): + self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix})) + self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42") + self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295") + + def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + config = home / ".config/droiddeck/game-environment.json" + config.parent.mkdir(parents=True) + probe = home / "fake-proton" + probe.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ.get('CUSTOM'), sys.argv[1:]]))\n") + probe.chmod(0o755) + env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"} + for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"): + config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}})) + result = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True) + self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]]) + self.assertFalse((home / "injected").exists()) + for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")): + output = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True) + self.assertEqual(json.loads(output)[0], "original") + config.write_text("{broken") + result = subprocess.run([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True) + self.assertEqual(json.loads(result.stdout)[0], "original") + + def test_generated_valve_and_third_party_launchers_apply_configuration(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + steam = home / "Steam" + tools = steam / "compatibilitytools.d" + depot = steam / "steamapps/common" / COMPAT["SOURCES"][0] + (depot / "files/bin-arm64").mkdir(parents=True) + extra = tools / "custom-proton" + extra.mkdir(parents=True) + for base in (depot, extra): + proton = base / "proton" + proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n") + proton.chmod(0o755) + (extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }') + COMPAT["build_tool"](str(tools / COMPAT["TOOL"])) + COMPAT["adopt_extras"](str(tools)) + config = home / ".config/droiddeck/game-environment.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}})) + wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"]) + for wrapper in wrappers: + wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/bannerlator-game-env", str(BIN / "bannerlator-game-env"))) + result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"], + env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)}, + text=True, capture_output=True) + self.assertEqual(result.returncode, 7, result.stderr) + self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]]) + + def test_both_proton_wrappers_call_environment_launcher(self): + for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"): + subprocess.run(["bash", "-n"], input=script, text=True, check=True) + self.assertIn('exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env', script) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py new file mode 100644 index 000000000..26220319d --- /dev/null +++ b/tools/tests/test_steam_games.py @@ -0,0 +1,231 @@ +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' + + +def load(name): + loader = importlib.machinery.SourceFileLoader(name, str(BIN / name)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +imports = load('bannerlator-steam-games') +shortcuts = load('bannerlator-steam-shortcuts') + + +class ImportsTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.steam = self.root / 'Steam' + self.acct = self.steam / 'userdata/123' + self.folder = self.root / 'Games/Example' + self.folder.mkdir(parents=True) + self.exe = self.folder / 'Example.exe' + self.exe.write_bytes(b'game') + self.game = dict(name='Example', exe=str(self.exe), folder=str(self.folder), + dir=str(self.folder), appid=0x87654321) + + def snapshot(self, owned=True, account='123'): + imports.save_json(self.acct / 'config' / imports.STATE, + dict(account=account, owned={'42': owned}, + candidates={str(self.folder): 42}, + sources={str(self.folder): imports.source_stamp(self.game)})) + + def test_owned_uses_real_id_and_registers_installed_files(self): + self.snapshot() + games, routes = imports.route(self.steam, self.acct, [self.game]) + self.assertEqual([], games) + self.assertEqual({str(self.game['appid']): 42}, routes) + manifest = self.steam / 'steamapps/appmanifest_42.acf' + self.assertIn('"StateFlags" "4"', manifest.read_text()) + self.assertIn('"Universe" "1"', manifest.read_text()) + self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve()) + self.assertEqual(b'game', self.exe.read_bytes()) + + def test_migrates_only_unchanged_legacy_import_manifests(self): + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + path = self.steam / 'steamapps/appmanifest_42.acf' + current = path.read_text() + legacy = current.replace(' "Universe" "1"\n', '').replace('"StateFlags" "4"', '"StateFlags" "2"') + path.write_text(legacy) + imports.route(self.steam, self.acct, [self.game]) + self.assertEqual(current, path.read_text()) + changed = legacy.replace('"StateFlags" "2"', '"StateFlags" "6"') + path.write_text(changed) + imports.route(self.steam, self.acct, [self.game]) + self.assertEqual(changed, path.read_text()) + + def test_unresolved_titles_are_retried_after_network_recovers(self): + listing = self.root / 'games.json' + listing.write_text(json.dumps([self.game])) + from unittest.mock import Mock + result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True}))) + with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \ + patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \ + patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \ + patch.object(imports.subprocess, 'run', return_value=result), \ + patch('builtins.print'): + with self.assertRaisesRegex(RuntimeError, 'stop'): + imports.watch(self.steam, listing) + self.assertEqual(2, identify.call_count) + snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {}) + self.assertTrue(snapshot['owned']['42']) + + def test_unowned_unknown_and_different_account_stay_shortcuts(self): + for owned, account in [(False, '123'), (True, '456')]: + self.snapshot(owned, account) + self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) + (self.acct / 'config' / imports.STATE).unlink() + self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) + self.assertFalse((self.steam / 'steamapps').exists()) + + def test_existing_manifest_is_never_overwritten(self): + manifest = self.steam / 'steamapps/appmanifest_42.acf' + manifest.parent.mkdir(parents=True) + manifest.write_text('Steam owns this file') + self.snapshot() + imports.route(self.steam, self.acct, [self.game]) + self.assertEqual('Steam owns this file', manifest.read_text()) + + def test_import_collision_falls_back_without_touching_files(self): + occupied = self.steam / 'steamapps/common/DroidDeck-42' + occupied.mkdir(parents=True) + self.snapshot() + self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) + self.assertTrue(occupied.is_dir()) + self.assertFalse(occupied.is_symlink()) + + def test_removed_folder_does_not_import(self): + self.snapshot() + self.exe.unlink() + self.folder.rmdir() + self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) + + def test_replaced_game_does_not_inherit_cached_ownership(self): + self.snapshot() + self.exe.write_bytes(b'a different game') + self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game])) + + def test_duplicate_owned_folders_share_one_steam_entry(self): + self.snapshot() + games, routes = imports.route(self.steam, self.acct, [self.game, self.game]) + self.assertEqual([], games) + self.assertEqual(1, len(routes)) + self.assertEqual(1, len(list((self.steam / 'steamapps').glob('*.acf')))) + + def test_explicit_appid_does_not_need_network(self): + (self.folder / 'steam_appid.txt').write_text('42\n') + with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError): + self.assertEqual(42, imports.identify(self.game)) + + def test_manifest_identity_uses_install_directory(self): + folder = self.root / 'second/steamapps/common/Custom Name' + folder.mkdir(parents=True) + (folder.parent.parent / 'appmanifest_42.acf').write_text('"AppState" { "installdir" "Custom Name" }') + game = dict(self.game, folder=str(folder), exe=str(folder / 'game.exe')) + with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError): + self.assertEqual(42, imports.identify(game)) + + def test_store_requires_unique_exact_title(self): + cases = [([{'id': 42, 'name': 'Example Deluxe'}], None), + ([{'id': 42, 'name': 'Example'}, {'id': 43, 'name': 'Example'}], None), + ([{'id': 42, 'name': 'EXAMPLE'}], 42)] + for items, expected in cases: + with patch.object(imports.urllib.request, 'urlopen') as request: + request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': items}).encode() + self.assertEqual(expected, imports.identify(self.game)) + + def test_store_country_and_edition_suffixes(self): + for folder, store in [('FINAL FANTASY VII REMAKE', 'FINAL FANTASY VII REMAKE INTERGRADE'), + ('FINAL FANTASY XV', 'FINAL FANTASY XV WINDOWS EDITION')]: + with patch.object(imports.urllib.request, 'urlopen') as request: + request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [{'id': 42, 'name': store}]}).encode() + self.assertEqual(42, imports.identify(dict(self.game, name=folder))) + self.assertIn('cc=US&', request.call_args.args[0]) + + def test_edition_matching_rejects_ambiguous_titles_and_soundtracks(self): + with patch.object(imports.urllib.request, 'urlopen') as request: + request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [ + {'id': 42, 'name': 'Example WINDOWS EDITION'}, + {'id': 43, 'name': 'Example INTERGRADE'}, + {'id': 44, 'name': 'Example Soundtrack'}, + ]}).encode() + self.assertIsNone(imports.identify(self.game)) + + def test_corrupt_shortcuts_are_preserved(self): + path = self.acct / 'config/shortcuts.vdf' + path.parent.mkdir(parents=True) + original = b'corrupt' + path.write_bytes(original) + listing = self.root / 'games.json' + listing.write_text(json.dumps([self.game])) + run = subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], capture_output=True) + self.assertEqual(1, run.returncode) + self.assertEqual(original, path.read_bytes()) + + def test_user_shortcuts_survive_owned_promotion(self): + self.snapshot() + path = self.acct / 'config/shortcuts.vdf' + manual = dict(shortcuts.shortcut(self.game), tags={'0': 'Personal'}, AppName='Manual') + path.write_bytes(shortcuts.write({'shortcuts': {'0': manual, '1': shortcuts.shortcut(self.game)}})) + listing = self.root / 'games.json' + listing.write_text(json.dumps([self.game])) + subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True) + result = shortcuts.parse(path.read_bytes())['shortcuts'] + self.assertEqual([manual], list(result.values())) + self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text())) + + +class SteamProbeTest(unittest.TestCase): + def test_probe_reads_account_and_licenses_and_releases_handles(self): + import ctypes as C + from unittest.mock import Mock + objects, callbacks, calls = [], [], [] + + def interface(slots): + table = (C.c_void_p * 24)() + for slot, result, args, callback in slots: + fn = C.CFUNCTYPE(result, C.c_void_p, *args)(callback) + callbacks.append(fn) + table[slot] = C.cast(fn, C.c_void_p).value + obj = C.pointer(C.cast(table, C.POINTER(C.c_void_p))) + objects.extend([table, obj]) + return C.cast(obj, C.c_void_p).value + + user = interface([ + (1, C.c_bool, [], lambda _: True), + (2, C.c_uint64, [], lambda _: imports.BASE_ID + 123), + ]) + apps = interface([(6, C.c_bool, [C.c_uint32], lambda _, appid: appid == 42)]) + client = interface([ + (0, C.c_int, [], lambda _: 7), + (1, C.c_bool, [C.c_int], lambda _, pipe: calls.append(('pipe', pipe)) or True), + (2, C.c_int, [C.c_int], lambda _, pipe: 9), + (4, None, [C.c_int, C.c_int], lambda _, pipe, handle: calls.append(('user', pipe, handle))), + (5, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: user if version == b'SteamUser021' else 0), + (15, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: apps if version == b'STEAMAPPS_INTERFACE_VERSION008' else 0), + ]) + library = Mock() + library.CreateInterface.return_value = client + with patch.object(imports.C, 'CDLL', return_value=library): + result = imports.probe('/Steam', [42, 43]) + self.assertEqual('123', result['account']) + self.assertEqual({'42': True, '43': False}, result['owned']) + self.assertEqual([('user', 7, 9), ('pipe', 7)], calls) + library.CreateInterface.assert_called_once_with(b'SteamClient020', None) + + +if __name__ == '__main__': + unittest.main()