mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-21 06:34:44 -04:00
Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ece38ddb8 | ||
|
|
b4b31d5a08 | ||
|
|
9a4eeb3d06 | ||
|
|
85add46809 | ||
|
|
b78b06b78d | ||
|
|
f394906726 | ||
|
|
b50dfa76d1 | ||
|
|
3f81ddff9b | ||
|
|
d1dec8d7a4 | ||
|
|
58ec3609b6 | ||
|
|
e60097e5cb | ||
|
|
7c7bf2dcbe | ||
|
|
75593cfd29 | ||
|
|
00d0f8cd2b | ||
|
|
faf4b4e8b5 | ||
|
|
abb3f5582b | ||
|
|
5a61d37c5d | ||
|
|
25079ab819 | ||
|
|
736b7bed5e | ||
|
|
a3f4c911e5 | ||
|
|
caa7682c6f | ||
|
|
e217cbe66d | ||
|
|
4914b2cc69 | ||
|
|
f5dfc946f2 | ||
|
|
6ecd6fa1d0 | ||
|
|
a38952378d | ||
|
|
29dc88dce1 | ||
|
|
0f4e83eefd | ||
|
|
56889fe2ba | ||
|
|
d62525f5af | ||
|
|
463291d655 | ||
|
|
b0e02124d8 | ||
|
|
34640e83cb | ||
|
|
4e9cc0bb36 | ||
|
|
f4d2c37fbd | ||
|
|
ffd9f92d7b | ||
|
|
213d110766 | ||
|
|
1a8c91b49b | ||
|
|
5c1b29f47a | ||
|
|
672b5dc74c | ||
|
|
76c82ee3ff | ||
|
|
15a541d745 | ||
|
|
fe04315461 | ||
|
|
4fef38e87e | ||
|
|
c63355be6c | ||
|
|
9644a4cc7f | ||
|
|
24c250fdb4 | ||
|
|
d4636afa97 | ||
|
|
32930e704e | ||
|
|
a8b18947a1 | ||
|
|
7c422577b6 | ||
|
|
1cc1ee994d | ||
|
|
d8d375cb9a | ||
|
|
221644e8be | ||
|
|
448feb30bc | ||
|
|
9d891d4db1 | ||
|
|
240f1194c0 | ||
|
|
7b74eebce2 | ||
|
|
7592684b6f | ||
|
|
7b78e1629b | ||
|
|
30741302fb | ||
|
|
645f7d0bf1 | ||
|
|
124f162ee8 | ||
|
|
3c577fd9dd | ||
|
|
659ff4817f | ||
|
|
626adb62af | ||
|
|
71cb100a40 | ||
|
|
68002f3fa7 | ||
|
|
dae7592788 | ||
|
|
e10f5b4430 | ||
|
|
3e32b75a63 | ||
|
|
435f7a1b03 | ||
|
|
1fd8c50225 | ||
|
|
e797bc5f01 | ||
|
|
0cc3fe6bca | ||
|
|
ab8542e4e5 | ||
|
|
c2bcf20172 | ||
|
|
2341659cea | ||
|
|
b2eaf380dc | ||
|
|
197fc3f163 | ||
|
|
9530e7b394 | ||
|
|
0e0f133496 | ||
|
|
d98c211168 | ||
|
|
614b4d4de4 | ||
|
|
43a19a831a | ||
|
|
0d308af024 | ||
|
|
128f73da8f | ||
|
|
e22a55e949 | ||
|
|
67cb005e6c | ||
|
|
73f76f6799 | ||
|
|
961a060461 | ||
|
|
dadbbf841a | ||
|
|
1ce8280371 | ||
|
|
78bdc0e3b5 | ||
|
|
ce5f2f5513 | ||
|
|
0f2792c7ac | ||
|
|
7185b775d4 | ||
|
|
cda6e89c12 | ||
|
|
5f80898c27 | ||
|
|
85560d6471 | ||
|
|
be6c875277 | ||
|
|
91beced154 | ||
|
|
ebc3da08b4 | ||
|
|
41eb780495 | ||
|
|
65fc2a38a3 | ||
|
|
359d1535ac | ||
|
|
4c3a9f027c | ||
|
|
5a6cc3c76e | ||
|
|
89bd7deceb | ||
|
|
169f2c3e34 | ||
|
|
e6a797695a | ||
|
|
f1a2ecc857 | ||
|
|
36916f6bde | ||
|
|
1b96d13a51 | ||
|
|
cb1d70bb14 | ||
|
|
37af590ca2 | ||
|
|
02f356f8c0 | ||
|
|
5448177814 | ||
|
|
ed6ce0ca41 | ||
|
|
9b440686c5 | ||
|
|
e7b7e9dc43 | ||
|
|
1120040aee | ||
|
|
3506a272da | ||
|
|
b752748b7d | ||
|
|
8990e7d7a4 | ||
|
|
c92a423ba1 | ||
|
|
3b82b3e1f8 | ||
|
|
e985ce1975 | ||
|
|
8496ce3e51 | ||
|
|
47188f40ed | ||
|
|
e186c9bc4c | ||
|
|
7705f3c222 | ||
|
|
5675b4897f | ||
|
|
1c87d92ef7 | ||
|
|
031e64dfe1 | ||
|
|
f590dd9763 | ||
|
|
af7dc40538 | ||
|
|
a28aae6e4a | ||
|
|
c024470cc7 | ||
|
|
16ae5ab84a | ||
|
|
144947305a | ||
|
|
563bc6cad7 | ||
|
|
59cde93821 | ||
|
|
c104e1c276 | ||
|
|
b5da0d0db6 | ||
|
|
5defc3e38b | ||
|
|
6a9f52f932 | ||
|
|
06a8d75769 | ||
|
|
ec12ddd0a8 | ||
|
|
570902d5f3 | ||
|
|
6120069975 | ||
|
|
3e337c9bda | ||
|
|
2869e76406 | ||
|
|
61fdb80823 | ||
|
|
fd180a108a | ||
|
|
4de74c740c | ||
|
|
b7c32c92d2 | ||
|
|
8610ca0928 | ||
|
|
a6b1ad37d0 | ||
|
|
97b13f7488 | ||
|
|
5d5b45a012 | ||
|
|
5ebafaabda | ||
|
|
56627a8aaf | ||
|
|
f59ced4e98 | ||
|
|
296e848718 | ||
|
|
8c63413019 | ||
|
|
2861c6d284 | ||
|
|
69c2d8e566 | ||
|
|
414fddf7b8 | ||
|
|
b5ca6145a6 | ||
|
|
5a5948b071 | ||
|
|
e58b5088b3 | ||
|
|
ecbce4c8d3 | ||
|
|
96bf0a3bf3 | ||
|
|
da7522a3ee | ||
|
|
5605e777ca | ||
|
|
a10e2e1fe8 | ||
|
|
4fb623fa33 | ||
|
|
3b4956d088 | ||
|
|
18deaafc34 | ||
|
|
0c4eede6d2 | ||
|
|
54681f2647 | ||
|
|
5271edd972 | ||
|
|
e4bb9856ef | ||
|
|
e462bbb24a | ||
|
|
d8f3090d9b | ||
|
|
d1af8b0114 | ||
|
|
dae64bbe69 | ||
|
|
8b127bed84 | ||
|
|
67187b1340 | ||
|
|
02cac57438 | ||
|
|
277dcd174c | ||
|
|
3c23ae7310 | ||
|
|
8cb13d40b5 | ||
|
|
c950d31960 | ||
|
|
ae570cd5b7 | ||
|
|
0fd1dc4fce | ||
|
|
f0854e8518 | ||
|
|
ceffd3f759 | ||
|
|
dc1816612c | ||
|
|
1d04a7e0e3 | ||
|
|
1e523c4e93 | ||
|
|
d83f766cf3 | ||
|
|
bb5e1ae63d | ||
|
|
f27a4f6c95 | ||
|
|
d7ff92106a | ||
|
|
34a17acaba | ||
|
|
110aec0b6f | ||
|
|
06502bb49b | ||
|
|
c20a166134 | ||
|
|
162fa8e2de | ||
|
|
ae5614d212 | ||
|
|
b72d8a8bab | ||
|
|
d4cf890d8a | ||
|
|
82e494269d | ||
|
|
c982d65675 | ||
|
|
f4e606fb15 | ||
|
|
9dbdb5e771 | ||
|
|
fa12478fdd | ||
|
|
c3b0d8ef03 | ||
|
|
cbb9a168e6 | ||
|
|
73e2fc58a0 | ||
|
|
500f4c69eb | ||
|
|
aa843b5a31 | ||
|
|
f249acc50f | ||
|
|
9422608960 | ||
|
|
de37ddcfb5 | ||
|
|
4c90196890 | ||
|
|
30f6ca6ffc | ||
|
|
ecff3240a6 | ||
|
|
ddd4a0c24d | ||
|
|
019949847a | ||
|
|
4dcbf269d2 |
No files matched your search
+159
-5
@@ -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,139 @@ jobs:
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
environment: signing-main
|
||||
variants: '["standard"]'
|
||||
variants: ${{ needs.build.outputs.variants }}
|
||||
secrets: inherit
|
||||
|
||||
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
|
||||
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
|
||||
# Discord's builds channel linking to it. The release is made with a token of the org's
|
||||
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
|
||||
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
|
||||
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
|
||||
announce:
|
||||
needs: [build, sign]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: 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
|
||||
|
||||
# What this build is, for the top of the release notes: the merged PR's title and the first
|
||||
# paragraph of its description, or the commit's subject for a push made without a PR.
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
number=$(jq -r .number <<<"$pr")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
else
|
||||
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
|
||||
echo "**Pushed to main** without a PR - $subject" > notes-head.md
|
||||
fi
|
||||
cat notes-head.md
|
||||
|
||||
- name: Publish to DroidDeck-CI
|
||||
id: publish
|
||||
env:
|
||||
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="$(cat notes-head.md)
|
||||
|
||||
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(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"
|
||||
@@ -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")
|
||||
@@ -114,28 +115,55 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-pr-signed-standard
|
||||
path: signed
|
||||
- name: Publish to DroidDeck-CI when configured
|
||||
# What this build is, for the top of the release notes: the PR's title and the first
|
||||
# paragraph of its description.
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
cat notes-head.md
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
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: ${{ 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"
|
||||
sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1)
|
||||
notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
notes="$(cat notes-head.md)
|
||||
|
||||
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
Commit: $HEAD_SHA
|
||||
APK SHA-256: $sha"
|
||||
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -12,6 +12,7 @@ captures/
|
||||
# copies cannot drift.
|
||||
app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
@@ -28,3 +29,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
|
||||
@@ -1,10 +1,17 @@
|
||||
# DroidDeck
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="artwork/droiddeck-banner-dark.svg">
|
||||
<img alt="DroidDeck" src="artwork/droiddeck-banner-light.svg" width="100%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
Run Valve's native ARM64 Steam client on an Adreno Android device. Steam runs in a Linux runtime under proot, with gamescope and an in-app Vulkan compositor. Windows games use Valve's ARM64 Proton and FEX. A desktop with LXQt, Firefox, and emulators is also available.
|
||||
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
|
||||
|
||||
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
|
||||
|
||||
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
|
||||
|
||||
|
||||
+18
-3
@@ -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 {
|
||||
@@ -121,6 +121,10 @@ android {
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
// Kadb's Bouncy Castle artifacts repeat the same multi-release JAR manifest.
|
||||
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
|
||||
}
|
||||
jniLibs {
|
||||
// The PulseAudio daemon is executed from the native library directory and its
|
||||
// libraries are read out of the apk by the class loader: both want the old,
|
||||
@@ -136,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
|
||||
@@ -148,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')
|
||||
@@ -160,6 +169,12 @@ dependencies {
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
|
||||
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
|
||||
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'
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
@@ -47,7 +48,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="sensorLandscape">
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -124,6 +126,13 @@
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
|
||||
<service
|
||||
android:name=".runtime.RuntimeInstallService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".core.WirelessAdbPairingService"
|
||||
android:exported="false" />
|
||||
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -13,11 +13,13 @@ add_library(bannerwayland SHARED
|
||||
src/ahb_swapchain.c
|
||||
src/waylandcomp_jni.c
|
||||
src/banner_ext.c
|
||||
src/wl_dmabuf.c
|
||||
src/wl_clipboard.c
|
||||
src/wl_text_input.c
|
||||
src/wl_toplevel_icon.c
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
generated/color-management-v1-protocol.c
|
||||
generated/xdg-shell-protocol.c
|
||||
generated/linux-dmabuf-v1-protocol.c
|
||||
@@ -33,6 +35,9 @@ add_library(bannerwayland SHARED
|
||||
src/framegen_bridge.c
|
||||
src/framegen_jni.c
|
||||
)
|
||||
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
|
||||
target_include_directories(bannerwayland PRIVATE
|
||||
generated
|
||||
prebuilt/include
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#version 450
|
||||
// Wayland buffers carry premultiplied alpha; the pipeline blends ONE, ONE_MINUS_SRC_ALPHA.
|
||||
layout(binding = 0) uniform sampler2D Surface;
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
outColor = texture(Surface, uv);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#version 450
|
||||
// One surface as a quad: dst = the target rectangle in NDC, src = the buffer rectangle in UV.
|
||||
layout(push_constant) uniform PC { vec4 dst; vec4 src; } pc;
|
||||
layout(location = 0) out vec2 uv;
|
||||
|
||||
void main() {
|
||||
vec2 t = vec2(gl_VertexIndex & 1, gl_VertexIndex >> 1);
|
||||
gl_Position = vec4(mix(pc.dst.xy, pc.dst.zw, t), 0.0, 1.0);
|
||||
uv = mix(pc.src.xy, pc.src.zw, t);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t blend_frag_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x00000014,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x00000011,0x00030010,
|
||||
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
|
||||
0x00000000,0x00050005,0x00000009,0x4374756f,0x726f6c6f,0x00000000,0x00040005,0x0000000d,
|
||||
0x66727553,0x00656361,0x00030005,0x00000011,0x00007675,0x00040047,0x00000009,0x0000001e,
|
||||
0x00000000,0x00040047,0x0000000d,0x00000021,0x00000000,0x00040047,0x0000000d,0x00000022,
|
||||
0x00000000,0x00040047,0x00000011,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,
|
||||
0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
|
||||
0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,
|
||||
0x00000003,0x00090019,0x0000000a,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
|
||||
0x00000001,0x00000000,0x0003001b,0x0000000b,0x0000000a,0x00040020,0x0000000c,0x00000000,
|
||||
0x0000000b,0x0004003b,0x0000000c,0x0000000d,0x00000000,0x00040017,0x0000000f,0x00000006,
|
||||
0x00000002,0x00040020,0x00000010,0x00000001,0x0000000f,0x0004003b,0x00000010,0x00000011,
|
||||
0x00000001,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
|
||||
0x0004003d,0x0000000b,0x0000000e,0x0000000d,0x0004003d,0x0000000f,0x00000012,0x00000011,
|
||||
0x00050057,0x00000007,0x00000013,0x0000000e,0x00000012,0x0003003e,0x00000009,0x00000013,
|
||||
0x000100fd,0x00010038
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
/* Alpha composition: one surface drawn source-over onto the scene - see blend_pass.h. Compositor thread. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "blend_pass.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* banner_log, vkp_gpu_name */
|
||||
#include <string.h>
|
||||
|
||||
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
|
||||
* and committed next to the GLSL: the NDK build compiles no shaders. */
|
||||
#include "blend_vert.h"
|
||||
#include "blend_frag.h"
|
||||
|
||||
struct pc_blend { float dst[4]; float src[4]; };
|
||||
|
||||
static VkDevice g_dev;
|
||||
static int g_ready; /* 0 = not built, 1 = ok, -1 = unavailable (said once) */
|
||||
static VkSampler g_sampler;
|
||||
static VkDescriptorSetLayout g_dsl;
|
||||
static VkPipelineLayout g_pl;
|
||||
static VkDescriptorPool g_dpool;
|
||||
static VkShaderModule g_vert, g_frag;
|
||||
static unsigned g_frame = 1;
|
||||
|
||||
/* One render pass + pipeline per target format: the screen swapchain's, the scene image's, the HDR
|
||||
* composition's. */
|
||||
#define NFMT 4
|
||||
static struct { VkFormat fmt; VkRenderPass rp; VkPipeline pipe; } g_out[NFMT];
|
||||
|
||||
#define NSRC 32
|
||||
static struct { VkImage img; VkImageView view; VkDescriptorSet ds; unsigned frame; } g_src[NSRC];
|
||||
#define NDST 12
|
||||
static struct { VkImage img; VkFormat fmt; int w, h; VkImageView view; VkFramebuffer fb; unsigned frame; } g_dst[NDST];
|
||||
|
||||
void blendp_bind_device(VkDevice dev) { g_dev = dev; }
|
||||
|
||||
void blendp_begin_frame(void) { g_frame++; }
|
||||
|
||||
static void src_release(int i) {
|
||||
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
|
||||
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
|
||||
memset(&g_src[i], 0, sizeof(g_src[i]));
|
||||
}
|
||||
|
||||
static void dst_release(int i) {
|
||||
if (g_dst[i].fb) g_vk.DestroyFramebuffer(g_dev, g_dst[i].fb, NULL);
|
||||
if (g_dst[i].view) g_vk.DestroyImageView(g_dev, g_dst[i].view, NULL);
|
||||
memset(&g_dst[i], 0, sizeof(g_dst[i]));
|
||||
}
|
||||
|
||||
void blendp_forget_image(VkImage img) {
|
||||
if (!g_dev || !img) return;
|
||||
for (int i = 0; i < NSRC; i++) if (g_src[i].img == img) src_release(i);
|
||||
for (int i = 0; i < NDST; i++) if (g_dst[i].img == img) dst_release(i);
|
||||
}
|
||||
|
||||
static VkShaderModule shader(const uint32_t *code, size_t size) {
|
||||
VkShaderModule m = VK_NULL_HANDLE;
|
||||
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
|
||||
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return m;
|
||||
}
|
||||
|
||||
/* The target keeps what the blits put there (LOAD) and stays in TRANSFER_DST_OPTIMAL for the blits
|
||||
* that follow. The dependencies order the pass between them. */
|
||||
static VkRenderPass make_rp(VkFormat fmt) {
|
||||
VkAttachmentDescription att = {.format = fmt, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
|
||||
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
|
||||
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL};
|
||||
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
|
||||
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
|
||||
.pColorAttachments = &ref};
|
||||
VkSubpassDependency deps[2] = {
|
||||
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT},
|
||||
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT}};
|
||||
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
|
||||
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
|
||||
.dependencyCount = 2, .pDependencies = deps};
|
||||
VkRenderPass rp = VK_NULL_HANDLE;
|
||||
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &rp) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return rp;
|
||||
}
|
||||
|
||||
static VkPipeline make_pipe(VkRenderPass rp) {
|
||||
VkPipelineShaderStageCreateInfo st[2] = {
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT,
|
||||
.module = g_vert, .pName = "main"},
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||
.module = g_frag, .pName = "main"}};
|
||||
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
|
||||
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
|
||||
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
|
||||
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
||||
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
|
||||
.dynamicStateCount = 2, .pDynamicStates = dyn};
|
||||
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
|
||||
.viewportCount = 1, .scissorCount = 1};
|
||||
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
|
||||
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
|
||||
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
|
||||
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
|
||||
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
|
||||
/* The target's alpha is left alone: the screen is opaque whatever is drawn onto it. */
|
||||
VkPipelineColorBlendAttachmentState ba = {
|
||||
.blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
|
||||
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD,
|
||||
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
|
||||
.alphaBlendOp = VK_BLEND_OP_ADD,
|
||||
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT};
|
||||
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
|
||||
.attachmentCount = 1, .pAttachments = &ba};
|
||||
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
|
||||
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
|
||||
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
|
||||
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
|
||||
.renderPass = rp, .subpass = 0};
|
||||
VkPipeline p = VK_NULL_HANDLE;
|
||||
if (g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void objects_destroy(void) {
|
||||
if (g_vert) g_vk.DestroyShaderModule(g_dev, g_vert, NULL);
|
||||
if (g_frag) g_vk.DestroyShaderModule(g_dev, g_frag, NULL);
|
||||
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
|
||||
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
|
||||
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
|
||||
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
|
||||
g_vert = g_frag = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
|
||||
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
static int objects_ensure(void) {
|
||||
if (g_ready) return g_ready == 1 ? 0 : -1;
|
||||
g_ready = -1;
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
|
||||
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
|
||||
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
|
||||
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
|
||||
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
|
||||
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
|
||||
.bindingCount = 1, .pBindings = &b};
|
||||
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
|
||||
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .offset = 0, .size = sizeof(struct pc_blend)};
|
||||
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
|
||||
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
|
||||
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, NSRC};
|
||||
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
|
||||
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, .maxSets = NSRC,
|
||||
.poolSizeCount = 1, .pPoolSizes = &ps};
|
||||
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
|
||||
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
|
||||
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
|
||||
g_ready = 1;
|
||||
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
|
||||
vkp_gpu_name());
|
||||
return 0;
|
||||
fail:
|
||||
objects_destroy();
|
||||
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
|
||||
"are drawn opaque this session");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int out_slot(VkFormat fmt) {
|
||||
int i;
|
||||
for (i = 0; i < NFMT && g_out[i].pipe; i++) if (g_out[i].fmt == fmt) return i;
|
||||
if (i == NFMT) return -1;
|
||||
VkRenderPass rp = make_rp(fmt);
|
||||
VkPipeline pipe = rp ? make_pipe(rp) : VK_NULL_HANDLE;
|
||||
if (!pipe) {
|
||||
if (rp) g_vk.DestroyRenderPass(g_dev, rp, NULL);
|
||||
return -1;
|
||||
}
|
||||
g_out[i].fmt = fmt; g_out[i].rp = rp; g_out[i].pipe = pipe;
|
||||
return i;
|
||||
}
|
||||
|
||||
static VkImageView make_view(VkImage img, VkFormat fmt) {
|
||||
VkImageView v = VK_NULL_HANDLE;
|
||||
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
|
||||
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = fmt,
|
||||
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
|
||||
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* The slot to recycle: a free one, else the one unused for longest - never one this command buffer
|
||||
* already refers to. -1 = every slot is in this frame. */
|
||||
#define OLDEST(arr, n, out) do { \
|
||||
unsigned age_ = 0; \
|
||||
(out) = -1; \
|
||||
for (int k_ = 0; k_ < (n); k_++) { \
|
||||
if (!(arr)[k_].img) { (out) = k_; break; } \
|
||||
if ((arr)[k_].frame != g_frame && g_frame - (arr)[k_].frame > age_) { age_ = g_frame - (arr)[k_].frame; (out) = k_; } \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static VkDescriptorSet src_ensure(VkImage img, VkFormat fmt, VkImageLayout layout) {
|
||||
int i;
|
||||
for (i = 0; i < NSRC; i++)
|
||||
if (g_src[i].img == img) { g_src[i].frame = g_frame; return g_src[i].ds; }
|
||||
OLDEST(g_src, NSRC, i);
|
||||
if (i < 0) return VK_NULL_HANDLE;
|
||||
src_release(i);
|
||||
if (!(g_src[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
|
||||
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
|
||||
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
|
||||
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &g_src[i].ds) != VK_SUCCESS) {
|
||||
g_src[i].ds = VK_NULL_HANDLE;
|
||||
src_release(i);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = g_src[i].view, .imageLayout = layout};
|
||||
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = g_src[i].ds, .dstBinding = 0,
|
||||
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.pImageInfo = &ii};
|
||||
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
|
||||
g_src[i].img = img; g_src[i].frame = g_frame;
|
||||
return g_src[i].ds;
|
||||
}
|
||||
|
||||
static VkFramebuffer dst_ensure(VkImage img, VkFormat fmt, int w, int h, VkRenderPass rp) {
|
||||
int i;
|
||||
for (i = 0; i < NDST; i++) {
|
||||
if (g_dst[i].img != img) continue;
|
||||
if (g_dst[i].fmt == fmt && g_dst[i].w == w && g_dst[i].h == h) { g_dst[i].frame = g_frame; return g_dst[i].fb; }
|
||||
if (g_dst[i].frame == g_frame) return VK_NULL_HANDLE;
|
||||
break;
|
||||
}
|
||||
if (i == NDST) OLDEST(g_dst, NDST, i);
|
||||
if (i < 0) return VK_NULL_HANDLE;
|
||||
dst_release(i);
|
||||
if (!(g_dst[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
|
||||
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = rp,
|
||||
.attachmentCount = 1, .pAttachments = &g_dst[i].view, .width = (uint32_t)w,
|
||||
.height = (uint32_t)h, .layers = 1};
|
||||
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_dst[i].fb) != VK_SUCCESS) {
|
||||
g_dst[i].fb = VK_NULL_HANDLE;
|
||||
dst_release(i);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
g_dst[i].img = img; g_dst[i].fmt = fmt; g_dst[i].w = w; g_dst[i].h = h; g_dst[i].frame = g_frame;
|
||||
return g_dst[i].fb;
|
||||
}
|
||||
|
||||
static void src_layout(VkCommandBuffer cmd, VkImage img, VkImageLayout from, VkImageLayout to) {
|
||||
int sampling = to == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkImageMemoryBarrier b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = from, .newLayout = to,
|
||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||
.image = img, .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1},
|
||||
.srcAccessMask = sampling ? VK_ACCESS_TRANSFER_READ_BIT : VK_ACCESS_SHADER_READ_BIT,
|
||||
.dstAccessMask = sampling ? VK_ACCESS_SHADER_READ_BIT : VK_ACCESS_TRANSFER_READ_BIT};
|
||||
g_vk.CmdPipelineBarrier(cmd, sampling ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
||||
sampling ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 0, NULL, 0, NULL, 1, &b);
|
||||
}
|
||||
|
||||
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
|
||||
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit) {
|
||||
if (!src || !dst || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0 || objects_ensure() != 0) return -1;
|
||||
int slot = out_slot(dst_fmt);
|
||||
if (slot < 0) return -1;
|
||||
VkImageLayout sampled = src_general ? VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkDescriptorSet ds = src_ensure(src, src_fmt, sampled);
|
||||
if (!ds) return -1;
|
||||
VkFramebuffer fb = dst_ensure(dst, dst_fmt, dst_w, dst_h, g_out[slot].rp);
|
||||
if (!fb) return -1;
|
||||
|
||||
const VkOffset3D *d = blit->dstOffsets, *s = blit->srcOffsets;
|
||||
int x0 = d[0].x < d[1].x ? d[0].x : d[1].x, x1 = d[0].x < d[1].x ? d[1].x : d[0].x;
|
||||
int y0 = d[0].y < d[1].y ? d[0].y : d[1].y, y1 = d[0].y < d[1].y ? d[1].y : d[0].y;
|
||||
if (x0 < 0) x0 = 0;
|
||||
if (y0 < 0) y0 = 0;
|
||||
if (x1 > dst_w) x1 = dst_w;
|
||||
if (y1 > dst_h) y1 = dst_h;
|
||||
if (x1 <= x0 || y1 <= y0) return 0;
|
||||
|
||||
struct pc_blend pc = {
|
||||
.dst = {2.0f * d[0].x / dst_w - 1.0f, 2.0f * d[0].y / dst_h - 1.0f,
|
||||
2.0f * d[1].x / dst_w - 1.0f, 2.0f * d[1].y / dst_h - 1.0f},
|
||||
.src = {(float)s[0].x / src_w, (float)s[0].y / src_h, (float)s[1].x / src_w, (float)s[1].y / src_h}};
|
||||
|
||||
if (!src_general) src_layout(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sampled);
|
||||
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_out[slot].rp,
|
||||
.framebuffer = fb,
|
||||
.renderArea = {{x0, y0}, {(uint32_t)(x1 - x0), (uint32_t)(y1 - y0)}}};
|
||||
VkViewport vp = {0, 0, (float)dst_w, (float)dst_h, 0, 1};
|
||||
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
|
||||
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
|
||||
g_vk.CmdSetScissor(cmd, 0, 1, &rpi.renderArea);
|
||||
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_out[slot].pipe);
|
||||
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
|
||||
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
|
||||
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
|
||||
g_vk.CmdEndRenderPass(cmd);
|
||||
if (!src_general) src_layout(cmd, src, sampled, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#ifndef BLEND_PASS_H
|
||||
#define BLEND_PASS_H
|
||||
/*
|
||||
* Alpha composition. The scene is composed with blits, which replace what is under them; a surface
|
||||
* with translucent pixels (gamescope's overlay and notification planes over a game, a toolkit's
|
||||
* shadowed popup) is drawn through this pass instead, source-over with premultiplied alpha.
|
||||
*
|
||||
* Compositor thread. Objects are built on the first use; if the driver refuses them the pass is
|
||||
* unavailable for the session (logged once) and the caller keeps blitting.
|
||||
*/
|
||||
#define VK_USE_PLATFORM_ANDROID_KHR
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
/* Called from dev_init (device up). */
|
||||
void blendp_bind_device(VkDevice dev);
|
||||
/* A source or target image is about to be destroyed: drop what is cached for it. Frames are fenced,
|
||||
* so nothing submitted still uses it. */
|
||||
void blendp_forget_image(VkImage img);
|
||||
/* A new command buffer is being recorded: what the previous one used may be recycled again. */
|
||||
void blendp_begin_frame(void);
|
||||
|
||||
/* Record one surface over `dst`. `dst` is in TRANSFER_DST_OPTIMAL before and after. `src` was created
|
||||
* with SAMPLED usage and is in GENERAL (src_general) or TRANSFER_SRC_OPTIMAL, and is left there.
|
||||
* `blit` is the draw as the blit path maps it. 0 = recorded, -1 = unavailable (nothing recorded). */
|
||||
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
|
||||
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,55 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t blend_vert_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x0000003a,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0008000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x0000001b,0x00000031,
|
||||
0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,0x00000000,0x00030005,
|
||||
0x00000009,0x00000074,0x00060005,0x0000000c,0x565f6c67,0x65747265,0x646e4978,0x00007865,
|
||||
0x00060005,0x00000019,0x505f6c67,0x65567265,0x78657472,0x00000000,0x00060006,0x00000019,
|
||||
0x00000000,0x505f6c67,0x7469736f,0x006e6f69,0x00070006,0x00000019,0x00000001,0x505f6c67,
|
||||
0x746e696f,0x657a6953,0x00000000,0x00070006,0x00000019,0x00000002,0x435f6c67,0x4470696c,
|
||||
0x61747369,0x0065636e,0x00070006,0x00000019,0x00000003,0x435f6c67,0x446c6c75,0x61747369,
|
||||
0x0065636e,0x00030005,0x0000001b,0x00000000,0x00030005,0x0000001d,0x00004350,0x00040006,
|
||||
0x0000001d,0x00000000,0x00747364,0x00040006,0x0000001d,0x00000001,0x00637273,0x00030005,
|
||||
0x0000001f,0x00006370,0x00030005,0x00000031,0x00007675,0x00040047,0x0000000c,0x0000000b,
|
||||
0x0000002a,0x00030047,0x00000019,0x00000002,0x00050048,0x00000019,0x00000000,0x0000000b,
|
||||
0x00000000,0x00050048,0x00000019,0x00000001,0x0000000b,0x00000001,0x00050048,0x00000019,
|
||||
0x00000002,0x0000000b,0x00000003,0x00050048,0x00000019,0x00000003,0x0000000b,0x00000004,
|
||||
0x00030047,0x0000001d,0x00000002,0x00050048,0x0000001d,0x00000000,0x00000023,0x00000000,
|
||||
0x00050048,0x0000001d,0x00000001,0x00000023,0x00000010,0x00040047,0x00000031,0x0000001e,
|
||||
0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,
|
||||
0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,
|
||||
0x00000007,0x00040015,0x0000000a,0x00000020,0x00000001,0x00040020,0x0000000b,0x00000001,
|
||||
0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x0004002b,0x0000000a,0x0000000e,
|
||||
0x00000001,0x00040017,0x00000015,0x00000006,0x00000004,0x00040015,0x00000016,0x00000020,
|
||||
0x00000000,0x0004002b,0x00000016,0x00000017,0x00000001,0x0004001c,0x00000018,0x00000006,
|
||||
0x00000017,0x0006001e,0x00000019,0x00000015,0x00000006,0x00000018,0x00000018,0x00040020,
|
||||
0x0000001a,0x00000003,0x00000019,0x0004003b,0x0000001a,0x0000001b,0x00000003,0x0004002b,
|
||||
0x0000000a,0x0000001c,0x00000000,0x0004001e,0x0000001d,0x00000015,0x00000015,0x00040020,
|
||||
0x0000001e,0x00000009,0x0000001d,0x0004003b,0x0000001e,0x0000001f,0x00000009,0x00040020,
|
||||
0x00000020,0x00000009,0x00000015,0x0004002b,0x00000006,0x00000029,0x00000000,0x0004002b,
|
||||
0x00000006,0x0000002a,0x3f800000,0x00040020,0x0000002e,0x00000003,0x00000015,0x00040020,
|
||||
0x00000030,0x00000003,0x00000007,0x0004003b,0x00000030,0x00000031,0x00000003,0x00050036,
|
||||
0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000008,
|
||||
0x00000009,0x00000007,0x0004003d,0x0000000a,0x0000000d,0x0000000c,0x000500c7,0x0000000a,
|
||||
0x0000000f,0x0000000d,0x0000000e,0x0004006f,0x00000006,0x00000010,0x0000000f,0x0004003d,
|
||||
0x0000000a,0x00000011,0x0000000c,0x000500c3,0x0000000a,0x00000012,0x00000011,0x0000000e,
|
||||
0x0004006f,0x00000006,0x00000013,0x00000012,0x00050050,0x00000007,0x00000014,0x00000010,
|
||||
0x00000013,0x0003003e,0x00000009,0x00000014,0x00050041,0x00000020,0x00000021,0x0000001f,
|
||||
0x0000001c,0x0004003d,0x00000015,0x00000022,0x00000021,0x0007004f,0x00000007,0x00000023,
|
||||
0x00000022,0x00000022,0x00000000,0x00000001,0x00050041,0x00000020,0x00000024,0x0000001f,
|
||||
0x0000001c,0x0004003d,0x00000015,0x00000025,0x00000024,0x0007004f,0x00000007,0x00000026,
|
||||
0x00000025,0x00000025,0x00000002,0x00000003,0x0004003d,0x00000007,0x00000027,0x00000009,
|
||||
0x0008000c,0x00000007,0x00000028,0x00000001,0x0000002e,0x00000023,0x00000026,0x00000027,
|
||||
0x00050051,0x00000006,0x0000002b,0x00000028,0x00000000,0x00050051,0x00000006,0x0000002c,
|
||||
0x00000028,0x00000001,0x00070050,0x00000015,0x0000002d,0x0000002b,0x0000002c,0x00000029,
|
||||
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000001b,0x0000001c,0x0003003e,0x0000002f,
|
||||
0x0000002d,0x00050041,0x00000020,0x00000032,0x0000001f,0x0000000e,0x0004003d,0x00000015,
|
||||
0x00000033,0x00000032,0x0007004f,0x00000007,0x00000034,0x00000033,0x00000033,0x00000000,
|
||||
0x00000001,0x00050041,0x00000020,0x00000035,0x0000001f,0x0000000e,0x0004003d,0x00000015,
|
||||
0x00000036,0x00000035,0x0007004f,0x00000007,0x00000037,0x00000036,0x00000036,0x00000002,
|
||||
0x00000003,0x0004003d,0x00000007,0x00000038,0x00000009,0x0008000c,0x00000007,0x00000039,
|
||||
0x00000001,0x0000002e,0x00000034,0x00000037,0x00000038,0x0003003e,0x00000031,0x00000039,
|
||||
0x000100fd,0x00010038
|
||||
};
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <sys/timerfd.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
@@ -51,6 +52,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__)
|
||||
@@ -161,21 +163,9 @@ static int log_budget(void) {
|
||||
|
||||
static struct wl_display *g_display;
|
||||
|
||||
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
|
||||
struct client_info {
|
||||
struct wl_client *client;
|
||||
struct wl_listener destroy;
|
||||
pid_t pid;
|
||||
char name[64];
|
||||
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
|
||||
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
|
||||
unsigned asked_feedback : 1, shm_gl_said : 1;
|
||||
unsigned dmabuf_buffers, shm_frames;
|
||||
struct client_info *next;
|
||||
};
|
||||
static struct client_info *g_clients;
|
||||
|
||||
static struct client_info *client_info_of(struct wl_client *client) {
|
||||
struct client_info *client_info_of(struct wl_client *client) {
|
||||
for (struct client_info *ci = g_clients; ci; ci = ci->next)
|
||||
if (ci->client == client) return ci;
|
||||
return NULL;
|
||||
@@ -248,6 +238,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];
|
||||
@@ -258,6 +250,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;
|
||||
|
||||
@@ -439,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
|
||||
* delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier
|
||||
* limit had spaced out, and the game's first frames after that waited on empty slots. */
|
||||
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
|
||||
int limit = g_fps_limit;
|
||||
if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t interval = 1000000000LL / limit, now = now_ns();
|
||||
const int limit = g_fps_limit;
|
||||
/* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for
|
||||
* frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced
|
||||
* buffer straight back let it draw as fast as the GPU allowed and everything above the refresh
|
||||
* rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05).
|
||||
* One release per refresh is the back-pressure X11 gives, and the screen shows the same frames
|
||||
* either way. */
|
||||
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
|
||||
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t now = now_ns();
|
||||
int64_t at = s->next_release_ns + interval;
|
||||
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
|
||||
if (at < now) at = now;
|
||||
@@ -472,6 +475,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;
|
||||
|
||||
@@ -486,6 +495,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 */
|
||||
@@ -493,6 +503,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++; }
|
||||
@@ -500,7 +518,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);
|
||||
@@ -513,10 +531,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);
|
||||
}
|
||||
|
||||
@@ -557,40 +578,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++)
|
||||
@@ -601,7 +589,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,
|
||||
};
|
||||
|
||||
@@ -743,6 +731,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));
|
||||
@@ -794,6 +783,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;
|
||||
@@ -965,13 +955,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;
|
||||
@@ -995,6 +1066,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;
|
||||
@@ -1011,27 +1086,19 @@ 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 (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);
|
||||
wl_buffer_send_release(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) {
|
||||
@@ -1103,6 +1170,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;
|
||||
@@ -1139,24 +1208,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,
|
||||
@@ -1179,6 +1253,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);
|
||||
}
|
||||
@@ -1570,319 +1645,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
|
||||
@@ -1942,6 +1704,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;
|
||||
@@ -1961,7 +1734,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;
|
||||
}
|
||||
|
||||
@@ -2032,6 +1806,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) ||
|
||||
@@ -2370,7 +2145,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();
|
||||
@@ -2496,6 +2271,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 = {
|
||||
@@ -2643,8 +2428,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 */
|
||||
@@ -3341,7 +3126,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
|
||||
@@ -3361,9 +3173,13 @@ static void key_event(uint32_t evdev, int pressed) {
|
||||
struct seat_keyboard *sk;
|
||||
if (!keyboard_for(client)) return;
|
||||
keyboard_focus(target->resource);
|
||||
for_each_keyboard_of(client, sk)
|
||||
for_each_keyboard_of(client, sk) {
|
||||
wl_keyboard_send_key(sk->kb, wl_display_next_serial(g_display), now_ms(), evdev,
|
||||
pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
|
||||
if (mods_changed)
|
||||
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
|
||||
g_mods_depressed, 0, g_mods_locked, 0);
|
||||
}
|
||||
wl_display_flush_clients(g_display);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
|
||||
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
|
||||
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
|
||||
*/
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <android/log.h>
|
||||
#include <wayland-server.h>
|
||||
#include "vk_present.h"
|
||||
|
||||
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
|
||||
|
||||
#ifndef WLOGE
|
||||
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
|
||||
#endif
|
||||
|
||||
/* ------------------------------------------------------------------ dmabuf buffers */
|
||||
|
||||
#define FOURCC(a, b, c, d) \
|
||||
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
|
||||
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
|
||||
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
|
||||
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
|
||||
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
|
||||
#define MOD_LINEAR VKP_MOD_LINEAR
|
||||
#define MOD_INVALID VKP_MOD_INVALID
|
||||
#define MAX_PLANES 4
|
||||
|
||||
struct dmabuf_params {
|
||||
int fd[MAX_PLANES];
|
||||
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
|
||||
uint64_t modifier[MAX_PLANES];
|
||||
int n_planes;
|
||||
};
|
||||
struct dmabuf_buffer {
|
||||
int fd[MAX_PLANES];
|
||||
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
|
||||
int n_planes;
|
||||
int32_t width, height;
|
||||
uint32_t format;
|
||||
uint64_t modifier;
|
||||
struct vkp_image *img; /* imported once, reused for every frame */
|
||||
int import_failed;
|
||||
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
|
||||
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
|
||||
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
|
||||
* until the surface commits something newer, so the picture never blinks to black. */
|
||||
int refs;
|
||||
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
|
||||
};
|
||||
|
||||
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
|
||||
struct client_info {
|
||||
struct wl_client *client;
|
||||
struct wl_listener destroy;
|
||||
pid_t pid;
|
||||
char name[64];
|
||||
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
|
||||
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
|
||||
unsigned asked_feedback : 1, shm_gl_said : 1;
|
||||
/* Clients that describe opaque regions can use alpha outside those regions. Wine windows
|
||||
* never describe them, and their unused alpha channel must not make them translucent. */
|
||||
unsigned declares_opaque : 1;
|
||||
unsigned dmabuf_buffers, shm_frames;
|
||||
struct client_info *next;
|
||||
};
|
||||
|
||||
/* ---- compositor.c */
|
||||
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
|
||||
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
|
||||
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
|
||||
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
|
||||
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
|
||||
|
||||
/* ---- wl_dmabuf.c */
|
||||
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
|
||||
COMPOSITOR_INTERNAL void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id);
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "effects_chain.h"
|
||||
#include "framegen_bridge.h"
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "banner_color.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -32,6 +33,8 @@ struct vkp_image {
|
||||
void *map; /* shm images: persistently mapped linear memory */
|
||||
VkDeviceSize offset, row_pitch;
|
||||
int in_general; /* shm images: moved from PREINITIALIZED to GENERAL */
|
||||
VkFormat fmt;
|
||||
int sampled; /* created with SAMPLED usage: the alpha pass can read it */
|
||||
};
|
||||
|
||||
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
|
||||
@@ -230,6 +233,7 @@ static void destroy_swapchain(void) {
|
||||
if (g_dev_state != 1) return;
|
||||
g_vk.DeviceWaitIdle(g_dev);
|
||||
reset_sync();
|
||||
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
|
||||
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
|
||||
g_swapchain = VK_NULL_HANDLE;
|
||||
g_swap_md_identity = 0; /* a new swapchain gets the metadata again */
|
||||
@@ -525,6 +529,7 @@ static int dev_init(void) {
|
||||
|
||||
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
|
||||
hdrc_bind_device(g_dev, &g_memprops);
|
||||
blendp_bind_device(g_dev);
|
||||
g_dev_state = 1;
|
||||
reset_sync();
|
||||
init_black_image();
|
||||
@@ -873,9 +878,8 @@ const char *vkp_modifier_name(uint64_t modifier) {
|
||||
return other;
|
||||
}
|
||||
|
||||
/* Can the driver create the image vkp_image_import_dmabuf() creates for this format+modifier
|
||||
* (2D, DRM_FORMAT_MODIFIER tiling, TRANSFER_SRC, dma-buf memory) - and import it? */
|
||||
static int modifier_importable(VkFormat fmt, uint64_t modifier) {
|
||||
/* Can the driver create and import this format/modifier with the requested image usage? */
|
||||
static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlags usage) {
|
||||
if (!g_vk.GetPhysicalDeviceImageFormatProperties2) return 1; /* can't ask; the create will tell */
|
||||
VkPhysicalDeviceExternalImageFormatInfo ext = {
|
||||
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO,
|
||||
@@ -886,7 +890,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier) {
|
||||
VkPhysicalDeviceImageFormatInfo2 info = {
|
||||
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, .pNext = &mod,
|
||||
.format = fmt, .type = VK_IMAGE_TYPE_2D, .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT};
|
||||
.usage = usage};
|
||||
VkExternalImageFormatProperties extp = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES};
|
||||
VkImageFormatProperties2 props = {.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, .pNext = &extp};
|
||||
if (g_vk.GetPhysicalDeviceImageFormatProperties2(g_pd, &info, &props) != VK_SUCCESS) return 0;
|
||||
@@ -914,7 +918,7 @@ int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
|
||||
if (m != VKP_MOD_LINEAR && m != VKP_MOD_QCOM_COMPRESSED) why = "unknown layout";
|
||||
else if (props[i].drmFormatModifierPlaneCount != 1) why = "not single-plane";
|
||||
else if (!(props[i].drmFormatModifierTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) why = "no blit source";
|
||||
else if (!modifier_importable(fmt, m)) why = "not importable as a dma-buf";
|
||||
else if (!modifier_importable(fmt, m, VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) why = "not importable as a dma-buf";
|
||||
if (why) {
|
||||
LOGI("dmabuf: %c%c%c%c %s reported by the driver but not advertised: %s",
|
||||
drm_format & 0xff, (drm_format >> 8) & 0xff, (drm_format >> 16) & 0xff,
|
||||
@@ -942,6 +946,10 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = w; img->h = h; img->dmabuf = 1; img->blit_dst = as_blit_dst ? 1 : 0;
|
||||
img->fmt = drm_to_vk(drm_format);
|
||||
const VkImageUsageFlags sampled_usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
|
||||
img->sampled = !as_blit_dst && g_vk.GetPhysicalDeviceImageFormatProperties2 &&
|
||||
modifier_importable(img->fmt, modifier, sampled_usage);
|
||||
|
||||
VkSubresourceLayout plane = {.offset = offset, .rowPitch = stride};
|
||||
VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {
|
||||
@@ -952,10 +960,11 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT};
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &extImg,
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = drm_to_vk(drm_format), .extent = {w, h, 1},
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = img->fmt, .extent = {w, h, 1},
|
||||
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
|
||||
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT
|
||||
: img->sampled ? sampled_usage : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
|
||||
if (cr != VK_SUCCESS) {
|
||||
@@ -1008,18 +1017,35 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Linear wl_shm images can use the blend pass only if the driver samples them with a linear filter. */
|
||||
static int shm_sampled(void) {
|
||||
static int known;
|
||||
if (!known) {
|
||||
const VkFormatFeatureFlags need = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT |
|
||||
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
|
||||
VkFormatProperties2 fp = {.sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2};
|
||||
if (g_vk.GetPhysicalDeviceFormatProperties2)
|
||||
g_vk.GetPhysicalDeviceFormatProperties2(g_pd, VK_FORMAT_B8G8R8A8_UNORM, &fp);
|
||||
known = (fp.formatProperties.linearTilingFeatures & need) == need ? 1 : -1;
|
||||
}
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
struct vkp_image *vkp_image_create_shm(int w, int h) {
|
||||
if (w <= 0 || h <= 0 || dev_init() != 0) return NULL;
|
||||
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = w; img->h = h;
|
||||
img->fmt = VK_FORMAT_B8G8R8A8_UNORM;
|
||||
img->sampled = shm_sampled();
|
||||
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
|
||||
.format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
|
||||
.format = img->fmt, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
|
||||
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | (img->sampled ? VK_IMAGE_USAGE_SAMPLED_BIT : 0),
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_PREINITIALIZED};
|
||||
if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return NULL; }
|
||||
|
||||
@@ -1062,6 +1088,7 @@ int vkp_image_height(const struct vkp_image *img) { return img ? img->h : 0; }
|
||||
void vkp_image_destroy(struct vkp_image *img) {
|
||||
if (!img) return;
|
||||
if (g_dev) {
|
||||
blendp_forget_image(img->image);
|
||||
if (img->map) g_vk.UnmapMemory(g_dev, img->mem);
|
||||
if (img->image) g_vk.DestroyImage(g_dev, img->image, NULL);
|
||||
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
|
||||
@@ -1111,8 +1138,21 @@ static int draw_to_scene_blit(const struct vkp_draw *d, int scene_w, int scene_h
|
||||
return map_draw(d, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, (float)scene_w, (float)scene_h, blit);
|
||||
}
|
||||
|
||||
/* A translucent draw needs source-over blending; every other draw keeps the existing blit path. */
|
||||
static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkImageBlit *blit, VkImage target,
|
||||
VkFormat target_fmt, int target_w, int target_h, VkFilter filter) {
|
||||
const struct vkp_image *im = d->img;
|
||||
if (d->blend && im->sampled &&
|
||||
blendp_draw(cmd, im->image, im->fmt, im->w, im->h, !im->dmabuf, target, target_fmt, target_w, target_h,
|
||||
blit) == 0)
|
||||
return;
|
||||
g_vk.CmdBlitImage(cmd, im->image, im->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, blit, filter);
|
||||
}
|
||||
|
||||
static void destroy_scene_image(void) {
|
||||
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
|
||||
if (g_scene.img) blendp_forget_image(g_scene.img);
|
||||
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
|
||||
if (g_scene.mem) g_vk.FreeMemory(g_dev, g_scene.mem, NULL);
|
||||
memset(&g_scene, 0, sizeof(g_scene));
|
||||
@@ -1150,6 +1190,7 @@ static int ensure_scene_image(int w, int h) {
|
||||
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
|
||||
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
|
||||
if (s->img) hdrc_forget_image(s->img);
|
||||
if (s->img) blendp_forget_image(s->img);
|
||||
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
|
||||
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
|
||||
memset(s, 0, sizeof(*s));
|
||||
@@ -1190,7 +1231,8 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
int scene_w, int scene_h, VkImage out, VkFormat out_fmt, enum hdrc_mode mode) {
|
||||
if (!hf || !hf->is_hdr || n <= 0) return -1;
|
||||
if (ensure_img(&g_mixed, scene_w, scene_h, HDR_FMT,
|
||||
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
|
||||
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
|
||||
"HDR composition (mixed)") != 0)
|
||||
return -1;
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
@@ -1214,9 +1256,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
|
||||
/* A blit converts UNORM to UNORM by value: 8-bit sRGB and 10-bit PQ both land unchanged. */
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
g_mixed.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
|
||||
record_draw(cmd, &draws[i], &blit, g_mixed.img, HDR_FMT, scene_w, scene_h, VK_FILTER_LINEAR);
|
||||
if (hf->is_hdr[i] && lowest_hdr < 0) lowest_hdr = i;
|
||||
}
|
||||
static int truncated_said;
|
||||
@@ -1249,6 +1289,7 @@ static void device_lost(const char *where) {
|
||||
vkp_framegen_device_lost();
|
||||
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
|
||||
"restart the session", where);
|
||||
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
|
||||
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
|
||||
g_swapchain = VK_NULL_HANDLE;
|
||||
if (g_surface) g_vk.DestroySurfaceKHR(g_inst, g_surface, NULL);
|
||||
@@ -1547,6 +1588,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
g_vk.BeginCommandBuffer(cmd, &bi);
|
||||
blendp_begin_frame();
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
/* Source images: take dmabufs from the client's queue family, move shm images to
|
||||
@@ -1589,8 +1631,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
free(bars);
|
||||
|
||||
/* Clear the composition target to black (the letterbox on the direct path, the desktop's
|
||||
* background on the pass) before the draws land on it. A draw that covers the whole scene
|
||||
* overwrites every pixel of its rectangle (blits never blend), so then only what lies outside it
|
||||
* background on the pass) before the draws land on it. An opaque draw that covers the whole scene
|
||||
* overwrites every pixel of its rectangle, so then only what lies outside it
|
||||
* needs black: the letterbox bars on the direct path, nothing at all on the pass (the scene image
|
||||
* is exactly the scene). Anything else - no such draw, the HDR composition - clears the whole
|
||||
* target as before. */
|
||||
@@ -1600,7 +1642,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
if (!(hf && pass)) {
|
||||
for (int i = n - 1; i >= 0 && !covered; i--) {
|
||||
const struct vkp_draw *d = &draws[i];
|
||||
if (!d->img || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w || d->dy + d->dh < scene_h) continue;
|
||||
if (!d->img || d->blend || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w ||
|
||||
d->dy + d->dh < scene_h) continue;
|
||||
covered = pass ? draw_to_scene_blit(d, scene_w, scene_h, &cover_blit) : draw_to_blit(d, &cover_blit);
|
||||
}
|
||||
}
|
||||
@@ -1633,10 +1676,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img) continue;
|
||||
if (pass ? !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit) : !draw_to_blit(&draws[i], &blit)) continue;
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit,
|
||||
pass ? VK_FILTER_LINEAR : blit_filter);
|
||||
record_draw(cmd, &draws[i], &blit, target, pass ? scene_fmt : g_swap_fmt,
|
||||
pass ? scene_w : (int)g_extent.width, pass ? scene_h : (int)g_extent.height,
|
||||
pass ? VK_FILTER_LINEAR : blit_filter);
|
||||
drawn++;
|
||||
}
|
||||
|
||||
@@ -2073,6 +2115,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
g_vk.BeginCommandBuffer(cmd, &bi);
|
||||
blendp_begin_frame();
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
/* Same acquisition as vkp_render's compositor pass: the sources come from the client's queue
|
||||
@@ -2132,9 +2175,8 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
for (int i = 0; i < n; i++) {
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
g_scene.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
|
||||
record_draw(cmd, &draws[i], &blit, g_scene.img, SCENE_FMT, scene_w, scene_h,
|
||||
VK_FILTER_LINEAR);
|
||||
}
|
||||
if (vkp_effects_active()) vkp_effects_set_formats(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ struct vkp_draw {
|
||||
struct vkp_image *img;
|
||||
float sx, sy, sw, sh;
|
||||
int dx, dy, dw, dh;
|
||||
int blend; /* translucent: composed over what is under it (blend_pass.h) */
|
||||
};
|
||||
|
||||
// The GPU the renderer runs on ("Adreno (TM) 750"), empty before the device is up.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* zwp_linux_dmabuf_v1: the buffer params a client builds a dma-buf wl_buffer from, the format and
|
||||
* modifier table the renderer can import, and dma-buf feedback (version 4). Split out of
|
||||
* compositor.c; the buffer records themselves are in compositor_internal.h.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
#include <wayland-server.h>
|
||||
|
||||
#include "linux-dmabuf-v1-server-protocol.h"
|
||||
#include "vk_present.h"
|
||||
#include "banner_ext.h"
|
||||
#include "banner_color.h"
|
||||
#include "compositor_internal.h"
|
||||
|
||||
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
|
||||
|
||||
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
|
||||
dmabuf_buffer_unref(wl_resource_get_user_data(r));
|
||||
}
|
||||
|
||||
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
|
||||
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
if (plane >= MAX_PLANES) { close(fd); return; }
|
||||
if (p->fd[plane] >= 0) close(p->fd[plane]);
|
||||
p->fd[plane] = fd;
|
||||
p->offset[plane] = offset;
|
||||
p->stride[plane] = stride;
|
||||
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
|
||||
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
|
||||
}
|
||||
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
|
||||
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
|
||||
if (!b) return NULL;
|
||||
b->n_planes = p->n_planes;
|
||||
b->refs = 1; /* the wl_buffer resource's */
|
||||
b->width = w; b->height = h; b->format = format;
|
||||
b->modifier = p->modifier[0];
|
||||
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
|
||||
for (int i = 0; i < p->n_planes; i++) {
|
||||
b->fd[i] = p->fd[i];
|
||||
b->offset[i] = p->offset[i];
|
||||
b->stride[i] = p->stride[i];
|
||||
p->fd[i] = -1; /* ownership moves to the buffer */
|
||||
}
|
||||
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
|
||||
if (!buf) {
|
||||
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
|
||||
free(b);
|
||||
wl_client_post_no_memory(c);
|
||||
return NULL;
|
||||
}
|
||||
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->dmabuf_buffers++;
|
||||
return buf;
|
||||
}
|
||||
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
|
||||
uint32_t format, uint32_t flags) {
|
||||
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
|
||||
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
|
||||
else zwp_linux_buffer_params_v1_send_failed(r);
|
||||
}
|
||||
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
|
||||
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
|
||||
params_do_create(c, r, buffer_id, w, h, format, flags);
|
||||
}
|
||||
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
|
||||
.destroy = params_destroy,
|
||||
.add = params_add,
|
||||
.create = params_create,
|
||||
.create_immed = params_create_immed,
|
||||
};
|
||||
static void params_resource_destroy(struct wl_resource *r) {
|
||||
struct dmabuf_params *p = wl_resource_get_user_data(r);
|
||||
if (!p) return;
|
||||
for (int i = 0; i < MAX_PLANES; i++)
|
||||
if (p->fd[i] >= 0) close(p->fd[i]);
|
||||
free(p);
|
||||
}
|
||||
|
||||
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
|
||||
struct dmabuf_params *p = calloc(1, sizeof(*p));
|
||||
if (!p) { wl_client_post_no_memory(c); return; }
|
||||
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
|
||||
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
|
||||
wl_resource_get_version(r), id);
|
||||
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
|
||||
wl_resource_set_implementation(pr, ¶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));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Application
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.session.CrashHandler
|
||||
|
||||
/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */
|
||||
/**
|
||||
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
|
||||
* the socket Flatpak's sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
BwrapSpawner.start(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
|
||||
/**
|
||||
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
|
||||
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
|
||||
var compCatalogAt by mutableStateOf(0L)
|
||||
var compProton by mutableStateOf<String?>(null)
|
||||
var compComp by mutableStateOf("dxvk")
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
|
||||
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
|
||||
fun refreshComponents(snapshotFirst: Boolean = false) {
|
||||
Thread({
|
||||
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(activity) }
|
||||
runCatching { ComponentsManager.applyQueued(activity) }
|
||||
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
|
||||
ui.post {
|
||||
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
|
||||
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
|
||||
}
|
||||
}, "components").start()
|
||||
}
|
||||
|
||||
fun componentAction(label: String, work: () -> String) {
|
||||
if (compBusy != null) return
|
||||
compBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
compBusy = null
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshComponents()
|
||||
}
|
||||
}, "components-action").start()
|
||||
}
|
||||
|
||||
fun refreshComponentCatalog() {
|
||||
if (compChecking) return
|
||||
compChecking = true
|
||||
Thread({
|
||||
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
|
||||
ui.post {
|
||||
compChecking = false
|
||||
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
|
||||
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
|
||||
}
|
||||
}, "components-catalog").start()
|
||||
}
|
||||
|
||||
fun downloadComponent(item: ComponentsManager.CatalogItem) {
|
||||
if (compDownloads.containsKey(item.file)) return
|
||||
compDownloads = compDownloads + (item.file to -1)
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
|
||||
"Stored ${pkg.version}"
|
||||
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
compDownloads = compDownloads - item.file
|
||||
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshComponents()
|
||||
}
|
||||
}, "components-download").start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
|
||||
* in progress. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var deckyInstalled by mutableStateOf<String?>(null)
|
||||
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
|
||||
var deckyChecking by mutableStateOf(false)
|
||||
var deckyStage by mutableStateOf<String?>(null)
|
||||
var deckyPercent by mutableIntStateOf(-1)
|
||||
var deckySupervisor by mutableStateOf(false)
|
||||
var deckyReleaseRequest = 0
|
||||
|
||||
fun refreshDecky() {
|
||||
val request = ++deckyReleaseRequest
|
||||
deckyChecking = true
|
||||
Thread({
|
||||
val channels = runCatching { DeckyManager.releaseChannels(activity) }
|
||||
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
|
||||
ui.post {
|
||||
if (request == deckyReleaseRequest) {
|
||||
// Use the newest compatible stable build, or the newest compatible
|
||||
// prerelease when the fork has not published a stable ARM64 asset.
|
||||
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
|
||||
deckyChecking = false
|
||||
}
|
||||
}
|
||||
}, "decky-releases").start()
|
||||
}
|
||||
|
||||
fun installDecky(release: DeckyManager.Release) {
|
||||
if (deckyStage != null || SessionState.running) return
|
||||
deckyStage = "Starting…"; deckyPercent = -1
|
||||
Thread({
|
||||
val problem = runCatching {
|
||||
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
|
||||
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "install-decky").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
|
||||
/**
|
||||
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
|
||||
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
|
||||
* rows to the mode settings dialog.
|
||||
*/
|
||||
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
|
||||
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
|
||||
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
|
||||
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
|
||||
var releaseChecking by mutableStateOf(false)
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
private val releaseProgress = HashMap<String, Int>()
|
||||
var linuxSteam by mutableStateOf("")
|
||||
var linuxDesktop by mutableStateOf("")
|
||||
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
fun refreshDrivers() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
|
||||
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
|
||||
).joinToString(" · "),
|
||||
true, origin(id),
|
||||
)
|
||||
}
|
||||
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
|
||||
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
|
||||
val td = TurnipDriver(activity)
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
add(DriverRow(
|
||||
TurnipDriver.AUTO, "Auto - picked by GPU",
|
||||
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
}
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
refreshReleaseRows()
|
||||
}
|
||||
|
||||
/**
|
||||
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
|
||||
* library - then say what happened. A refusal's message is the user-facing reason.
|
||||
*/
|
||||
fun importDriver(uri: Uri, linux: Boolean) {
|
||||
val name = activity.displayNameOf(uri)
|
||||
Thread({
|
||||
val problem = try {
|
||||
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
else TurnipDriver(activity).installFromZip(uri, name)
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "driver import", e)
|
||||
"Import failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Imported ${name ?: "driver"}",
|
||||
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "import-driver").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
|
||||
* session never starts on a driver that is gone; a release download is forgotten, so the menu
|
||||
* offers it again.
|
||||
*/
|
||||
fun deleteDriver(id: String, linux: Boolean) {
|
||||
if (linux) {
|
||||
LinuxVulkanDriverManager(activity).removeDriver(id)
|
||||
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
|
||||
}
|
||||
} else {
|
||||
val td = TurnipDriver(activity)
|
||||
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
|
||||
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** The download entries and the refresh line, from what the last check found. */
|
||||
fun refreshReleaseRows() {
|
||||
val check = TurnipReleases.cached(activity)
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
fun rows(linux: Boolean) = check?.assets.orEmpty()
|
||||
.filter { it.linux == linux }
|
||||
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
}
|
||||
linuxDownloads = rows(linux = true)
|
||||
androidDownloads = rows(linux = false)
|
||||
if (!releaseChecking) releaseStatus = when (check) {
|
||||
null -> "Not checked yet - tap refresh to look for new drivers"
|
||||
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
|
||||
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
|
||||
" · checked ${ago(check.checkedAt)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ago(t: Long): String {
|
||||
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
|
||||
return when {
|
||||
m < 1 -> "just now"
|
||||
m < 60 -> "$m min ago"
|
||||
m < 48 * 60 -> "${m / 60} h ago"
|
||||
else -> "${m / (24 * 60)} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
/** Only when the user taps refresh: nothing goes online on its own. */
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip and WinNative…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
}
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
}
|
||||
|
||||
/** Download one release driver and import it through the same importer a picked zip uses. */
|
||||
fun downloadReleaseDriver(assetName: String) {
|
||||
val asset = TurnipReleases.cached(activity)?.assets?.firstOrNull { it.name == assetName } ?: return
|
||||
if (releaseProgress.containsKey(assetName)) return
|
||||
releaseProgress[assetName] = 0
|
||||
refreshReleaseRows()
|
||||
Thread({
|
||||
var file: java.io.File? = null
|
||||
val problem = try {
|
||||
file = TurnipReleases.download(activity, asset) { pct ->
|
||||
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
|
||||
}
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "download-turnip").start()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "MainActivity"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Context.displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -97,13 +97,11 @@ object HomeApp {
|
||||
val intent = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(app.packageName, app.className))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (displayId == null) {
|
||||
context.startActivity(intent)
|
||||
return
|
||||
}
|
||||
// Keep the target app's task separate from DroidDeck. Without this, Android can put the
|
||||
// app on top of the caller's task and move the entire task to the chosen display.
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
|
||||
context.startActivity(intent, options.toBundle())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.ui.ProtonRow
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
|
||||
* removal in progress. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
|
||||
var protonBusyId by mutableStateOf<String?>(null)
|
||||
var protonStage by mutableStateOf<String?>(null)
|
||||
var protonPercent by mutableIntStateOf(-1)
|
||||
|
||||
fun installProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
ProtonExtras.unqueue(activity, tool)
|
||||
protonBusyId = id
|
||||
protonStage = "Starting…"
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
val problem = ProtonExtras.install(activity, tool) { label, value ->
|
||||
ui.post { protonStage = label; protonPercent = value }
|
||||
}
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
protonPercent = -1
|
||||
refreshProtons()
|
||||
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "install-proton-$id").start()
|
||||
}
|
||||
|
||||
fun removeProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = "Removing ${tool.name}…"
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
ProtonExtras.remove(activity, tool)
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
refreshProtons()
|
||||
}
|
||||
}, "remove-proton-$id").start()
|
||||
}
|
||||
|
||||
fun refreshProtons() {
|
||||
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
@@ -45,7 +47,6 @@ import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHints
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -69,6 +70,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
|
||||
|
||||
/**
|
||||
@@ -88,6 +91,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var loading: LoadingState
|
||||
private lateinit var hud: PerfHud
|
||||
private var padBridge: PadBridge? = null
|
||||
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
|
||||
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var watching = true
|
||||
@@ -95,6 +109,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
|
||||
private var cursorVisible by mutableStateOf(false)
|
||||
/** The blue the front end flooded to on the way in; the loading screen opens on it (LaunchFlood). */
|
||||
private var floodColor by mutableStateOf<Int?>(null)
|
||||
private val cursorHide = Runnable { cursorVisible = false }
|
||||
/** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */
|
||||
private var lastPadInputMs = 0L
|
||||
@@ -140,6 +156,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
// Compose reads these; the activity writes them.
|
||||
private var drawerOpen by mutableStateOf(false)
|
||||
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
|
||||
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
|
||||
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
|
||||
private var quitFlooded = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerPage by mutableIntStateOf(0)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
@@ -154,9 +176,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(-1)
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
|
||||
private var started = false
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private var secondScreenPresentation: SecondScreenPresentation? = null
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
@@ -191,8 +215,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
|
||||
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
|
||||
// Game-tier power policy for the whole session: the panel's fastest mode, and the OS told
|
||||
// it is in gameplay. Logged so a slow device says why.
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this))
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
@@ -212,6 +236,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
padBridge = bridge
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
}
|
||||
SessionState.deckPadListener = deckPadListener
|
||||
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
|
||||
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
|
||||
bridge.setOnPlayerInput {
|
||||
@@ -228,12 +257,14 @@ 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 needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
|
||||
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
|
||||
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
|
||||
if (needRuntime || needDesktop) installThenStart(needRuntime, needDesktop)
|
||||
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
|
||||
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
|
||||
}
|
||||
hud = PerfHud(this)
|
||||
hud.onPresentingWindowChanged = {
|
||||
@@ -246,121 +277,17 @@ 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,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
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.
|
||||
@@ -369,6 +296,7 @@ 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()
|
||||
@@ -378,6 +306,147 @@ 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,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
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()
|
||||
@@ -408,7 +477,8 @@ 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) {
|
||||
@@ -424,6 +494,62 @@ 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) {
|
||||
@@ -471,17 +597,23 @@ 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) {
|
||||
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
|
||||
if (SessionState.stopRequested) return
|
||||
installingRuntime = true
|
||||
SessionState.installing = if (runtime) "runtime" else "desktop"
|
||||
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
|
||||
SessionEvents.transition(
|
||||
SessionPhase.INSTALLING_RUNTIME,
|
||||
if (runtime) "runtime.installing" else "desktop.installing",
|
||||
"${SessionState.installing}.installing",
|
||||
mapOf("component" to SessionState.installing),
|
||||
)
|
||||
loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop"
|
||||
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
|
||||
else "downloading Proton Experimental (ARM64)"
|
||||
loading.percent = -1
|
||||
Thread({
|
||||
var failedComponent: String? = null
|
||||
@@ -496,6 +628,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
problem = installDesktop()
|
||||
if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop"
|
||||
}
|
||||
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
|
||||
@@ -511,6 +650,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.fail(code, problem)
|
||||
collectStartArtifacts("session start failed")
|
||||
loading.showEnded(problem)
|
||||
focusEndedScreen()
|
||||
return@post
|
||||
}
|
||||
loading.percent = -1
|
||||
@@ -561,6 +701,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -629,9 +778,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
|
||||
)
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
|
||||
// period, so a long frame raises CPU clocks now rather than after the load averages up.
|
||||
PerfHints.arm(this, refreshHz())
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
// running - NOT only when the compositor was just started: the compositor lives for the
|
||||
// whole process, so the second Play after a session ended used to re-attach the Surface,
|
||||
@@ -717,9 +863,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return Pair(width and 1.inv(), height and 1.inv())
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
|
||||
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
|
||||
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
|
||||
*/
|
||||
private fun refreshHz(): Float {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
|
||||
val hz = display?.refreshRate ?: 60f
|
||||
val wanted = window.attributes.preferredDisplayModeId
|
||||
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
?: display?.refreshRate ?: 60f
|
||||
return if (hz > 1f) hz else 60f
|
||||
}
|
||||
|
||||
@@ -791,19 +944,35 @@ 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.
|
||||
val what = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
|
||||
SessionService.MODE_DESKTOP -> "The desktop"
|
||||
else -> "Steam"
|
||||
}
|
||||
loading.showEnded(
|
||||
hint ?: "$what 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -851,10 +1020,26 @@ 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
|
||||
@@ -1112,11 +1297,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
|
||||
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
|
||||
// A choice kept while its display was away (asleep, waking): back as soon as it is.
|
||||
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
private fun selectSecondScreenDisplay(displayId: Int) {
|
||||
if (secondScreenDisplays.none { it.id == displayId }) return
|
||||
selectedSecondScreenDisplay = displayId
|
||||
SessionState.secondScreenDisplay = displayId
|
||||
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
@@ -1138,8 +1326,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun showSecondScreen(mode: SecondScreenMode) {
|
||||
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
|
||||
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
|
||||
closeSecondScreen(reset = true)
|
||||
refreshSecondScreenDisplays()
|
||||
// Not there right now - a panel still waking, say. The choice is kept; the display
|
||||
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
|
||||
closeSecondScreen(reset = false)
|
||||
return
|
||||
}
|
||||
var presentation = secondScreenPresentation
|
||||
@@ -1171,6 +1360,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (!presentation.isShowing) presentation.show()
|
||||
presentation.showMode(mode)
|
||||
secondScreenMode = mode
|
||||
SessionState.secondScreenMode = mode
|
||||
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
|
||||
closeSecondScreen(reset = true)
|
||||
@@ -1190,7 +1381,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun closeSecondScreen(reset: Boolean) {
|
||||
secondScreenPresentation?.closeControls()
|
||||
secondScreenPresentation = null
|
||||
if (reset) secondScreenMode = SecondScreenMode.NONE
|
||||
if (reset) {
|
||||
secondScreenMode = SecondScreenMode.NONE
|
||||
SessionState.secondScreenMode = SecondScreenMode.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
|
||||
@@ -1303,15 +1497,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
started = true
|
||||
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||
refreshSecondScreenDisplays()
|
||||
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
|
||||
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
SessionService.setActivityVisible(this, true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
started = false
|
||||
displayManager.unregisterDisplayListener(displayListener)
|
||||
closeSecondScreen(reset = true)
|
||||
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
|
||||
// onStart puts it back.
|
||||
closeSecondScreen(reset = false)
|
||||
SessionService.setActivityVisible(this, false)
|
||||
super.onStop()
|
||||
}
|
||||
@@ -1338,6 +1536,7 @@ 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
|
||||
@@ -1347,15 +1546,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
|
||||
onScreenControls?.reload()
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
}
|
||||
|
||||
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.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
}
|
||||
@@ -1391,6 +1598,13 @@ 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
|
||||
@@ -1402,21 +1616,34 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = true)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.setFirstFrameListener(null)
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
|
||||
private fun finishFlooded() {
|
||||
if (isFinishing) return
|
||||
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
|
||||
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
|
||||
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
|
||||
quitFlooded = true
|
||||
SessionService.stop(this)
|
||||
finish()
|
||||
}
|
||||
|
||||
/** Leaving the session sinks its surface back down onto the front end. */
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionActivity"
|
||||
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
|
||||
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
|
||||
private const val CURSOR_PAD_HOLD_MS = 1200L
|
||||
private const val DRAWER_HAT_THRESHOLD = 0.5f
|
||||
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.droiddeck.launcher.core;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Where an archive entry may land. An entry name ("../x", "/etc/x") or a symlink already written
|
||||
* by an earlier entry can point outside the directory being filled; the canonical path follows
|
||||
* both, so anything that does not resolve inside it is refused.
|
||||
*/
|
||||
public final class ArchivePaths {
|
||||
private ArchivePaths() {}
|
||||
|
||||
/**
|
||||
* The file for {@code name} under {@code destination}, or null when it would resolve outside
|
||||
* it. The destination itself counts as inside (a "./" entry).
|
||||
*/
|
||||
public static File inside(File destination, String name) throws IOException {
|
||||
String base = destination.getCanonicalPath();
|
||||
File file = new File(destination, name);
|
||||
String path = file.getCanonicalPath();
|
||||
if (path.equals(base) || path.startsWith(base + File.separator)) return file;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
@@ -145,9 +144,12 @@ object DeviceReport {
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
|
||||
k("Skip xalia", SessionPrefs.noXalia(context))
|
||||
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
@@ -162,21 +164,7 @@ object DeviceReport {
|
||||
})
|
||||
|
||||
h("Android process limits")
|
||||
// Android 12 kills "phantom" processes - the children an app forks itself rather than
|
||||
// starting through the framework - once there are more than a handful of them. A session is
|
||||
// nothing but those: proot, gamescope, Xwayland, the client, its helpers, Wine, FEX. Where
|
||||
// this is left on, the OS kills the session and nothing in our logs says why, because
|
||||
// nothing in the session did it. Some ROMs expose it in Developer options as a
|
||||
// "restrict child processes" switch; otherwise it is
|
||||
// adb shell settings put global settings_enable_monitor_phantom_procs false
|
||||
val phantom = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "settings_enable_monitor_phantom_procs")
|
||||
}.getOrNull()
|
||||
k("Phantom proc monitor", when (phantom?.lowercase()) {
|
||||
"false", "0" -> "disabled (good - the OS will not kill the session's children)"
|
||||
null, "" -> "not set (ROM default, usually ENABLED - see the note below)"
|
||||
else -> "ENABLED (the OS may kill the session with no log; turn off \"restrict child processes\")"
|
||||
})
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
|
||||
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
|
||||
* session's environment; the games under FEX inherit them from the client. The default is FEX's
|
||||
* own defaults - no variables at all - which is what every session has run on so far.
|
||||
*
|
||||
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
|
||||
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
|
||||
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
|
||||
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
|
||||
*/
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object FexPreset {
|
||||
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
|
||||
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
|
||||
|
||||
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
|
||||
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
|
||||
)
|
||||
|
||||
val all: List<Preset> = listOf(
|
||||
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
|
||||
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
|
||||
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
|
||||
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
|
||||
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
|
||||
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
|
||||
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
|
||||
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
|
||||
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
|
||||
)
|
||||
|
||||
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
|
||||
|
||||
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
|
||||
/** Game-launch variables in KEY=VALUE form. */
|
||||
fun env(id: String): List<String> = byId(id).env
|
||||
}
|
||||
@@ -104,6 +104,7 @@ public final class FileUtils {
|
||||
public static String sizeToString(long bytes) {
|
||||
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
|
||||
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
|
||||
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
|
||||
return bytes + " B";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
object GameEnvironment {
|
||||
data class Config(
|
||||
val shared: Map<String, String?> = emptyMap(),
|
||||
val games: Map<String, Map<String, String?>> = emptyMap(),
|
||||
) {
|
||||
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
|
||||
|
||||
fun withEntries(scope: String, entries: Map<String, String?>): Config =
|
||||
if (scope.isEmpty()) copy(shared = entries.toMap())
|
||||
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
|
||||
}
|
||||
|
||||
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val unused = setOf(
|
||||
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
|
||||
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
|
||||
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
|
||||
)
|
||||
|
||||
fun validName(name: String) = namePattern.matches(name)
|
||||
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object GameEnvironmentOptions {
|
||||
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
|
||||
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
|
||||
|
||||
val all = listOf(
|
||||
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
|
||||
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
|
||||
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
|
||||
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
|
||||
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
|
||||
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
|
||||
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
|
||||
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
|
||||
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
|
||||
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
|
||||
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
|
||||
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
|
||||
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
|
||||
)
|
||||
|
||||
fun find(name: String): Option? = all.firstOrNull { it.name == name }
|
||||
|
||||
fun toggle(value: String, choice: String): String {
|
||||
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
|
||||
if (!selected.remove(choice)) selected.add(choice)
|
||||
return selected.joinToString(",")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** File checksums as lowercase hex, and GitHub's release-asset digests, in one place. */
|
||||
object Hashes {
|
||||
private val GITHUB_SHA256 = Regex("(?i)sha256:[0-9a-f]{64}")
|
||||
|
||||
@JvmStatic
|
||||
fun sha256(file: File): String = hex(file, "SHA-256")
|
||||
|
||||
@JvmStatic
|
||||
fun sha512(file: File): String = hex(file, "SHA-512")
|
||||
|
||||
/** True when [digest] is GitHub's asset digest form, "sha256:" and 64 hex digits. */
|
||||
@JvmStatic
|
||||
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
|
||||
|
||||
/** The hex of a GitHub asset digest ("sha256:<hex>"), or null when [digest] is not one. */
|
||||
@JvmStatic
|
||||
fun githubSha256(digest: String?): String? = digest?.takeIf { isGithubSha256(it) }?.substringAfter(':')
|
||||
|
||||
private fun hex(file: File, algorithm: String): String {
|
||||
val digest = MessageDigest.getInstance(algorithm)
|
||||
file.inputStream().buffered(1 shl 16).use { input ->
|
||||
val buffer = ByteArray(1 shl 20)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
digest.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,8 @@ object HostProcess {
|
||||
}
|
||||
}
|
||||
|
||||
private fun pidOf(process: Process): Int = try {
|
||||
@JvmStatic
|
||||
fun pidOf(process: Process): Int = try {
|
||||
val field = process.javaClass.getDeclaredField("pid")
|
||||
field.isAccessible = true
|
||||
val pid = field.getInt(process)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
|
||||
enum class PhantomProcessStatus {
|
||||
NOT_APPLICABLE,
|
||||
DISABLED,
|
||||
ENABLED,
|
||||
UNSET,
|
||||
UNREADABLE,
|
||||
}
|
||||
|
||||
object PhantomProcessLimit {
|
||||
private const val SETTING = "settings_enable_monitor_phantom_procs"
|
||||
|
||||
private const val MAX_PHANTOM = "2147483647"
|
||||
private const val PREFS = "phantom-process-limit"
|
||||
private const val PREF_ANDROID_12_OFF = "android12-off"
|
||||
|
||||
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
|
||||
|
||||
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
|
||||
usesDeviceConfig(sdk) && enabled -> listOf(
|
||||
"device_config delete activity_manager max_phantom_processes",
|
||||
"device_config set_sync_disabled_for_tests none",
|
||||
)
|
||||
usesDeviceConfig(sdk) -> listOf(
|
||||
"device_config set_sync_disabled_for_tests persistent",
|
||||
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
|
||||
)
|
||||
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
|
||||
}
|
||||
|
||||
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
|
||||
else "settings get global $SETTING"
|
||||
|
||||
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
|
||||
val value = output.trim()
|
||||
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
|
||||
else value == if (enabled) "true" else "false"
|
||||
}
|
||||
|
||||
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
|
||||
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
|
||||
|
||||
fun adbCommand(): String = adbCommand(false)
|
||||
|
||||
fun rememberAndroid12(context: Context, off: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
|
||||
}
|
||||
|
||||
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
|
||||
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
|
||||
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 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
|
||||
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
|
||||
|
||||
fun title(status: PhantomProcessStatus): String = when (status) {
|
||||
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
|
||||
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
|
||||
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
|
||||
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
|
||||
}
|
||||
|
||||
fun instructions(status: PhantomProcessStatus): String = when (status) {
|
||||
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, 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)"
|
||||
PhantomProcessStatus.UNSET -> "not set (ROM default applies; DroidDeck requires an explicit disabled value)"
|
||||
PhantomProcessStatus.UNREADABLE -> "unreadable (DroidDeck could not verify the setting)"
|
||||
PhantomProcessStatus.NOT_APPLICABLE -> "not applicable before Android 12"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
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
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.ArrayDeque
|
||||
|
||||
/** A narrowly scoped Wireless debugging client for the child-process setting repair. */
|
||||
object WirelessAdbFix {
|
||||
private const val CERT_FILE = "wireless-adb-cert.pem"
|
||||
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?)
|
||||
|
||||
@Synchronized
|
||||
private fun loadOrCreateIdentity(context: Context) {
|
||||
val directory = context.noBackupFilesDir
|
||||
val cert = File(directory, CERT_FILE)
|
||||
val key = File(directory, KEY_FILE)
|
||||
if (cert.isFile && key.isFile) {
|
||||
KadbCert.set(cert.readBytes(), key.readBytes())
|
||||
return
|
||||
}
|
||||
|
||||
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
|
||||
KadbCert.set(identity.first, identity.second)
|
||||
writeAtomically(cert, identity.first)
|
||||
writeAtomically(key, identity.second)
|
||||
}
|
||||
|
||||
private fun writeAtomically(destination: File, contents: ByteArray) {
|
||||
val temporary = File(destination.parentFile, "${destination.name}.tmp")
|
||||
temporary.writeBytes(contents)
|
||||
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
|
||||
}
|
||||
|
||||
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.pair(host, port, pairingCode, "DroidDeck")
|
||||
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
|
||||
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
|
||||
}
|
||||
|
||||
private fun savedConnection(context: Context): SavedConnection? {
|
||||
val directory = context.applicationContext.noBackupFilesDir
|
||||
if (!File(directory, CERT_FILE).isFile || !File(directory, KEY_FILE).isFile) return null
|
||||
val host = File(directory, HOST_FILE).takeIf(File::isFile)?.readText()?.trim()
|
||||
?.takeIf(String::isNotEmpty) ?: return null
|
||||
val lines = File(directory, CONNECTION_FILE).takeIf(File::isFile)?.readLines()
|
||||
val port = lines?.takeIf { it.size == 2 && it[0] == host }
|
||||
?.get(1)?.toIntOrNull()?.takeIf { it in 1..65535 }
|
||||
return SavedConnection(host, port)
|
||||
}
|
||||
|
||||
/** A saved pairing skips the PIN; an unknown connection port can be filled in from Settings. */
|
||||
fun savedConnectionAddress(context: Context): String? = savedConnection(context)?.let { saved ->
|
||||
val host = if (':' in saved.host) "[${saved.host}]" else saved.host
|
||||
"$host:${saved.port ?: ""}"
|
||||
}
|
||||
|
||||
private fun saveConnection(context: Context, host: String, port: Int) {
|
||||
writeAtomically(
|
||||
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
|
||||
"$host\n$port".toByteArray(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 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)
|
||||
val queue = ArrayDeque<NsdServiceInfo>()
|
||||
val seen = mutableSetOf<String>()
|
||||
var resolving = false
|
||||
val lock = Any()
|
||||
|
||||
lateinit var resolveNext: () -> Unit
|
||||
resolveNext = {
|
||||
val next = synchronized(lock) {
|
||||
if (finished.get() || resolving || queue.isEmpty()) null
|
||||
else queue.removeFirst().also { resolving = true }
|
||||
}
|
||||
if (next != null) {
|
||||
val listener = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
synchronized(lock) { resolving = false }
|
||||
resolveNext()
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host
|
||||
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
|
||||
resolvedPort.set(serviceInfo.port)
|
||||
latch.countDown()
|
||||
} else {
|
||||
synchronized(lock) { resolving = false }
|
||||
resolveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching { manager.resolveService(next, listener) }.onFailure {
|
||||
synchronized(lock) { resolving = false }
|
||||
resolveNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val discoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) = Unit
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType?.contains("_adb-tls-connect._tcp") == true) {
|
||||
synchronized(lock) {
|
||||
if (seen.add(serviceInfo.serviceName)) queue.addLast(serviceInfo)
|
||||
}
|
||||
resolveNext()
|
||||
}
|
||||
}
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) { latch.countDown() }
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
|
||||
}
|
||||
|
||||
try {
|
||||
manager.discoverServices("_adb-tls-connect._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
latch.await(timeoutSeconds, TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
} finally {
|
||||
runCatching { manager.stopServiceDiscovery(discoveryListener) }
|
||||
}
|
||||
return resolvedPort.get().takeIf { it in 1..65535 }
|
||||
}
|
||||
|
||||
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
|
||||
loadOrCreateIdentity(context.applicationContext)
|
||||
Kadb.create(host, port).use { adb ->
|
||||
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)
|
||||
}
|
||||
|
||||
/** Tries the app's saved pairing without asking for a new pairing code. */
|
||||
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
|
||||
val appContext = context.applicationContext
|
||||
val saved = savedConnection(appContext)
|
||||
?: error("No Wireless debugging pairing is saved")
|
||||
val cachedPort = saved.port
|
||||
if (cachedPort != null) {
|
||||
val cachedResult = runCatching {
|
||||
setChildProcessLimit(appContext, saved.host, cachedPort, enabled)
|
||||
}
|
||||
if (cachedResult.isSuccess) return
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
if (discoveredPort != null && discoveredPort != cachedPort) {
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
} else {
|
||||
cachedResult.getOrThrow()
|
||||
}
|
||||
} else {
|
||||
val discoveredPort = findConnectPort(appContext, saved.host)
|
||||
?: error("Enter the current Wireless debugging IP address & Port")
|
||||
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.RemoteInput
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class WirelessAdbPairingService : Service() {
|
||||
sealed interface Stage {
|
||||
data object Idle : Stage
|
||||
data object Waiting : Stage
|
||||
data class CodeNeeded(val error: String? = null) : Stage
|
||||
data class Working(val step: String) : Stage
|
||||
data object Done : Stage
|
||||
data class Failed(val error: String) : Stage
|
||||
}
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var nsd: NsdManager? = null
|
||||
private var discovery: NsdManager.DiscoveryListener? = null
|
||||
private var pairingHost: String? = null
|
||||
private var pairingPort: Int? = null
|
||||
@Volatile private var working = false
|
||||
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CODE -> {
|
||||
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
|
||||
?.filter(Char::isDigit)?.toString().orEmpty()
|
||||
onCode(code)
|
||||
}
|
||||
ACTION_CANCEL -> finish(Stage.Idle)
|
||||
else -> begin()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
pairingHost = null
|
||||
pairingPort = null
|
||||
working = false
|
||||
publish(Stage.Waiting)
|
||||
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
|
||||
startDiscovery()
|
||||
main.removeCallbacks(timeout)
|
||||
main.postDelayed(timeout, TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun startDiscovery() {
|
||||
stopDiscovery()
|
||||
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) = Unit
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
|
||||
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
|
||||
}
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
|
||||
override fun onDiscoveryStopped(serviceType: String) = Unit
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
|
||||
}
|
||||
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
|
||||
.onSuccess { nsd = manager; discovery = listener }
|
||||
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
|
||||
}
|
||||
|
||||
private fun resolveListener() = object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
val address = serviceInfo.host ?: return
|
||||
if (!WirelessAdbFix.isLocalAddress(address)) return
|
||||
main.post {
|
||||
if (working || current.value == Stage.Done) return@post
|
||||
pairingHost = address.hostAddress
|
||||
pairingPort = serviceInfo.port
|
||||
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCode(code: String) {
|
||||
val host = pairingHost
|
||||
val port = pairingPort
|
||||
if (working) return
|
||||
if (host == null || port == null) {
|
||||
show(Stage.Waiting)
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
|
||||
return
|
||||
}
|
||||
working = true
|
||||
show(Stage.Working("Pairing…"))
|
||||
Thread({
|
||||
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
|
||||
if (paired.isFailure) {
|
||||
main.post {
|
||||
working = false
|
||||
pairingPort = null
|
||||
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
main.post {
|
||||
stopDiscovery()
|
||||
show(Stage.Working("Paired. Connecting…"))
|
||||
}
|
||||
val result = runCatching {
|
||||
val connectPort = WirelessAdbFix.localConnectPort(this)
|
||||
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
|
||||
main.post { show(Stage.Working("Applying the setting…")) }
|
||||
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
|
||||
}
|
||||
main.post {
|
||||
working = false
|
||||
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
|
||||
}
|
||||
}, "wireless-adb-pairing").start()
|
||||
}
|
||||
|
||||
private fun show(stage: Stage) {
|
||||
publish(stage)
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
|
||||
}
|
||||
|
||||
private fun finish(stage: Stage) {
|
||||
main.removeCallbacks(timeout)
|
||||
stopDiscovery()
|
||||
publish(stage)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (stage is Stage.Done || stage is Stage.Failed) {
|
||||
manager?.notify(NOTIFICATION_ID, notification(stage))
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
manager?.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun stopDiscovery() {
|
||||
val listener = discovery ?: return
|
||||
runCatching { nsd?.stopServiceDiscovery(listener) }
|
||||
discovery = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
main.removeCallbacks(timeout)
|
||||
stopDiscovery()
|
||||
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun notification(stage: Stage): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
|
||||
NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Asks for the Wireless debugging pairing code while Settings is open"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
})
|
||||
val open = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
val cancel = PendingIntent.getService(this, 1,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
|
||||
val builder = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentIntent(open)
|
||||
.setShowWhen(false)
|
||||
.setCategory(Notification.CATEGORY_STATUS)
|
||||
when (stage) {
|
||||
Stage.Waiting, Stage.Idle -> builder
|
||||
.setContentTitle("Pair Wireless debugging")
|
||||
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
|
||||
.setStyle(Notification.BigTextStyle().bigText(
|
||||
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
is Stage.CodeNeeded -> {
|
||||
val reply = PendingIntent.getService(this, 2,
|
||||
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
|
||||
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
|
||||
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
|
||||
builder
|
||||
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setOngoing(true)
|
||||
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
|
||||
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
|
||||
}
|
||||
is Stage.Working -> builder
|
||||
.setContentTitle("Setting up Steam")
|
||||
.setContentText(stage.step)
|
||||
.setProgress(0, 0, true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
Stage.Done -> builder
|
||||
.setContentTitle("Steam is ready")
|
||||
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
|
||||
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
|
||||
.setAutoCancel(true)
|
||||
is Stage.Failed -> builder
|
||||
.setContentTitle("Wireless debugging setup stopped")
|
||||
.setContentText(stage.error)
|
||||
.setStyle(Notification.BigTextStyle().bigText(stage.error))
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "wireless-adb-pairing"
|
||||
private const val NOTIFICATION_ID = 3
|
||||
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
|
||||
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
|
||||
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
|
||||
private const val KEY_CODE = "code"
|
||||
private const val TIMEOUT_MS = 5 * 60_000L
|
||||
|
||||
private val current = MutableStateFlow<Stage>(Stage.Idle)
|
||||
val stage: StateFlow<Stage> = current
|
||||
|
||||
private fun publish(stage: Stage) { current.value = stage }
|
||||
|
||||
fun start(context: Context) {
|
||||
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
|
||||
publish(Stage.Waiting)
|
||||
val intent = Intent(context, WirelessAdbPairingService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
|
||||
publish(Stage.Idle)
|
||||
return
|
||||
}
|
||||
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
|
||||
}
|
||||
|
||||
fun notificationsEnabled(context: Context): Boolean =
|
||||
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ fun CollapsibleRail(
|
||||
TextButton(onClick = link.onClick, modifier = Modifier.padding(start = 4.dp)) {
|
||||
Icon(link.icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(link.label, fontSize = 11.sp)
|
||||
Text(link.label, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ fun CollapsibleRail(
|
||||
Text(
|
||||
section.header,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
fontSize = 9.sp,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.6.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 10.dp, bottom = 2.dp),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import java.io.File
|
||||
|
||||
// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr).
|
||||
|
||||
// ── DOS file attributes (Read-only / Hidden) ──
|
||||
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
|
||||
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
|
||||
// app owns (container files) and the Wine DOS-attribute xattr.
|
||||
|
||||
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
|
||||
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
|
||||
|
||||
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
|
||||
|
||||
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
|
||||
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
|
||||
// disabled while Read-only keeps working.
|
||||
internal data class FileAttrState(
|
||||
val readOnly: Boolean,
|
||||
val hidden: Boolean,
|
||||
val hiddenSupported: Boolean,
|
||||
)
|
||||
|
||||
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
|
||||
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
|
||||
// prefix and stop at the first non-hex character.
|
||||
private fun parseDosAttrib(raw: ByteArray): Int {
|
||||
val s = String(raw, Charsets.US_ASCII).trim()
|
||||
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
|
||||
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
return digits.toIntOrNull(16) ?: 0
|
||||
}
|
||||
|
||||
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
|
||||
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
|
||||
internal fun readFileAttrs(file: File): FileAttrState {
|
||||
val path = file.absolutePath
|
||||
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
|
||||
val readOnly = runCatching {
|
||||
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
|
||||
}.getOrDefault(!file.canWrite())
|
||||
|
||||
var hidden = false
|
||||
var supported = true
|
||||
try {
|
||||
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
|
||||
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
|
||||
supported = e.errno == android.system.OsConstants.ENODATA
|
||||
} catch (e: Exception) {
|
||||
supported = false
|
||||
}
|
||||
return FileAttrState(readOnly, hidden, supported)
|
||||
}
|
||||
|
||||
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
|
||||
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
|
||||
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
|
||||
// somehow refused. Returns true on success.
|
||||
internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
|
||||
val path = file.absolutePath
|
||||
val mode = android.system.Os.stat(path).st_mode
|
||||
val writeBits = android.system.OsConstants.S_IWUSR or
|
||||
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
|
||||
val newMode = if (readOnly) mode and writeBits.inv()
|
||||
else mode or android.system.OsConstants.S_IWUSR
|
||||
android.system.Os.chmod(path, newMode)
|
||||
true
|
||||
}.getOrElse { file.setWritable(!readOnly, true) }
|
||||
|
||||
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
|
||||
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
|
||||
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
|
||||
// store the xattr, so the caller can disable just the Hidden toggle.
|
||||
internal fun setHidden(file: File, hidden: Boolean): Boolean = try {
|
||||
val path = file.absolutePath
|
||||
var attr = try {
|
||||
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
|
||||
}
|
||||
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
|
||||
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
|
||||
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import java.io.File
|
||||
|
||||
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
|
||||
class FileManagerActivity : ComponentActivity() {
|
||||
@@ -19,10 +20,11 @@ class FileManagerActivity : ComponentActivity() {
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen()
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,4 +34,8 @@ class FileManagerActivity : ComponentActivity() {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 1 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) recreate()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_START_DIR = "com.droiddeck.launcher.files.START_DIR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.ContentCut
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// The File Manager's rows, tiles, favourites and context menu.
|
||||
|
||||
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
|
||||
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
|
||||
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
|
||||
val white = Color(0xFFFFFFFF)
|
||||
return when {
|
||||
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
|
||||
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
|
||||
else -> Color(0xFF555555) to white // grey
|
||||
}
|
||||
}
|
||||
|
||||
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
|
||||
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
|
||||
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
|
||||
// the menu first, then runs its action.
|
||||
@Composable
|
||||
private fun FileContextMenuItems(
|
||||
file: File,
|
||||
isFavorite: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onProperties: () -> Unit,
|
||||
onDismissMenu: () -> Unit,
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select") },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Properties") },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onToggleFavorite() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cut") },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun FileItemRow(
|
||||
file: File,
|
||||
showActions: Boolean = true,
|
||||
compact: Boolean = false,
|
||||
selectionMode: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
onLongPress: () -> Unit = {},
|
||||
onToggleSelect: () -> Unit = {},
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean,
|
||||
onDismissMenu: () -> Unit,
|
||||
onSelect: () -> Unit = {},
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
isFavorite: Boolean = false,
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
|
||||
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.combinedClickable(
|
||||
// In selection mode a tap toggles instead of opening, so you can rattle through a
|
||||
// folder without long-pressing every single row.
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
when {
|
||||
// Show the executable's own embedded icon when we managed to extract one.
|
||||
exeIcon != null -> Image(
|
||||
bitmap = exeIcon!!,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isExe -> Icon(
|
||||
painter = painterResource(R.drawable.icon_menu_container),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isDir -> Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
internal fun FavoritesList(
|
||||
currentDir: File,
|
||||
favTick: Int,
|
||||
onPinCurrent: () -> Unit,
|
||||
onJump: (File) -> Unit,
|
||||
onUnpin: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val favorites = remember(favTick) {
|
||||
FavoritesStore.list(context).map(::File).filter { it.exists() }
|
||||
}
|
||||
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
|
||||
FavoritesStore.isFavorite(context, currentDir.absolutePath)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onPinCurrent,
|
||||
enabled = !currentAlreadyPinned,
|
||||
) {
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Pin current folder",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(favorites, key = { it.absolutePath }) { file ->
|
||||
val loc = remember(file.absolutePath) { describeLocation(file) }
|
||||
FavoriteCard(
|
||||
file = file,
|
||||
loc = loc,
|
||||
onJump = { onJump(file) },
|
||||
onUnpin = { onUnpin(file) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
|
||||
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
|
||||
// and the full display path; tapping jumps into it, the filled star unpins.
|
||||
@Composable
|
||||
private fun FavoriteCard(
|
||||
file: File,
|
||||
loc: FavLocation,
|
||||
onJump: () -> Unit,
|
||||
onUnpin: () -> Unit,
|
||||
) {
|
||||
val (badgeBg, badgeFg) = badgeColors(loc)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onJump),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = loc.driveLabel,
|
||||
color = badgeFg,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(badgeBg)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = loc.displayPath,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = "Remove from favorites",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
|
||||
*
|
||||
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
|
||||
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
|
||||
* reading details.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun FileGridTile(
|
||||
file: File,
|
||||
selectionMode: Boolean,
|
||||
selected: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onToggleSelect: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean = false,
|
||||
onDismissMenu: () -> Unit = {},
|
||||
isFavorite: Boolean = false,
|
||||
onSelect: () -> Unit = {},
|
||||
onRename: () -> Unit = {},
|
||||
onCopy: () -> Unit = {},
|
||||
onCut: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
|
||||
Box {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
|
||||
isDir -> Icon(
|
||||
Icons.Filled.Folder, null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.InsertDriveFile, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggleSelect() },
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
|
||||
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
|
||||
Text(
|
||||
file.name,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.io.File
|
||||
|
||||
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
|
||||
|
||||
/*
|
||||
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
|
||||
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
|
||||
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
|
||||
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
|
||||
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
|
||||
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
|
||||
* either app carries to the other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What to do when a pasted item already exists at the destination.
|
||||
*
|
||||
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
|
||||
* directory and truncates existing files - but they mean different things to the user, so both are
|
||||
* offered and the wording is chosen per item type (files overwrite, folders merge).
|
||||
*/
|
||||
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
|
||||
|
||||
/**
|
||||
* Shortens a path from the LEFT, keeping whole segments.
|
||||
*
|
||||
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
|
||||
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
|
||||
*/
|
||||
internal fun elidePathStart(path: String, max: Int): String {
|
||||
if (path.length <= max) return path
|
||||
val parts = path.split('/').filter { it.isNotEmpty() }
|
||||
val out = StringBuilder()
|
||||
for (part in parts.asReversed()) {
|
||||
if (out.length + part.length + 1 > max - 2) break
|
||||
out.insert(0, "/$part")
|
||||
}
|
||||
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
|
||||
}
|
||||
|
||||
/** Folder-first ordering stays fixed across both sort directions. */
|
||||
internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
|
||||
val inner: Comparator<File> = when (sortBy) {
|
||||
"date" -> compareBy { it.lastModified() }
|
||||
// Directory length() is meaningless, so folders sort by name within the size ordering
|
||||
// instead of pretending to have one.
|
||||
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
|
||||
else -> compareBy { it.name.lowercase() }
|
||||
}
|
||||
val directed = if (desc) inner.reversed() else inner
|
||||
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
|
||||
}
|
||||
|
||||
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
|
||||
internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
|
||||
// True when [child] is [ancestor] itself or lives anywhere inside it.
|
||||
internal fun isWithin(child: File, ancestor: File): Boolean {
|
||||
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
|
||||
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
|
||||
return c == a || c.startsWith(a + File.separator)
|
||||
}
|
||||
|
||||
// ── Favorites: origin resolution ──
|
||||
|
||||
enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
|
||||
fun describeLocation(file: File): FavLocation {
|
||||
val abs = file.absolutePath
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
}
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
}
|
||||
@@ -7,12 +7,7 @@ import androidx.compose.foundation.Image
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.ContentCut
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SdStorage
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Unarchive
|
||||
import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/*
|
||||
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
|
||||
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
|
||||
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
|
||||
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
|
||||
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
|
||||
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
|
||||
* either app carries to the other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What to do when a pasted item already exists at the destination.
|
||||
*
|
||||
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
|
||||
* directory and truncates existing files - but they mean different things to the user, so both are
|
||||
* offered and the wording is chosen per item type (files overwrite, folders merge).
|
||||
*/
|
||||
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
|
||||
|
||||
/**
|
||||
* Shortens a path from the LEFT, keeping whole segments.
|
||||
*
|
||||
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
|
||||
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
|
||||
*/
|
||||
private fun elidePathStart(path: String, max: Int): String {
|
||||
if (path.length <= max) return path
|
||||
val parts = path.split('/').filter { it.isNotEmpty() }
|
||||
val out = StringBuilder()
|
||||
for (part in parts.asReversed()) {
|
||||
if (out.length + part.length + 1 > max - 2) break
|
||||
out.insert(0, "/$part")
|
||||
}
|
||||
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
|
||||
}
|
||||
|
||||
/** Folder-first ordering stays fixed across both sort directions. */
|
||||
private fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
|
||||
val inner: Comparator<File> = when (sortBy) {
|
||||
"date" -> compareBy { it.lastModified() }
|
||||
// Directory length() is meaningless, so folders sort by name within the size ordering
|
||||
// instead of pretending to have one.
|
||||
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
|
||||
else -> compareBy { it.name.lowercase() }
|
||||
}
|
||||
val directed = if (desc) inner.reversed() else inner
|
||||
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
|
||||
}
|
||||
|
||||
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
|
||||
private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
|
||||
// True when [child] is [ancestor] itself or lives anywhere inside it.
|
||||
private fun isWithin(child: File, ancestor: File): Boolean {
|
||||
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
|
||||
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
|
||||
return c == a || c.startsWith(a + File.separator)
|
||||
}
|
||||
|
||||
// ── DOS file attributes (Read-only / Hidden) ──
|
||||
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
|
||||
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
|
||||
// app owns (container files) and the Wine DOS-attribute xattr.
|
||||
|
||||
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
|
||||
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
|
||||
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
|
||||
|
||||
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
|
||||
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
|
||||
// disabled while Read-only keeps working.
|
||||
private data class FileAttrState(
|
||||
val readOnly: Boolean,
|
||||
val hidden: Boolean,
|
||||
val hiddenSupported: Boolean,
|
||||
)
|
||||
|
||||
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
|
||||
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
|
||||
// prefix and stop at the first non-hex character.
|
||||
private fun parseDosAttrib(raw: ByteArray): Int {
|
||||
val s = String(raw, Charsets.US_ASCII).trim()
|
||||
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
|
||||
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
return digits.toIntOrNull(16) ?: 0
|
||||
}
|
||||
|
||||
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
|
||||
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
|
||||
private fun readFileAttrs(file: File): FileAttrState {
|
||||
val path = file.absolutePath
|
||||
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
|
||||
val readOnly = runCatching {
|
||||
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
|
||||
}.getOrDefault(!file.canWrite())
|
||||
|
||||
var hidden = false
|
||||
var supported = true
|
||||
try {
|
||||
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
|
||||
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
|
||||
supported = e.errno == android.system.OsConstants.ENODATA
|
||||
} catch (e: Exception) {
|
||||
supported = false
|
||||
}
|
||||
return FileAttrState(readOnly, hidden, supported)
|
||||
}
|
||||
|
||||
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
|
||||
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
|
||||
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
|
||||
// somehow refused. Returns true on success.
|
||||
private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
|
||||
val path = file.absolutePath
|
||||
val mode = android.system.Os.stat(path).st_mode
|
||||
val writeBits = android.system.OsConstants.S_IWUSR or
|
||||
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
|
||||
val newMode = if (readOnly) mode and writeBits.inv()
|
||||
else mode or android.system.OsConstants.S_IWUSR
|
||||
android.system.Os.chmod(path, newMode)
|
||||
true
|
||||
}.getOrElse { file.setWritable(!readOnly, true) }
|
||||
|
||||
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
|
||||
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
|
||||
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
|
||||
// store the xattr, so the caller can disable just the Hidden toggle.
|
||||
private fun setHidden(file: File, hidden: Boolean): Boolean = try {
|
||||
val path = file.absolutePath
|
||||
var attr = try {
|
||||
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
|
||||
} catch (e: android.system.ErrnoException) {
|
||||
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
|
||||
}
|
||||
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
|
||||
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
|
||||
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
// ── Favorites: origin resolution ──
|
||||
|
||||
enum class FavStorage { INTERNAL, SD, OTHER }
|
||||
|
||||
data class FavLocation(
|
||||
val storage: FavStorage,
|
||||
val driveLabel: String, // "Internal", "SD card", or "Storage"
|
||||
val displayPath: String // the unix absolute path
|
||||
)
|
||||
|
||||
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
|
||||
fun describeLocation(file: File): FavLocation {
|
||||
val abs = file.absolutePath
|
||||
|
||||
val internal = "/storage/emulated/0"
|
||||
if (abs == internal || abs.startsWith("$internal/")) {
|
||||
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
|
||||
}
|
||||
|
||||
if (abs.startsWith("/storage/")) {
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") {
|
||||
return FavLocation(FavStorage.SD, "SD card", abs)
|
||||
}
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, "Storage", abs)
|
||||
}
|
||||
|
||||
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
|
||||
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
|
||||
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
|
||||
val white = Color(0xFFFFFFFF)
|
||||
return when {
|
||||
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
|
||||
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
|
||||
else -> Color(0xFF555555) to white // grey
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -1030,7 +813,7 @@ fun FileManagerScreen(
|
||||
// when it doesn't fit we drop the /storage/emulated/0 prefix, not the tail.
|
||||
text = elidePathStart(currentDir.absolutePath, 52),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
// fill = true: the path takes all remaining width, so the free-space figure
|
||||
// is pinned to the right edge instead of sliding around with the path length.
|
||||
@@ -1040,7 +823,7 @@ fun FileManagerScreen(
|
||||
Text(
|
||||
"${FileOps.formatBytes(freeSpace)} free",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
@@ -1414,675 +1197,3 @@ fun FileManagerScreen(
|
||||
} // end rail + content Row
|
||||
}
|
||||
}
|
||||
|
||||
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
|
||||
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
|
||||
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
|
||||
// the menu first, then runs its action.
|
||||
@Composable
|
||||
private fun FileContextMenuItems(
|
||||
file: File,
|
||||
isFavorite: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onProperties: () -> Unit,
|
||||
onDismissMenu: () -> Unit,
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
DropdownMenuItem(
|
||||
text = { Text("Select") },
|
||||
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onSelect() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
|
||||
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Properties") },
|
||||
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onToggleFavorite() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onRename() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Copy") },
|
||||
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCopy() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Cut") },
|
||||
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onCut() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
|
||||
onClick = { onDismissMenu(); onDelete() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun FileItemRow(
|
||||
file: File,
|
||||
showActions: Boolean = true,
|
||||
compact: Boolean = false,
|
||||
selectionMode: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
onLongPress: () -> Unit = {},
|
||||
onToggleSelect: () -> Unit = {},
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean,
|
||||
onDismissMenu: () -> Unit,
|
||||
onSelect: () -> Unit = {},
|
||||
onCopy: () -> Unit,
|
||||
onCut: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
isFavorite: Boolean = false,
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
|
||||
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.combinedClickable(
|
||||
// In selection mode a tap toggles instead of opening, so you can rattle through a
|
||||
// folder without long-pressing every single row.
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
when {
|
||||
// Show the executable's own embedded icon when we managed to extract one.
|
||||
exeIcon != null -> Image(
|
||||
bitmap = exeIcon!!,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isExe -> Icon(
|
||||
painter = painterResource(R.drawable.icon_menu_container),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
isDir -> Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
|
||||
append(dateFormat.format(Date(file.lastModified())))
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
if (showActions) Box {
|
||||
IconButton(onClick = onMenu) {
|
||||
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
private fun FavoritesList(
|
||||
currentDir: File,
|
||||
favTick: Int,
|
||||
onPinCurrent: () -> Unit,
|
||||
onJump: (File) -> Unit,
|
||||
onUnpin: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val favorites = remember(favTick) {
|
||||
FavoritesStore.list(context).map(::File).filter { it.exists() }
|
||||
}
|
||||
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
|
||||
FavoritesStore.isFavorite(context, currentDir.absolutePath)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onPinCurrent,
|
||||
enabled = !currentAlreadyPinned,
|
||||
) {
|
||||
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Pin current folder",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(favorites, key = { it.absolutePath }) { file ->
|
||||
val loc = remember(file.absolutePath) { describeLocation(file) }
|
||||
FavoriteCard(
|
||||
file = file,
|
||||
loc = loc,
|
||||
onJump = { onJump(file) },
|
||||
onUnpin = { onUnpin(file) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
|
||||
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
|
||||
// and the full display path; tapping jumps into it, the filled star unpins.
|
||||
@Composable
|
||||
private fun FavoriteCard(
|
||||
file: File,
|
||||
loc: FavLocation,
|
||||
onJump: () -> Unit,
|
||||
onUnpin: () -> Unit,
|
||||
) {
|
||||
val (badgeBg, badgeFg) = badgeColors(loc)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onJump),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Origin line: coloured drive badge + source description.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = loc.driveLabel,
|
||||
color = badgeFg,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(badgeBg)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (loc.storage) {
|
||||
FavStorage.INTERNAL -> "Internal storage"
|
||||
FavStorage.SD -> "SD card"
|
||||
FavStorage.OTHER -> "Storage"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = loc.displayPath,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onUnpin) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = "Remove from favorites",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
|
||||
*
|
||||
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
|
||||
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
|
||||
* reading details.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FileGridTile(
|
||||
file: File,
|
||||
selectionMode: Boolean,
|
||||
selected: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onToggleSelect: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
menuExpanded: Boolean = false,
|
||||
onDismissMenu: () -> Unit = {},
|
||||
isFavorite: Boolean = false,
|
||||
onSelect: () -> Unit = {},
|
||||
onRename: () -> Unit = {},
|
||||
onCopy: () -> Unit = {},
|
||||
onCut: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val isDir = file.isDirectory
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
|
||||
Box {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (selectionMode) onToggleSelect() else onTap() },
|
||||
onLongClick = onLongPress,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
|
||||
isDir -> Icon(
|
||||
Icons.Filled.Folder, null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
isImage -> AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.InsertDriveFile, null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(44.dp),
|
||||
)
|
||||
}
|
||||
if (selectionMode) {
|
||||
androidx.compose.material3.Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggleSelect() },
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
|
||||
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
|
||||
Text(
|
||||
file.name,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 11.sp,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = onDismissMenu,
|
||||
modifier = Modifier.outlinedMenuCard(),
|
||||
) {
|
||||
FileContextMenuItems(
|
||||
file = file,
|
||||
isFavorite = isFavorite,
|
||||
onSelect = onSelect,
|
||||
onRename = onRename,
|
||||
onCopy = onCopy,
|
||||
onCut = onCut,
|
||||
onDelete = onDelete,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onProperties = onProperties,
|
||||
onDismissMenu = onDismissMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
|
||||
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
|
||||
* immediately (off the main thread) and refreshes the listing via [onChanged].
|
||||
*
|
||||
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
|
||||
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
|
||||
* Read-only keeps working everywhere.
|
||||
*/
|
||||
@Composable
|
||||
private fun FilePropertiesDialog(
|
||||
file: File,
|
||||
onDismiss: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
|
||||
// Guards against a second toggle landing while the first is still being applied off-thread.
|
||||
var busy by remember(file.absolutePath) { mutableStateOf(false) }
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
|
||||
}
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Properties") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = "Hidden",
|
||||
description = if (state != null && !hiddenSupported)
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(hidden = want)
|
||||
onChanged()
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
// One "label: value" line in the Properties info block.
|
||||
@Composable
|
||||
private fun PropertyLine(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.width(76.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A labelled attribute switch with a one-line description, used by the Properties sheet.
|
||||
@Composable
|
||||
private fun AttributeToggleRow(
|
||||
label: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// The File Manager's properties dialog.
|
||||
|
||||
/**
|
||||
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
|
||||
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
|
||||
* immediately (off the main thread) and refreshes the listing via [onChanged].
|
||||
*
|
||||
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
|
||||
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
|
||||
* Read-only keeps working everywhere.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FilePropertiesDialog(
|
||||
file: File,
|
||||
onDismiss: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
|
||||
// Guards against a second toggle landing while the first is still being applied off-thread.
|
||||
var busy by remember(file.absolutePath) { mutableStateOf(false) }
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
|
||||
}
|
||||
|
||||
OutlinedAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Properties") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// ── Basic info ──
|
||||
Text(
|
||||
text = file.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
|
||||
)
|
||||
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
|
||||
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
val state = attrs
|
||||
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
|
||||
AttributeToggleRow(
|
||||
label = "Read-only",
|
||||
description = "Stops games (and Wine) from overwriting or deleting this file.",
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
onChanged()
|
||||
} else {
|
||||
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
|
||||
val hiddenSupported = state?.hiddenSupported == true
|
||||
AttributeToggleRow(
|
||||
label = "Hidden",
|
||||
description = if (state != null && !hiddenSupported)
|
||||
"This storage can't store the hidden flag."
|
||||
else "Marks the file hidden in Windows (Wine's hidden attribute).",
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
|
||||
busy = false
|
||||
if (ok) {
|
||||
attrs = attrs?.copy(hidden = want)
|
||||
onChanged()
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
)
|
||||
}
|
||||
|
||||
// One "label: value" line in the Properties info block.
|
||||
@Composable
|
||||
private fun PropertyLine(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.width(76.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A labelled attribute switch with a one-line description, used by the Properties sheet.
|
||||
@Composable
|
||||
private fun AttributeToggleRow(
|
||||
label: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
androidx.compose.material3.Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* The user's own Windows games, added to the Steam client's library.
|
||||
*
|
||||
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
|
||||
* is the .exe in it that looks most like the game - the one named after the folder, else the
|
||||
* largest, never an installer, redistributable or crash reporter - unless the user picked one
|
||||
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
|
||||
* with the appid the client itself would derive, so launching from the rail and from the
|
||||
* client's own library are the same thing. Any number of Games folders, from anywhere on the
|
||||
* device: each is bound into the session under /root/Games on its own.
|
||||
*/
|
||||
/** Windows game folders shared with the Steam session. */
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
|
||||
@@ -29,6 +20,7 @@ object AddedGames {
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
val steamAppId: Int? = null,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
@@ -96,12 +88,15 @@ object AddedGames {
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
for (root in roots(context)) {
|
||||
val dir = root.host
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
for (dir in folders.distinctBy { it.absolutePath }) {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
|
||||
}
|
||||
return out
|
||||
return out.distinctBy { it.folder.canonicalPath }
|
||||
}
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
@@ -115,10 +110,23 @@ object AddedGames {
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -126,6 +134,7 @@ object AddedGames {
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
@@ -145,5 +154,5 @@ object AddedGames {
|
||||
return guestPath(context, file)
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
private fun quote(s: String): String = JSONObject.quote(s)
|
||||
}
|
||||
@@ -14,7 +14,18 @@ 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,
|
||||
)
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
const val ADDED = "added"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
@@ -55,16 +66,35 @@ object Library {
|
||||
4690330, // Legacy Steam Runtime
|
||||
)
|
||||
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
|
||||
private val STEAM_HEROES = listOf("library_hero.jpg")
|
||||
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
|
||||
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
|
||||
|
||||
/** Steam's library roots visible to this launcher: its private default plus the selected library. */
|
||||
private fun steamLibraries(context: Context): List<Pair<File, String>> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
}
|
||||
|
||||
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
|
||||
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
|
||||
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first })
|
||||
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
|
||||
return roots.asSequence()
|
||||
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
|
||||
.firstOrNull { it.isDirectory }
|
||||
}
|
||||
|
||||
fun steamGames(context: Context): List<SteamGame> {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
val libraries = steamLibraries(context)
|
||||
val out = LinkedHashMap<Int, SteamGame>()
|
||||
for ((library, label) in libraries) {
|
||||
val steamapps = File(library, "steamapps")
|
||||
@@ -77,19 +107,31 @@ object Library {
|
||||
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
// StateFlags 4 = fully installed; anything else is downloading, updating or broken.
|
||||
if (name.isEmpty() || flags and 4 == 0) return@forEach
|
||||
val art = steamCapsule(cache, appId)
|
||||
out[appId] = SteamGame(appId, name, art, label)
|
||||
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
|
||||
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
|
||||
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
|
||||
// App manifests are Valve KeyValues (VDF) files. installdir is one folder
|
||||
// below steamapps/common; only expose it when the directory exists and the
|
||||
// manifest value cannot escape that directory.
|
||||
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
|
||||
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
|
||||
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
|
||||
out[appId] = SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library),
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.values.toList()
|
||||
}
|
||||
|
||||
/** Steam stores current library capsules inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCapsule(cache: File, appId: Int): File? {
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
val appDir = File(cache, appId.toString())
|
||||
val dirs = listOf(appDir) + appDir.listFiles()
|
||||
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
|
||||
return STEAM_CAPSULES.asSequence()
|
||||
return names.asSequence()
|
||||
.flatMap { name -> dirs.asSequence().map { File(it, name) } }
|
||||
.firstOrNull { it.isFile && it.length() > 0L }
|
||||
}
|
||||
@@ -124,7 +166,13 @@ object Library {
|
||||
)
|
||||
|
||||
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
|
||||
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
|
||||
fun nameForProgram(program: String?): String? =
|
||||
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
|
||||
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
|
||||
} else specs.firstOrNull { it.program == program }?.name
|
||||
|
||||
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
|
||||
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Whether a program from the rail runs at the panel's own resolution rather than the session's
|
||||
@@ -278,7 +326,9 @@ object Library {
|
||||
"-C", "Dolphin.General.HotkeysRequireFocus=False", "-C", "Dolphin.Input.BackgroundInput=True",
|
||||
"-e", guestPath,
|
||||
)
|
||||
"cemu" -> listOf("-g", guestPath)
|
||||
// Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started
|
||||
// dialog away (bannerlator-pad-defaults).
|
||||
"cemu" -> listOf("-f", "-g", guestPath)
|
||||
// Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and
|
||||
// comes back (HK_FullscreenToggle, bannerlator-pad-defaults).
|
||||
"melonds" -> listOf("-f", guestPath)
|
||||
|
||||
@@ -40,9 +40,11 @@ object FrameGen {
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
|
||||
fun label(context: Context): String = when (engine(context)) {
|
||||
ENGINE_WINFG -> "Win-FG ${multiplier(context)}×"
|
||||
ENGINE_LSFG -> "LSFG ${multiplier(context)}×"
|
||||
fun label(context: Context): String = label(engine(context), multiplier(context))
|
||||
|
||||
fun label(engine: String, multiplier: Int): String = when (engine) {
|
||||
ENGINE_WINFG -> "Win-FG $multiplier×"
|
||||
ENGINE_LSFG -> "LSFG $multiplier×"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
@@ -82,47 +84,5 @@ object FrameGen {
|
||||
return problem
|
||||
}
|
||||
|
||||
/**
|
||||
* Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. The LSFG rows are greyed
|
||||
* and unpickable until Lossless Scaling is installed in the Steam client - the engine cannot
|
||||
* run without its DLL, so offering it would only produce a setting that silently does nothing.
|
||||
*/
|
||||
fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) {
|
||||
val lsfgReady = LsfgNative.isInstalled(activity)
|
||||
val lsfgNote = if (lsfgReady) "" else " - install Lossless Scaling in Steam"
|
||||
val choices = listOf("Off",
|
||||
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
|
||||
"LSFG 2×$lsfgNote", "LSFG 3×$lsfgNote", "LSFG 4×$lsfgNote")
|
||||
val current = when (engine(activity)) {
|
||||
ENGINE_WINFG -> multiplier(activity) - 1
|
||||
ENGINE_LSFG -> if (lsfgReady) multiplier(activity) + 2 else 0
|
||||
else -> 0
|
||||
}
|
||||
val adapter = object : android.widget.ArrayAdapter<String>(
|
||||
activity, android.R.layout.simple_list_item_single_choice, choices) {
|
||||
override fun isEnabled(position: Int) = lsfgReady || position < 4
|
||||
override fun areAllItemsEnabled() = lsfgReady
|
||||
override fun getView(position: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
view.isEnabled = isEnabled(position)
|
||||
view.alpha = if (isEnabled(position)) 1f else 0.38f
|
||||
return view
|
||||
}
|
||||
}
|
||||
android.app.AlertDialog.Builder(activity)
|
||||
.setTitle(com.droiddeck.launcher.R.string.frame_gen_title)
|
||||
.setSingleChoiceItems(adapter, current) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> set(activity, ENGINE_OFF, 2)
|
||||
in 1..3 -> set(activity, ENGINE_WINFG, which + 1)
|
||||
else -> set(activity, ENGINE_LSFG, which - 2)
|
||||
}
|
||||
dialog.dismiss()
|
||||
onChanged()
|
||||
}
|
||||
.setNeutralButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private const val TAG = "FrameGen"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONArray
|
||||
@@ -27,7 +28,9 @@ object TurnipReleases {
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
|
||||
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
|
||||
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
|
||||
val sha256: String = "")
|
||||
/** [latest] = each source's newest release tag, for the refresh line. */
|
||||
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
|
||||
|
||||
@@ -89,11 +92,13 @@ object TurnipReleases {
|
||||
val a = list.getJSONObject(j)
|
||||
val name = a.getString("name")
|
||||
val (linux, label) = src.classify(name, tag) ?: continue
|
||||
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
// Newest first: the first release carrying a variant is the one offered.
|
||||
if (!seen.add("$linux|$label")) continue
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
|
||||
.put("linux", linux).put("label", label))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha))
|
||||
}
|
||||
}
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
@@ -131,7 +136,7 @@ object TurnipReleases {
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.getJSONObject(i)
|
||||
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
|
||||
}
|
||||
val latest = ArrayList<Pair<String, String>>()
|
||||
val l = json.optJSONArray("latest") ?: JSONArray()
|
||||
@@ -167,6 +172,7 @@ object TurnipReleases {
|
||||
|
||||
/** Download an asset into the cache; the caller imports it and deletes the file. */
|
||||
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
|
||||
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
|
||||
val target = File(context.cacheDir, asset.name)
|
||||
val c = URL(asset.url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
@@ -191,6 +197,9 @@ object TurnipReleases {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException("Checksum mismatch - the download was discarded")
|
||||
}
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(target)
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.onSignal
|
||||
import com.droiddeck.launcher.ui.LocalPalette
|
||||
|
||||
class ControllerEditorActivity : ComponentActivity() {
|
||||
@@ -47,7 +48,7 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
|
||||
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Text("Drag to move", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
|
||||
EditorButton("Reset", false) { controls.resetLayout() }
|
||||
EditorButton("Cancel", false) { finish() }
|
||||
EditorButton("Save", true) {
|
||||
@@ -67,7 +68,7 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
val pal = LocalPalette.current
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (primary) Color.White else MaterialTheme.colorScheme.onBackground,
|
||||
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.clip(RoundedCornerShape(50))
|
||||
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
|
||||
.clickable(onClick = onClick)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
/**
|
||||
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
|
||||
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
|
||||
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
|
||||
*
|
||||
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
|
||||
* position, a pad clicked also reports full pressure.
|
||||
*/
|
||||
object DeckControls {
|
||||
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
|
||||
const val L4 = 1
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
/** The pad the Deck controller is made from (PadBridge's slot). */
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
private val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
publish()
|
||||
}
|
||||
|
||||
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
/** Everything let go - for when the panel goes away with a finger still on it. */
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
pressure.fill(0)
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
}
|
||||
@@ -67,6 +67,9 @@ public class FakeInputWriter {
|
||||
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
|
||||
* button is not, because PadState keeps 10 and 11 for the triggers. */
|
||||
private static final int SNAPSHOT_IDX_MODE = 10;
|
||||
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
|
||||
* libfakeinput's Deck controller reads it from here. */
|
||||
private static final int SNAPSHOT_IDX_QAM = 11;
|
||||
|
||||
private static final int EVENT_SIZE = 24;
|
||||
static final int MAX_EVENTS_PER_UPDATE = 32;
|
||||
@@ -92,6 +95,18 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
|
||||
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
|
||||
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -110,6 +125,7 @@ public class FakeInputWriter {
|
||||
private volatile boolean destroyed = false;
|
||||
|
||||
private final boolean[] prevButtonStates = new boolean[12];
|
||||
private boolean prevQam;
|
||||
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
|
||||
private int prevTriggerL, prevTriggerR;
|
||||
private int prevHatX, prevHatY;
|
||||
@@ -217,30 +233,40 @@ public class FakeInputWriter {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(IMU_OFFSET, 0);
|
||||
data.putLong(IMU_SEQ_OFFSET, 0L);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
|
||||
private static void releaseRingSlotLocked(int slot) {
|
||||
RingSlot ringSlot = RING_SLOTS[slot];
|
||||
if (ringSlot == null) {
|
||||
return;
|
||||
}
|
||||
ringSlot.data = null;
|
||||
if (ringSlot.ringChannel != null) {
|
||||
try {
|
||||
ringSlot.ringChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
synchronized (ringSlot) {
|
||||
ringSlot.data = null;
|
||||
if (ringSlot.ringChannel != null) {
|
||||
try {
|
||||
ringSlot.ringChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
ringSlot.ringChannel = null;
|
||||
}
|
||||
ringSlot.ringChannel = null;
|
||||
}
|
||||
if (ringSlot.ringRaf != null) {
|
||||
try {
|
||||
ringSlot.ringRaf.close();
|
||||
} catch (IOException ignored) {
|
||||
if (ringSlot.ringRaf != null) {
|
||||
try {
|
||||
ringSlot.ringRaf.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
ringSlot.ringRaf = null;
|
||||
}
|
||||
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
|
||||
ringSlot.ringFile.delete();
|
||||
}
|
||||
ringSlot.ringRaf = null;
|
||||
}
|
||||
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
|
||||
ringSlot.ringFile.delete();
|
||||
}
|
||||
RING_SLOTS[slot] = null;
|
||||
}
|
||||
@@ -289,9 +315,9 @@ public class FakeInputWriter {
|
||||
FileChannel channel = null;
|
||||
try {
|
||||
raf = new RandomAccessFile(ringFile, "rw");
|
||||
raf.setLength(RING_SIZE);
|
||||
raf.setLength(RING_FILE_SIZE);
|
||||
channel = raf.getChannel();
|
||||
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
|
||||
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
|
||||
initializeRingHeader(data);
|
||||
|
||||
RingSlot ringSlot = new RingSlot();
|
||||
@@ -332,6 +358,9 @@ public class FakeInputWriter {
|
||||
return false;
|
||||
}
|
||||
synchronized (ringSlot) {
|
||||
if (ringSlot.data == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ringSlot.active) {
|
||||
if (ringSlot.everActivated) {
|
||||
ringSlot.generation++;
|
||||
@@ -381,6 +410,9 @@ public class FakeInputWriter {
|
||||
ByteBuffer source = this.buffer.duplicate();
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) {
|
||||
return false;
|
||||
}
|
||||
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
|
||||
int sourceLimit = source.limit();
|
||||
while (source.remaining() >= EVENT_SIZE) {
|
||||
@@ -416,6 +448,9 @@ public class FakeInputWriter {
|
||||
buttons |= (1 << i);
|
||||
}
|
||||
}
|
||||
if (this.prevQam) {
|
||||
buttons |= (1 << SNAPSHOT_IDX_QAM);
|
||||
}
|
||||
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
|
||||
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
|
||||
nativeStoreFence();
|
||||
@@ -434,6 +469,58 @@ public class FakeInputWriter {
|
||||
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
|
||||
* controller's axes and units (see PadMotion). Safe from any thread.
|
||||
*/
|
||||
public static void writeMotion(int slot, short[] accel, short[] gyro) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
long seq = ring.getLong(IMU_SEQ_OFFSET);
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
|
||||
nativeStoreFence();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
|
||||
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
|
||||
}
|
||||
ring.putInt(IMU_OFFSET, IMU_MAGIC);
|
||||
nativeStoreFence();
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
|
||||
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
|
||||
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
|
||||
*/
|
||||
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
long seq = ring.getLong(IMU_SEQ_OFFSET);
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
|
||||
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
|
||||
ring.putInt(DECK_CONTROLS_OFFSET, controls);
|
||||
ring.putInt(IMU_OFFSET, IMU_MAGIC);
|
||||
nativeStoreFence();
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
@@ -512,6 +599,11 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
if (prevQam) {
|
||||
prevQam = false;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Zero all axes
|
||||
if (prevThumbLX != 0) {
|
||||
prevThumbLX = 0;
|
||||
@@ -620,6 +712,11 @@ public class FakeInputWriter {
|
||||
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
|
||||
writeButton(SNAPSHOT_IDX_MODE,
|
||||
state.isDown(PadState.GUIDE));
|
||||
boolean qam = state.isDown(PadState.QAM);
|
||||
if (forceResend || qam != prevQam) {
|
||||
prevQam = qam;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Sticks
|
||||
int lx = (int) (state.leftX * 32767);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import com.droiddeck.launcher.session.SessionState;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
@@ -19,16 +21,34 @@ import java.io.File;
|
||||
* SDL → Big Picture.
|
||||
*
|
||||
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
|
||||
* mapping without the user configuring anything.
|
||||
* mapping without the user configuring anything. In a Steam session it is a Steam Deck controller
|
||||
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
|
||||
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
/** 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();
|
||||
@@ -39,6 +59,8 @@ public final class PadBridge {
|
||||
private boolean systemQamPressed;
|
||||
private boolean qamChordActive;
|
||||
private boolean qamSyntheticAPressed;
|
||||
/** The Deck controller's Quick Access button, held for one tap. */
|
||||
private boolean qamTapPressed;
|
||||
private int qamChordGeneration;
|
||||
/** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */
|
||||
private volatile Runnable onPlayerInput;
|
||||
@@ -62,6 +84,7 @@ public final class PadBridge {
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
qamSyntheticAPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (open) {
|
||||
state.clear();
|
||||
@@ -189,7 +212,8 @@ public final class PadBridge {
|
||||
boolean qamStarted = qamPressed && !systemQamPressed;
|
||||
systemGuidePressed = guidePressed;
|
||||
systemQamPressed = qamPressed;
|
||||
if (qamStarted) startQamChord();
|
||||
// A Deck controller's QAM button is held for as long as the touch one is.
|
||||
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
|
||||
else publish();
|
||||
}
|
||||
|
||||
@@ -202,9 +226,23 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Starts the same Guide-then-A sequence used by the touch QAM button. */
|
||||
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
|
||||
public synchronized void triggerQam() {
|
||||
startQamChord();
|
||||
if (!SessionState.getDeckPad()) {
|
||||
startQamChord();
|
||||
return;
|
||||
}
|
||||
if (qamTapPressed) return;
|
||||
qamTapPressed = true;
|
||||
int generation = qamChordGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamTap(int generation) {
|
||||
if (generation != qamChordGeneration || !qamTapPressed) return;
|
||||
qamTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
|
||||
private void startQamChord() {
|
||||
@@ -212,21 +250,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) {
|
||||
@@ -237,16 +281,17 @@ public final class PadBridge {
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) return;
|
||||
if (systemGuidePressed || qamChordActive) {
|
||||
effectiveState.copyFrom(state);
|
||||
effectiveState.press(PadState.GUIDE, true);
|
||||
if (qamSyntheticAPressed) {
|
||||
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
|
||||
}
|
||||
writer.writePad(effectiveState);
|
||||
} else {
|
||||
boolean guide = systemGuidePressed || qamChordActive;
|
||||
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
|
||||
if (!guide && !qam) {
|
||||
writer.writePad(state);
|
||||
return;
|
||||
}
|
||||
effectiveState.copyFrom(state);
|
||||
if (guide) effectiveState.press(PadState.GUIDE, true);
|
||||
if (qam) effectiveState.press(PadState.QAM, true);
|
||||
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
|
||||
writer.writePad(effectiveState);
|
||||
}
|
||||
|
||||
private static float axis(MotionEvent event, int axis) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad).
|
||||
*
|
||||
* The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android
|
||||
* reports them in the device's natural orientation; they are turned to the screen as it is shown
|
||||
* (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes
|
||||
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
|
||||
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
|
||||
*
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
|
||||
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
|
||||
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
|
||||
*/
|
||||
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(SensorManager::class.java)
|
||||
private var thread: HandlerThread? = null
|
||||
/** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */
|
||||
@Volatile private var active = false
|
||||
// Touched only on the worker thread.
|
||||
private val accel = ShortArray(3)
|
||||
private val gyro = ShortArray(3)
|
||||
|
||||
fun start() {
|
||||
if (thread != null || sensors == null) return
|
||||
val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||
val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
if (gyroscope == null && accelerometer == null) {
|
||||
Log.i(TAG, "pad motion: this device has no gyro or accelerometer")
|
||||
return
|
||||
}
|
||||
val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() }
|
||||
thread = worker
|
||||
val handler = Handler(worker.looper)
|
||||
active = true
|
||||
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val worker = thread ?: return
|
||||
thread = null
|
||||
active = false
|
||||
sensors?.unregisterListener(this)
|
||||
// A still controller, not one frozen mid-turn - published on the worker, after any reading
|
||||
// already queued there, so nothing can overwrite it.
|
||||
Handler(worker.looper).post {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (!active) return
|
||||
val values = event.values
|
||||
// Natural orientation to the screen's: Android's display-rotation transform.
|
||||
val (x, y) = when (rotation()) {
|
||||
Surface.ROTATION_90 -> -values[1] to values[0]
|
||||
Surface.ROTATION_180 -> -values[0] to -values[1]
|
||||
Surface.ROTATION_270 -> values[1] to -values[0]
|
||||
else -> values[0] to values[1]
|
||||
}
|
||||
val z = values[2]
|
||||
val (target, scale) = when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
|
||||
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
|
||||
else -> return
|
||||
}
|
||||
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
|
||||
target[0] = counts(x * scale)
|
||||
target[1] = counts(-z * scale)
|
||||
target[2] = counts(y * scale)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PadMotion"
|
||||
/** The pad the Deck controller is made from (PadBridge's slot). */
|
||||
private const val SLOT = 0
|
||||
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
|
||||
private const val SAMPLING_US = 4000
|
||||
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
|
||||
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
|
||||
}
|
||||
}
|
||||
@@ -57,5 +57,7 @@ class PadState {
|
||||
const val R3 = 9
|
||||
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
|
||||
const val GUIDE = 12
|
||||
/** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */
|
||||
const val QAM = 13
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) {
|
||||
NONE("none", "None"),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
|
||||
TERMINAL("terminal", "Terminal"),
|
||||
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
|
||||
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
|
||||
}
|
||||
|
||||
data class SecondScreenDisplay(val id: Int, val label: String)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
|
||||
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
|
||||
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
|
||||
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
|
||||
*
|
||||
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
|
||||
*/
|
||||
object AppImageManager {
|
||||
private const val TAG = "AppImageManager"
|
||||
const val GUEST_DIR = "/opt/appimages/user"
|
||||
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
|
||||
private const val ELF_AARCH64 = 183
|
||||
private const val ELF_X86_64 = 62
|
||||
|
||||
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
|
||||
val guestDir: String get() = "$GUEST_DIR/$id"
|
||||
}
|
||||
|
||||
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
|
||||
|
||||
fun list(context: Context): List<Item> =
|
||||
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
|
||||
?.map { dir ->
|
||||
Item(
|
||||
dir.name,
|
||||
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
|
||||
File(dir, "icon.png").takeIf { it.isFile },
|
||||
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}?.sortedBy { it.name.lowercase() } ?: emptyList()
|
||||
|
||||
/**
|
||||
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
|
||||
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
|
||||
*/
|
||||
internal fun problem(file: File): String? {
|
||||
if (!file.isFile) return "The file is gone"
|
||||
val head = ByteArray(20)
|
||||
try {
|
||||
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
|
||||
} catch (e: Exception) {
|
||||
return "The file cannot be read: ${e.message}"
|
||||
}
|
||||
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
|
||||
return "This is not an AppImage (it is not a Linux program)"
|
||||
}
|
||||
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
|
||||
return when {
|
||||
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
|
||||
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
|
||||
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
|
||||
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
|
||||
internal fun idFor(fileName: String, taken: Set<String>): String {
|
||||
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
|
||||
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
|
||||
var id = base
|
||||
var n = 2
|
||||
while (id in taken) id = "$base-${n++}"
|
||||
return id
|
||||
}
|
||||
|
||||
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
|
||||
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
problem(file)?.let { return it }
|
||||
val base = root(context).apply { mkdirs() }
|
||||
// Room for the image while it is extracted, and the extracted tree (about the same again).
|
||||
if (StatFs(base.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
}
|
||||
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
|
||||
val dir = File(base, id)
|
||||
val image = File(dir, "image.AppImage")
|
||||
try {
|
||||
dir.mkdirs()
|
||||
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
|
||||
// maps a program executable.
|
||||
onProgress("Copying ${file.name}")
|
||||
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
|
||||
image.setExecutable(true, false)
|
||||
onProgress("Extracting ${file.name}")
|
||||
val guestDir = "$GUEST_DIR/$id"
|
||||
val out = StringBuilder()
|
||||
val status = GuestCommand.run(context, listOf(
|
||||
"/bin/bash", "-c",
|
||||
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
|
||||
"extract", guestDir,
|
||||
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
|
||||
if (status != 0 || !File(dir, "app").isDirectory) {
|
||||
FileUtils.delete(dir)
|
||||
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
|
||||
}
|
||||
image.delete()
|
||||
describe(dir, file)
|
||||
writeMenuEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import ${file.name}", e)
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: "Import failed"
|
||||
} finally {
|
||||
image.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
|
||||
private fun describe(dir: File, source: File) {
|
||||
val app = File(dir, "app")
|
||||
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
|
||||
val text = entry?.let { FileUtils.readString(it) }
|
||||
fun key(k: String) = text?.let { desktopKey(it, k) }
|
||||
val name = key("Name") ?: source.name.substringBeforeLast('.')
|
||||
FileUtils.writeString(File(dir, "name"), name)
|
||||
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
|
||||
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
|
||||
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
|
||||
val iconName = key("Icon")
|
||||
val candidates = listOfNotNull(
|
||||
iconName?.let { File(app, "$it.png") },
|
||||
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
|
||||
File(app, ".DirIcon"),
|
||||
)
|
||||
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
|
||||
}
|
||||
|
||||
private fun isPng(f: File): Boolean = try {
|
||||
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
/** A key of a desktop entry's main group, unlocalised. */
|
||||
internal fun desktopKey(desktop: String, key: String): String? {
|
||||
var inMain = false
|
||||
for (line in desktop.lineSequence()) {
|
||||
val t = line.trim()
|
||||
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
|
||||
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
|
||||
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
|
||||
|
||||
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
|
||||
private fun writeMenuEntry(context: Context, dir: File) {
|
||||
val id = dir.name
|
||||
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
|
||||
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
|
||||
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
|
||||
FileUtils.writeString(menuEntry(context, id),
|
||||
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
|
||||
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
|
||||
FileUtils.delete(File(root(context), id))
|
||||
menuEntry(context, id).delete()
|
||||
}
|
||||
|
||||
private object Files {
|
||||
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.net.LocalServerSocket
|
||||
import android.net.LocalSocket
|
||||
import android.os.Process
|
||||
import android.system.Os
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap).
|
||||
*
|
||||
* A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount
|
||||
* namespaces Android gives no app. proot can show the same picture, and started here - beside the
|
||||
* session's proot rather than inside it - it traces the app once instead of twice. The stand-in
|
||||
* sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths),
|
||||
* this builds it under the cache and starts it, then streams the output, the pid and the exit
|
||||
* status back until one side goes away: the stand-in ending takes the sandbox down with it.
|
||||
*
|
||||
* The socket is abstract, so anything on the device could connect: only this app's own uid - the
|
||||
* uid every program in the runtime already runs as - is served.
|
||||
*/
|
||||
object BwrapSpawner {
|
||||
private const val TAG = "BwrapSpawner"
|
||||
const val SOCKET = "com.droiddeck.launcher.bwrap"
|
||||
private val seq = AtomicInteger()
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
|
||||
/** Listens for sandboxes for the rest of the process's life; later calls do nothing. */
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
if (server != null) return
|
||||
val app = context.applicationContext
|
||||
val socket = try {
|
||||
LocalServerSocket(SOCKET)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "cannot listen on $SOCKET", e)
|
||||
return
|
||||
}
|
||||
server = socket
|
||||
FileUtils.delete(File(app.cacheDir, "bwrap"))
|
||||
Thread({
|
||||
while (true) {
|
||||
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
|
||||
Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() }
|
||||
}
|
||||
}, "bwrap-server").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun serve(context: Context, client: LocalSocket) {
|
||||
val id = seq.incrementAndGet()
|
||||
val dir = File(context.cacheDir, "bwrap/$id")
|
||||
client.use {
|
||||
val out = DataOutputStream(client.outputStream)
|
||||
try {
|
||||
val peer = client.peerCredentials
|
||||
if (peer.uid != Process.myUid()) {
|
||||
Log.w(TAG, "refused a sandbox for uid ${peer.uid}")
|
||||
return
|
||||
}
|
||||
val input = DataInputStream(client.inputStream)
|
||||
val kind = input.readUnsignedByte()
|
||||
val body = ByteArray(input.readInt()).also { input.readFully(it) }
|
||||
if (kind != 'R'.code) return
|
||||
val request = JSONObject(String(body, Charsets.UTF_8))
|
||||
val plan = plan(context, request, dir)
|
||||
run(context, plan, client, out, id)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "sandbox $id", e)
|
||||
runCatching {
|
||||
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
|
||||
frame(out, 'X', "1".toByteArray())
|
||||
}
|
||||
} finally {
|
||||
FileUtils.delete(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What proot is started with: the sandbox root, its binds (host to guest), and the program. */
|
||||
class Plan(val root: File, val binds: List<Bind>, val cwd: String, val argv: List<String>, val env: Map<String, String>)
|
||||
|
||||
data class Bind(val host: String, val guest: String) {
|
||||
val spec: String get() = if (host == guest) host else "$host:$guest"
|
||||
|
||||
companion object {
|
||||
fun parse(spec: String): Bind {
|
||||
val colon = spec.indexOf(':')
|
||||
return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when [path] is [dir] or inside it. */
|
||||
internal fun under(path: String, dir: String): Boolean =
|
||||
dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/")
|
||||
|
||||
/**
|
||||
* Where a path the session's programs see lives on the host: through the session's own binds
|
||||
* (the most specific one), or else inside the rootfs.
|
||||
*/
|
||||
internal fun hostPath(sessionBinds: List<Bind>, rootfs: String, guest: String): String {
|
||||
val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length }
|
||||
?: return rootfs.trimEnd('/') + guest
|
||||
val rest = guest.substring(bind.guest.length).trimStart('/')
|
||||
return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved
|
||||
* under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what
|
||||
* Android keeps apps from reading.
|
||||
*/
|
||||
internal fun bindsFor(sessionBinds: List<Bind>, rootfs: String, src: String, dest: String): List<Bind> {
|
||||
val out = ArrayList<Bind>()
|
||||
out.add(Bind(hostPath(sessionBinds, rootfs, src), dest))
|
||||
for (b in sessionBinds.sortedBy { it.guest.length }) {
|
||||
if (b.guest != src && under(b.guest, src)) {
|
||||
out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/'))))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun plan(context: Context, request: JSONObject, dir: File): Plan {
|
||||
val rootfs = LinuxRuntime.rootDir(context).path
|
||||
val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) }
|
||||
val root = File(dir, "root").apply { mkdirs() }
|
||||
val binds = ArrayList<Bind>()
|
||||
var scratch = 0
|
||||
fun addBind(b: Bind) {
|
||||
binds.removeAll { it.guest == b.guest }
|
||||
binds.add(b)
|
||||
}
|
||||
fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length }
|
||||
fun newScratch(): File = File(dir, "x${scratch++}")
|
||||
/**
|
||||
* Where [dest] can be created on the host: inside the sandbox root, or inside a directory
|
||||
* of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the
|
||||
* GL extension's links). Null inside anything else bound in - the runtime itself, a host
|
||||
* directory - which must not be written to.
|
||||
*/
|
||||
fun writable(dest: String): File? {
|
||||
val cover = coveringBind(dest) ?: return inRoot(root, dest)
|
||||
if (!under(cover.host, dir.path)) return null
|
||||
return inRoot(File(cover.host), dest.substring(cover.guest.length))
|
||||
}
|
||||
val ops = request.getJSONArray("ops")
|
||||
for (i in 0 until ops.length()) {
|
||||
val op = ops.getJSONObject(i)
|
||||
val dest = op.optString("dest")
|
||||
when (op.getString("t")) {
|
||||
"bind" -> {
|
||||
val src = op.getString("src")
|
||||
val all = bindsFor(session, rootfs, src, dest)
|
||||
if (!File(all.first().host).exists()) {
|
||||
if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out")
|
||||
continue
|
||||
}
|
||||
all.forEach { addBind(it) }
|
||||
}
|
||||
"proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) }
|
||||
"dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) }
|
||||
"dir" -> {
|
||||
val perms = op.optInt("perms", 0x1ed)
|
||||
val here = writable(dest)
|
||||
when {
|
||||
op.optBoolean("empty") && coveringBind(dest) != null ->
|
||||
addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
|
||||
here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms)
|
||||
File(hostPath(binds, root.path, dest)).isDirectory -> {}
|
||||
else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
|
||||
}
|
||||
}
|
||||
"file" -> {
|
||||
val data = Base64.decode(op.getString("data"), Base64.DEFAULT)
|
||||
val here = writable(dest)
|
||||
val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here
|
||||
else newScratch().also { addBind(Bind(it.path, dest)) }
|
||||
f.writeBytes(data)
|
||||
chmod(f, op.optInt("perms", 0x1b6))
|
||||
}
|
||||
"symlink" -> {
|
||||
val target = op.getString("target")
|
||||
val link = writable(dest)
|
||||
if (link != null) {
|
||||
link.parentFile?.mkdirs()
|
||||
if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target))
|
||||
} else {
|
||||
// A link inside the runtime's own tree cannot be made; what it points at is
|
||||
// bound in its place.
|
||||
val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target)
|
||||
val host = hostPath(binds, root.path, abs)
|
||||
if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) }
|
||||
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
|
||||
val env = LinkedHashMap<String, String>()
|
||||
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
|
||||
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
|
||||
preload(session, rootfs, env).forEach { addBind(it) }
|
||||
browserSandboxes(env)
|
||||
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
|
||||
}
|
||||
|
||||
/** Where a sandbox finds the runtime's own GPU driver. */
|
||||
private const val GPU_DIR = "/run/droiddeck-gpu"
|
||||
|
||||
/**
|
||||
* The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM
|
||||
* kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The
|
||||
* runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks
|
||||
* but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's
|
||||
* own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone.
|
||||
*/
|
||||
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
|
||||
val lib = File(rootfs, "usr/lib")
|
||||
val driver = File(lib, "libvulkan_freedreno.so")
|
||||
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
|
||||
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
|
||||
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
|
||||
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
|
||||
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
|
||||
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
|
||||
val binds = ArrayList<Bind>()
|
||||
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
|
||||
val f = File(lib, name)
|
||||
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
|
||||
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
|
||||
}
|
||||
// Beside the sandbox root, in the sandbox's own directory: removed with it.
|
||||
val icd = File(root.parentFile, "freedreno_icd.json")
|
||||
icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n")
|
||||
binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json"))
|
||||
env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json"
|
||||
env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json"
|
||||
if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) {
|
||||
env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink"
|
||||
env["GALLIUM_DRIVER"] = "zink"
|
||||
}
|
||||
// The driver's own libraries, found beside it; nothing else is in that directory.
|
||||
env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":")
|
||||
return binds
|
||||
}
|
||||
|
||||
/**
|
||||
* What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the
|
||||
* session shim - SysV shared memory for X11, the network calls Android refuses - and the
|
||||
* controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through
|
||||
* LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app
|
||||
* saw no gamepad. Both are built against an older glibc than any Flathub runtime has.
|
||||
*/
|
||||
private fun preload(session: List<Bind>, rootfs: String, env: MutableMap<String, String>): List<Bind> {
|
||||
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
|
||||
?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList()
|
||||
if (libs.isEmpty()) return emptyList()
|
||||
val binds = ArrayList<Bind>()
|
||||
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
|
||||
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
|
||||
// Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then
|
||||
// hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start.
|
||||
// The controllers: FAKE_EVDEV_DIR is <session>/dev/input, the rings beside it in <session>/dev.
|
||||
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
|
||||
binds.addAll(bindsFor(session, rootfs, dev, dev))
|
||||
}
|
||||
return binds
|
||||
}
|
||||
|
||||
private const val PRELOAD_DIR = "/run/droiddeck-preload"
|
||||
|
||||
/**
|
||||
* Browsers sandbox their own children, and neither way works in here. Firefox wants
|
||||
* namespaces and seccomp from the kernel; its content processes died without them (the
|
||||
* desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start
|
||||
* their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses:
|
||||
* it knows a Flatpak by /proc/<pid>/root/.flatpak-info, and a proot sandbox's root is the
|
||||
* host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead.
|
||||
*/
|
||||
private fun browserSandboxes(env: MutableMap<String, String>) {
|
||||
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
|
||||
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
|
||||
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
|
||||
// Chromium on Wayland asks the render node the compositor names for its DRM version;
|
||||
// KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't
|
||||
// usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every
|
||||
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
|
||||
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
|
||||
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
|
||||
env.putIfAbsent(name, "1")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have
|
||||
* SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw
|
||||
* with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first
|
||||
* shader compiled killed the app with SIGILL. Without a part number LLVM targets generic
|
||||
* ARMv8 plus the "Features" line, which lists what the kernel really enabled.
|
||||
*/
|
||||
private fun genericCpuinfo(context: Context): File? = try {
|
||||
val out = File(context.cacheDir, "cpuinfo-generic")
|
||||
val text = File("/proc/cpuinfo").readLines()
|
||||
.filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") }
|
||||
.joinToString("\n", postfix = "\n")
|
||||
// Sandboxes start side by side; one must never read another's half-written copy.
|
||||
if (!out.isFile || out.readText() != text) {
|
||||
val staged = File.createTempFile("cpuinfo", null, context.cacheDir)
|
||||
staged.writeText(text)
|
||||
if (!staged.renameTo(out)) staged.delete()
|
||||
}
|
||||
out
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cpuinfo", e); null
|
||||
}
|
||||
|
||||
/** [path] with "." and ".." resolved lexically. */
|
||||
internal fun normalize(path: String): String {
|
||||
val out = ArrayList<String>()
|
||||
for (part in path.split('/')) when (part) {
|
||||
"", "." -> {}
|
||||
".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1)
|
||||
else -> out.add(part)
|
||||
}
|
||||
return "/" + out.joinToString("/")
|
||||
}
|
||||
|
||||
/**
|
||||
* [guest] inside the sandbox root, or null when the way there crosses a symlink: one of
|
||||
* Flatpak's (var/run -> ../run) may point anywhere once followed on the host.
|
||||
*/
|
||||
private fun inRoot(root: File, guest: String): File? {
|
||||
var f = root
|
||||
for (part in guest.split('/').filter { it.isNotEmpty() }) {
|
||||
if (part == "..") return null
|
||||
f = File(f, part)
|
||||
if (Files.isSymbolicLink(f.toPath())) return null
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
private fun chmod(f: File, mode: Int) {
|
||||
try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ }
|
||||
}
|
||||
|
||||
private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) {
|
||||
val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd)
|
||||
// DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot
|
||||
// say what it does, to logcat in full: for a sandbox that dies without a word.
|
||||
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
|
||||
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
|
||||
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
|
||||
// proot hands its own environment on; the sandbox gets exactly what Flatpak asked for.
|
||||
cmd.add("/usr/bin/env")
|
||||
cmd.add("-i")
|
||||
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
|
||||
cmd.addAll(plan.argv)
|
||||
val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root)
|
||||
builder.environment().apply {
|
||||
clear()
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
|
||||
put("PROOT_TMP_DIR", context.cacheDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
val pid = HostProcess.pidOf(process)
|
||||
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
|
||||
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
|
||||
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
|
||||
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
|
||||
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
|
||||
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
|
||||
Thread({
|
||||
try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } }
|
||||
catch (e: IOException) { /* closed */ }
|
||||
if (process.isAlive) {
|
||||
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
|
||||
process.destroy()
|
||||
}
|
||||
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
|
||||
val buffer = ByteArray(16384)
|
||||
// The start of what the sandbox says goes to logcat too: a sandbox that fails at once
|
||||
// (proot refusing a bind, a missing library) says why there even when nothing reads it.
|
||||
var logged = 0
|
||||
process.inputStream.use { stream ->
|
||||
while (true) {
|
||||
val n = try { stream.read(buffer) } catch (e: IOException) { -1 }
|
||||
if (n < 0) break
|
||||
if (logged < 4096 || verbose != null) {
|
||||
String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() }
|
||||
.forEach { Log.i(TAG, "sandbox $id: $it") }
|
||||
logged += n
|
||||
}
|
||||
try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break }
|
||||
}
|
||||
}
|
||||
val status = process.waitFor()
|
||||
com.droiddeck.launcher.session.OrphanReaper.release(pid)
|
||||
Log.i(TAG, "sandbox $id exited $status")
|
||||
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
|
||||
}
|
||||
|
||||
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
|
||||
out.writeByte(kind.code)
|
||||
out.writeInt(payload.size)
|
||||
out.write(payload)
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
import org.json.JSONArray
|
||||
|
||||
/** Optional Decky Loader installation for the ARM64 Linux guest. */
|
||||
@@ -84,7 +83,7 @@ object DeckyManager {
|
||||
val n = it.optString("name")
|
||||
(n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://")
|
||||
}?.optString("browser_download_url")?.takeIf { it.startsWith("https://") }
|
||||
val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) }
|
||||
val digest = binary.optString("digest").takeIf { Hashes.isGithubSha256(it) }
|
||||
if (digest == null && checksum == null) continue
|
||||
val size = binary.optLong("size", 0)
|
||||
if (size <= 0) continue
|
||||
@@ -109,7 +108,7 @@ object DeckyManager {
|
||||
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
|
||||
} ?: run { temp.delete(); return "Release checksum is missing" }
|
||||
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
|
||||
if (!expected.equals(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
|
||||
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
|
||||
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
|
||||
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
|
||||
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
|
||||
@@ -148,15 +147,6 @@ object DeckyManager {
|
||||
if (wipeData) File(root, "root/homebrew").deleteRecursively()
|
||||
}
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
FileInputStream(file).buffered().use { input ->
|
||||
val buffer = ByteArray(1024 * 1024)
|
||||
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
|
||||
}
|
||||
|
||||
private fun validElf(file: File, machine: Int): Boolean = runCatching {
|
||||
file.inputStream().use { input ->
|
||||
val h = ByteArray(20)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
@@ -18,6 +19,12 @@ import java.io.File
|
||||
object DesktopCatalog {
|
||||
private const val TAG = "DesktopCatalog"
|
||||
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,
|
||||
@@ -26,8 +33,8 @@ object DesktopCatalog {
|
||||
val icon: String, val category: String,
|
||||
)
|
||||
|
||||
fun fetch(): List<Entry>? {
|
||||
val body = Downloader.downloadString(CATALOG_URL) ?: return null
|
||||
fun fetch(url: String = CATALOG_URL): List<Entry>? {
|
||||
val body = Downloader.downloadString(url) ?: return null
|
||||
return try {
|
||||
val json = JSONObject(body)
|
||||
val arr = json.getJSONArray("packages")
|
||||
@@ -57,6 +64,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 =
|
||||
@@ -66,6 +82,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)
|
||||
@@ -73,11 +91,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" -> {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed
|
||||
* from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user
|
||||
* installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit.
|
||||
*
|
||||
* Every command runs in a proot of its own, outside any session: the store works whether or not
|
||||
* Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper).
|
||||
*/
|
||||
object FlatpakManager {
|
||||
private const val TAG = "FlatpakManager"
|
||||
const val FLATPAK = "/usr/bin/flatpak"
|
||||
const val BWRAP = "/usr/local/bin/bannerlator-bwrap"
|
||||
/** The program the front end starts a Flatpak app through, in a session of its own. */
|
||||
const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run"
|
||||
private const val HELPER = "/usr/local/bin/bannerlator-flatpak"
|
||||
private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup"
|
||||
private const val USER_DIR = "root/.local/share/flatpak"
|
||||
|
||||
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
|
||||
@Volatile var busy: String? = null
|
||||
private set
|
||||
|
||||
class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?)
|
||||
|
||||
/** Flatpak is in the runtime, with Flathub to install from. */
|
||||
fun ready(context: Context): Boolean {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
return File(root, FLATPAK.substring(1)).isFile &&
|
||||
File(root, "var/cache/droiddeck-flatpak/installed").isFile &&
|
||||
(FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true)
|
||||
}
|
||||
|
||||
private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) {
|
||||
"x86_64" -> "x86_64"
|
||||
else -> "aarch64"
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed apps, read from the installation itself: each app's deployed files carry the
|
||||
* desktop entry and icons it exports, so listing them needs no process in the runtime.
|
||||
*/
|
||||
fun installedApps(context: Context): List<App> {
|
||||
val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app")
|
||||
val arch = arch()
|
||||
return apps.listFiles()?.mapNotNull { dir ->
|
||||
val id = dir.name
|
||||
val archDir = File(dir, arch)
|
||||
val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null
|
||||
val active = File(branch, "active")
|
||||
val export = File(active, "export/share")
|
||||
val desktop = FileUtils.readString(File(export, "applications/$id.desktop"))
|
||||
val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.')
|
||||
val summary = desktop?.let { entry(it, "Comment") }
|
||||
App(id, name, icon(export, id), branch.name, summary)
|
||||
}?.sortedBy { it.name.lowercase() } ?: emptyList()
|
||||
}
|
||||
|
||||
fun isInstalled(context: Context, id: String): Boolean =
|
||||
File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true
|
||||
|
||||
/** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */
|
||||
private fun entry(desktop: String, key: String): String? {
|
||||
var inMain = false
|
||||
for (line in desktop.lineSequence()) {
|
||||
val t = line.trim()
|
||||
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
|
||||
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The largest PNG the app exports (the front end draws no SVG). */
|
||||
private fun icon(export: File, id: String): File? {
|
||||
val hicolor = File(export, "icons/hicolor")
|
||||
return hicolor.listFiles()
|
||||
?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } }
|
||||
?.filter { it.second.isFile }
|
||||
?.maxByOrNull { it.first }?.second
|
||||
}
|
||||
|
||||
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
|
||||
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
|
||||
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
|
||||
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
|
||||
}
|
||||
|
||||
private inline fun <T> exclusive(what: String, block: () -> T): T? {
|
||||
synchronized(this) {
|
||||
if (busy != null) return null
|
||||
busy = what
|
||||
}
|
||||
try { return block() } finally { busy = null }
|
||||
}
|
||||
|
||||
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
|
||||
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
return exclusive("setup") {
|
||||
var failure: String? = null
|
||||
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
|
||||
Log.i(TAG, "setup: $line")
|
||||
when {
|
||||
line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1)
|
||||
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
|
||||
}
|
||||
}
|
||||
when {
|
||||
failure != null -> failure
|
||||
status != 0 -> "Flatpak setup failed (exit $status)"
|
||||
!ready(context) -> "Flatpak setup did not finish"
|
||||
else -> null
|
||||
}
|
||||
} ?: "Another store task is running"
|
||||
}
|
||||
|
||||
/**
|
||||
* One store operation - install, uninstall or update - with its progress as a stage line and
|
||||
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
|
||||
*/
|
||||
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!ready(context)) return "Set up Flatpak first"
|
||||
return exclusive("$verb:${id ?: "all"}") {
|
||||
var error: String? = null
|
||||
// Progress lines carry no ref; they belong to the step the last "op" line started.
|
||||
var step = "Starting"
|
||||
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
|
||||
val status = runGuest(context, argv, fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull()
|
||||
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
|
||||
when (o.optString("e")) {
|
||||
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
|
||||
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
|
||||
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
|
||||
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
|
||||
}
|
||||
}
|
||||
error ?: if (status != 0) "Flatpak exited with status $status" else null
|
||||
} ?: "Another store task is running"
|
||||
}
|
||||
|
||||
private fun stage(o: JSONObject): String {
|
||||
val ref = o.optString("ref")
|
||||
val name = refLabel(ref)
|
||||
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
|
||||
return when (o.optString("kind")) {
|
||||
"uninstall" -> "Removing $name$step"
|
||||
"update" -> "Updating $name$step"
|
||||
else -> "Installing $name$step"
|
||||
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
|
||||
}
|
||||
|
||||
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
|
||||
internal fun refLabel(ref: String): String {
|
||||
val parts = ref.split('/')
|
||||
val id = parts.getOrNull(1) ?: return ref
|
||||
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
|
||||
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
|
||||
return when {
|
||||
id.endsWith(".Locale") -> "translations"
|
||||
".GL." in id || ".GL32." in id -> "graphics drivers"
|
||||
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
|
||||
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
|
||||
"gnome", "kde" -> vendor.uppercase()
|
||||
else -> vendor.replaceFirstChar { it.uppercase() }
|
||||
} + " runtime$branch"
|
||||
else -> id.substringAfterLast('.')
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole transaction's percentage: steps done, plus this one's share. */
|
||||
private fun overall(o: JSONObject, percent: Int): Int {
|
||||
val n = o.optInt("n").coerceAtLeast(1)
|
||||
val i = o.optInt("i").coerceIn(1, n)
|
||||
return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n)
|
||||
}
|
||||
|
||||
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
|
||||
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
|
||||
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
|
||||
|
||||
/** Installed apps with an update on Flathub, or null when that could not be checked. */
|
||||
fun updates(context: Context): Set<String>? {
|
||||
if (!ready(context)) return emptySet()
|
||||
var apps: Set<String>? = null
|
||||
exclusive("updates") {
|
||||
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
|
||||
if (o.optString("e") == "updates") {
|
||||
val a = o.optJSONArray("apps")
|
||||
apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
return apps
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.session.OrphanReaper
|
||||
import com.droiddeck.launcher.session.SessionFiles
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
|
||||
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
|
||||
*/
|
||||
object GuestCommand {
|
||||
/**
|
||||
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
|
||||
* for the package tools, which refuse any uid but 0. With [logName], everything the command
|
||||
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
|
||||
* be handed over like a session's.
|
||||
*/
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
// The resolver otherwise names the DNS of whatever network the last session was on.
|
||||
LinuxNetworkLinkComponent(context, root).publish()
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
|
||||
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
|
||||
put("PROOT_TMP_DIR", context.cacheDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
val pid = HostProcess.pidOf(process)
|
||||
OrphanReaper.keep(pid)
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
|
||||
return process.waitFor()
|
||||
} finally {
|
||||
OrphanReaper.release(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.system.StructStat;
|
||||
import android.system.StructUtsname;
|
||||
import com.droiddeck.launcher.session.SessionPrefs;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
@@ -33,7 +34,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";
|
||||
|
||||
@@ -115,22 +115,65 @@ public final class LinuxRuntime {
|
||||
File externalStorage, List<String> extraBinds,
|
||||
List<String> guestCommand) {
|
||||
File root = rootDir(context);
|
||||
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
|
||||
// A session's binds are the view its programs have; a one-off command's are not recorded.
|
||||
if (sessionRoot != null) lastBinds = binds;
|
||||
List<String> cmd = prootPrefix(context, root, "/root");
|
||||
for (String spec : binds) bind(cmd, spec);
|
||||
cmd.addAll(guestCommand);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* The binds of the last command built: the guest's view of the host, which the Flatpak
|
||||
* sandboxes started beside a session (BwrapSpawner) translate their paths through.
|
||||
*/
|
||||
private static volatile List<String> lastBinds;
|
||||
|
||||
public static List<String> lastBinds(Context context) {
|
||||
List<String> binds = lastBinds;
|
||||
if (binds != null) return binds;
|
||||
return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null);
|
||||
}
|
||||
|
||||
/** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */
|
||||
public static List<String> prootPrefix(Context context, File root, String cwd) {
|
||||
return prootPrefix(context, root, cwd, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that
|
||||
* refuse any other uid. Files it writes still belong to the app.
|
||||
*/
|
||||
public static List<String> prootPrefix(Context context, File root, String cwd, boolean fakeRoot) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(prootBinary(context).getPath());
|
||||
cmd.add("--kill-on-exit");
|
||||
// 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
|
||||
// itself while still reporting our real ids, so nothing inside sees a different user.
|
||||
int uid = Process.myUid();
|
||||
cmd.add("-i");
|
||||
cmd.add(uid + ":" + uid);
|
||||
if (fakeRoot) {
|
||||
cmd.add("-0");
|
||||
} else {
|
||||
cmd.add("-i");
|
||||
cmd.add(uid + ":" + uid);
|
||||
}
|
||||
cmd.add("-r");
|
||||
cmd.add(root.getPath());
|
||||
cmd.add("-w");
|
||||
cmd.add("/root");
|
||||
cmd.add(cwd);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */
|
||||
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
|
||||
File externalStorage, List<String> extraBinds) {
|
||||
File root = rootDir(context);
|
||||
List<String> cmd = new ArrayList<>();
|
||||
bind(cmd, "/dev");
|
||||
bind(cmd, "/proc");
|
||||
bind(cmd, "/sys");
|
||||
@@ -142,7 +185,7 @@ public final class LinuxRuntime {
|
||||
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
|
||||
bind(cmd, context.getFilesDir().getPath());
|
||||
bind(cmd, context.getCacheDir().getPath());
|
||||
bind(cmd, runtimeDir.getPath());
|
||||
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
|
||||
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
|
||||
if (externalStorage != null && externalStorage.isDirectory()) {
|
||||
bind(cmd, externalStorage.getPath());
|
||||
@@ -193,11 +236,14 @@ public final class LinuxRuntime {
|
||||
}
|
||||
}
|
||||
bindGpuNode(context, cmd);
|
||||
bindAdrenoStats(cmd);
|
||||
bindCpuTemps(cmd, root);
|
||||
if (extraBinds != null) {
|
||||
for (String spec : extraBinds) bind(cmd, spec);
|
||||
}
|
||||
cmd.addAll(guestCommand);
|
||||
return cmd;
|
||||
List<String> specs = new ArrayList<>();
|
||||
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
|
||||
return specs;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,15 +290,91 @@ public final class LinuxRuntime {
|
||||
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valve's mangoapp (Deck mode's performance overlay) reads an Adreno GPU's load, clock and
|
||||
* temperatures from where they are on Valve's own hardware; every Adreno under Android keeps
|
||||
* them in KGSL's sysfs, readable by the app, though which files a kernel has differs between
|
||||
* Snapdragon generations - so each value takes the first source that exists, the order other
|
||||
* Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left
|
||||
* alone.
|
||||
*/
|
||||
private static void bindAdrenoStats(List<String> cmd) {
|
||||
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
|
||||
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
|
||||
String[][] stats = {
|
||||
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"},
|
||||
{firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"),
|
||||
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
|
||||
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
|
||||
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
|
||||
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
|
||||
};
|
||||
for (String[] stat : stats) {
|
||||
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mangoapp's CPU temperature is the mean of the thermal zones named cpuN-thermal or
|
||||
* cpuN-top-thermal, as mainline kernels name them; Android kernels name the same sensors
|
||||
* cpuss-0, cpu-1-0, cpu0-silver-usr, apc1-cpu0-usr and the like, so those zones are shown
|
||||
* under the mainline name.
|
||||
*/
|
||||
private static void bindCpuTemps(List<String> cmd, File root) {
|
||||
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
|
||||
try {
|
||||
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
}
|
||||
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
|
||||
String type = zone.getKey();
|
||||
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
|
||||
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstReadable(String... paths) {
|
||||
for (String path : paths) {
|
||||
if (path != null && new File(path).canRead()) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The temp file of the thermal zone for a sensor: the one named so, else the first whose name has it. */
|
||||
private static String thermalZone(String sensor) {
|
||||
java.util.Map<String, File> zones = thermalZones();
|
||||
File zone = zones.get(sensor);
|
||||
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(zones).entrySet()) {
|
||||
if (zone == null && e.getKey().contains(sensor)) zone = e.getValue();
|
||||
}
|
||||
return zone != null ? new File(zone, "temp").getPath() : null;
|
||||
}
|
||||
|
||||
/** The device's thermal zones by their sensor's name, in lower case. */
|
||||
private static java.util.Map<String, File> thermalZones() {
|
||||
java.util.Map<String, File> byType = new java.util.HashMap<>();
|
||||
File[] zones = new File("/sys/class/thermal").listFiles((dir, n) -> n.startsWith("thermal_zone"));
|
||||
if (zones == null) return byType;
|
||||
for (File zone : zones) {
|
||||
try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.FileReader(new File(zone, "type")))) {
|
||||
byType.putIfAbsent(String.valueOf(r.readLine()).trim().toLowerCase(java.util.Locale.ROOT), zone);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
return byType;
|
||||
}
|
||||
|
||||
private static void bind(List<String> cmd, String spec) {
|
||||
cmd.add("-b");
|
||||
cmd.add(spec);
|
||||
}
|
||||
|
||||
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
|
||||
private static String guestUtsname() {
|
||||
static String guestUtsname(String hostname) {
|
||||
StructUtsname host = Os.uname();
|
||||
return "\\" + host.sysname + "\\" + GUEST_HOSTNAME + "\\" + host.release
|
||||
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
|
||||
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
|
||||
import com.droiddeck.launcher.core.ArchivePaths;
|
||||
import com.droiddeck.launcher.core.Downloader;
|
||||
import com.droiddeck.launcher.core.Hashes;
|
||||
import com.droiddeck.launcher.core.FileUtils;
|
||||
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
|
||||
@@ -23,7 +25,8 @@ import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* Downloads and unpacks the Linux runtime rootfs into {@code files/linuxfs}. It is opt-in: nothing
|
||||
@@ -52,7 +55,7 @@ public final class LinuxRuntimeInstaller {
|
||||
public final String sha256;
|
||||
public final long size;
|
||||
|
||||
Release(String version, String url, String sha256, long size) {
|
||||
public Release(String version, String url, String sha256, long size) {
|
||||
this.version = version;
|
||||
this.url = url;
|
||||
this.sha256 = sha256;
|
||||
@@ -60,6 +63,23 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One install per process. The launcher and the session screen can both start one, and a
|
||||
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
|
||||
* install would share the first one's archive and staging directory and wreck both.
|
||||
*/
|
||||
private static final class Job {
|
||||
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
|
||||
final CountDownLatch done = new CountDownLatch(1);
|
||||
volatile String stage = "Starting\u2026";
|
||||
volatile int percent = -1;
|
||||
volatile boolean ok;
|
||||
}
|
||||
|
||||
private static final Object JOB_LOCK = new Object();
|
||||
private static Job running;
|
||||
private static boolean removing;
|
||||
|
||||
private LinuxRuntimeInstaller() {}
|
||||
|
||||
/** The build currently unpacked, or null when the runtime is not installed. */
|
||||
@@ -104,6 +124,66 @@ public final class LinuxRuntimeInstaller {
|
||||
* not. An update therefore keeps Steam, the login and the installed games.
|
||||
*/
|
||||
public static boolean install(Context context, Release release, ProgressListener listener) {
|
||||
Job job;
|
||||
boolean owner;
|
||||
synchronized (JOB_LOCK) {
|
||||
if (removing) return false;
|
||||
owner = running == null;
|
||||
if (owner) running = new Job();
|
||||
job = running;
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
}
|
||||
if (!owner) return join(job, listener);
|
||||
try {
|
||||
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
|
||||
job.stage = stage;
|
||||
job.percent = percent;
|
||||
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
|
||||
});
|
||||
return job.ok;
|
||||
} finally {
|
||||
synchronized (JOB_LOCK) {
|
||||
running = null;
|
||||
}
|
||||
job.done.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
/** True while an install is running in this process, whoever started it. */
|
||||
public static boolean isInstalling() {
|
||||
synchronized (JOB_LOCK) {
|
||||
return running != null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
|
||||
* it ends and returns its result, or returns null at once when nothing is installing.
|
||||
*/
|
||||
public static Boolean attach(ProgressListener listener) {
|
||||
Job job;
|
||||
synchronized (JOB_LOCK) {
|
||||
job = running;
|
||||
if (job == null) return null;
|
||||
if (listener != null) job.listeners.add(listener);
|
||||
}
|
||||
return join(job, listener);
|
||||
}
|
||||
|
||||
private static boolean join(Job job, ProgressListener listener) {
|
||||
try {
|
||||
if (listener != null) listener.onProgress(job.stage, job.percent);
|
||||
job.done.await();
|
||||
return job.ok;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
} finally {
|
||||
if (listener != null) job.listeners.remove(listener);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
|
||||
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
|
||||
try {
|
||||
if (listener != null) listener.onProgress("Downloading", 0);
|
||||
@@ -120,7 +200,7 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
|
||||
if (listener != null) listener.onProgress("Verifying", -1);
|
||||
String actual = sha256(archive);
|
||||
String actual = Hashes.sha256(archive);
|
||||
if (!release.sha256.equalsIgnoreCase(actual)) {
|
||||
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
|
||||
return false;
|
||||
@@ -130,6 +210,8 @@ public final class LinuxRuntimeInstaller {
|
||||
// runtime that isInstalled() would happily launch.
|
||||
File root = LinuxRuntime.rootDir(context);
|
||||
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
|
||||
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
|
||||
recoverInterruptedSwap(root, staging, old);
|
||||
FileUtils.delete(staging);
|
||||
if (!staging.mkdirs()) return false;
|
||||
if (listener != null) listener.onProgress("Extracting", -1);
|
||||
@@ -139,7 +221,6 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
|
||||
|
||||
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
|
||||
FileUtils.delete(old);
|
||||
if (root.isDirectory() && !root.renameTo(old)) {
|
||||
FileUtils.delete(staging);
|
||||
@@ -164,6 +245,8 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
|
||||
if (!staging.renameTo(root)) {
|
||||
File keptTo = new File(staging, USER_DATA);
|
||||
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
|
||||
if (old.isDirectory()) old.renameTo(root);
|
||||
return false;
|
||||
}
|
||||
@@ -177,8 +260,38 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
public static void uninstall(Context context) {
|
||||
FileUtils.delete(LinuxRuntime.rootDir(context));
|
||||
private static void recoverInterruptedSwap(File root, File staging, File old) {
|
||||
File target = root.isDirectory() ? root : old;
|
||||
File stagedHome = new File(staging, USER_DATA);
|
||||
File home = new File(target, USER_DATA);
|
||||
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
|
||||
FileUtils.delete(home);
|
||||
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
|
||||
}
|
||||
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
|
||||
Log.w(TAG, "restored the previous runtime after an interrupted update");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEmptyDir(File dir) {
|
||||
String[] names = dir.list();
|
||||
return dir.isDirectory() && names != null && names.length == 0;
|
||||
}
|
||||
|
||||
/** Removes the runtime; returns false without touching it while an install is running. */
|
||||
public static boolean uninstall(Context context) {
|
||||
synchronized (JOB_LOCK) {
|
||||
if (running != null || removing) return false;
|
||||
removing = true;
|
||||
}
|
||||
try {
|
||||
FileUtils.delete(LinuxRuntime.rootDir(context));
|
||||
return true;
|
||||
} finally {
|
||||
synchronized (JOB_LOCK) {
|
||||
removing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,12 +306,10 @@ public final class LinuxRuntimeInstaller {
|
||||
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
|
||||
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
|
||||
TarArchiveEntry entry;
|
||||
String base = destination.getCanonicalPath() + File.separator;
|
||||
while ((entry = tar.getNextTarEntry()) != null) {
|
||||
File file = new File(destination, entry.getName());
|
||||
// Refuse anything that would land outside the runtime directory.
|
||||
if (!(file.getCanonicalPath() + (entry.isDirectory() ? File.separator : "")).startsWith(base)
|
||||
&& !file.getCanonicalPath().equals(destination.getCanonicalPath())) {
|
||||
File file = ArchivePaths.inside(destination, entry.getName());
|
||||
if (file == null) {
|
||||
Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName());
|
||||
continue;
|
||||
}
|
||||
@@ -215,7 +326,13 @@ public final class LinuxRuntimeInstaller {
|
||||
} else if (entry.isLink()) {
|
||||
// A hard link to an earlier entry. Link where the filesystem allows it and
|
||||
// fall back to a copy, which costs space but always works.
|
||||
File target = new File(destination, entry.getLinkName());
|
||||
// The target is checked too: a link to "../../shared_prefs/x" would pull a
|
||||
// file from outside the rootfs into it.
|
||||
File target = ArchivePaths.inside(destination, entry.getLinkName());
|
||||
if (target == null) {
|
||||
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
|
||||
continue;
|
||||
}
|
||||
file.delete();
|
||||
try {
|
||||
Files.createLink(file.toPath(), target.toPath());
|
||||
@@ -244,16 +361,4 @@ public final class LinuxRuntimeInstaller {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256(File file) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream in = new BufferedInputStream(new FileInputStream(file), 1 << 16)) {
|
||||
byte[] buffer = new byte[1 << 16];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) digest.update(buffer, 0, read);
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest.digest()) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.droiddeck.launcher.MainActivity
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/**
|
||||
* Keeps the process in the foreground while the Linux runtime downloads and unpacks. The runtime
|
||||
* is several hundred megabytes; without a foreground service Android may kill the app the moment
|
||||
* the user switches away, and the install starts over from the resumable part file.
|
||||
*
|
||||
* The install itself is [LinuxRuntimeInstaller.install], which runs one job per process: the
|
||||
* screen that asked for it joins the same job to show progress, so it does not matter which of the
|
||||
* two gets there first.
|
||||
*/
|
||||
class RuntimeInstallService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val release = intent?.let(::releaseFrom)
|
||||
startForeground(NOTIFICATION_ID, notification("Starting…", -1))
|
||||
if (release == null) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
Thread({
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
var shown = Int.MIN_VALUE
|
||||
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
|
||||
// Every percent at most once: a notification per read would flood the system.
|
||||
if (percent != shown) {
|
||||
shown = percent
|
||||
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
|
||||
}
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
stopSelf(startId)
|
||||
}, "runtime-install").start()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun notification(stage: String, percent: Int): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
|
||||
NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Shows while the Linux runtime downloads and installs"
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
})
|
||||
val open = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle("Installing the Linux runtime")
|
||||
.setContentText(stage)
|
||||
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
|
||||
.setContentIntent(open)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "runtime-install"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val EXTRA_VERSION = "version"
|
||||
private const val EXTRA_URL = "url"
|
||||
private const val EXTRA_SHA256 = "sha256"
|
||||
private const val EXTRA_SIZE = "size"
|
||||
|
||||
/** Starts the install of [release] in the foreground; a running install is joined instead. */
|
||||
fun start(context: Context, release: LinuxRuntimeInstaller.Release) {
|
||||
val intent = Intent(context, RuntimeInstallService::class.java)
|
||||
.putExtra(EXTRA_VERSION, release.version)
|
||||
.putExtra(EXTRA_URL, release.url)
|
||||
.putExtra(EXTRA_SHA256, release.sha256)
|
||||
.putExtra(EXTRA_SIZE, release.size)
|
||||
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
|
||||
}
|
||||
|
||||
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
|
||||
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
|
||||
val url = intent.getStringExtra(EXTRA_URL) ?: return null
|
||||
val sha256 = intent.getStringExtra(EXTRA_SHA256) ?: return null
|
||||
return LinuxRuntimeInstaller.Release(version, url, sha256, intent.getLongExtra(EXTRA_SIZE, 0L))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client.
|
||||
*
|
||||
* Every Proton the client runs games with keeps its own copy of these three inside its tree. This
|
||||
* lists them, stores packages (.wcp: tar + zstd with a profile.json) downloaded from the Nightlies
|
||||
* "-Linux" releases or imported, swaps one into a Proton, keeps the Proton's shipped files as
|
||||
* "original bundles" - one per Proton build, so a Steam update adds a new one instead of replacing
|
||||
* the last - restores those, and deletes stored packages.
|
||||
*
|
||||
* Where a Proton reads each component (its own `proton` script, ARM64 host):
|
||||
* DXVK files/lib/wine/dxvk/{aarch64,i386}-windows -> the prefix's system32 / syswow64
|
||||
* VKD3D files/lib/wine/vkd3d-proton/{aarch64,i386}-windows -> the same, on every launch
|
||||
* FEX files/lib/wine/aarch64-windows/lib{arm64ec,wow64}fex.dll
|
||||
* files/lib/wine/aarch64-unix/lib{arm64ec,wow64}fex.so (FEX 2607 and newer)
|
||||
* Proton copies DXVK and VKD3D into the prefix on every normal launch, so a swap applies the next
|
||||
* time a game starts. A swap into a Proton a running game is using waits until that game exits.
|
||||
*
|
||||
* Same formats and data layout as the Decky "Components Manager" plugin, whose data is taken over
|
||||
* once on first use.
|
||||
*/
|
||||
object ComponentsManager {
|
||||
private const val TAG = "Components"
|
||||
private const val NIGHTLIES = "The412Banner/Nightlies"
|
||||
/** Release tag -> component. Only the Linux repacks: the Android releases carry bionic unixlibs. */
|
||||
private val RELEASES = linkedMapOf(
|
||||
"FexCore-Linux" to "fex",
|
||||
"Dxvk-Linux" to "dxvk",
|
||||
"Dxvk-arm64ec-Linux" to "dxvk",
|
||||
"Dxvk-gplasync-Linux" to "dxvk",
|
||||
"Vkd3d-proton-Linux" to "vkd3d",
|
||||
)
|
||||
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
|
||||
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
|
||||
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
|
||||
val LABEL = mapOf("fex" to "FEX", "dxvk" to "DXVK", "vkd3d" to "VKD3D-Proton")
|
||||
|
||||
private const val WINE = "files/lib/wine"
|
||||
private val FEX_FILES = listOf(
|
||||
"$WINE/aarch64-windows/libarm64ecfex.dll",
|
||||
"$WINE/aarch64-windows/libwow64fex.dll",
|
||||
"$WINE/aarch64-unix/libarm64ecfex.so",
|
||||
"$WINE/aarch64-unix/libwow64fex.so",
|
||||
)
|
||||
private val COMP_DIR = mapOf("dxvk" to "$WINE/dxvk", "vkd3d" to "$WINE/vkd3d-proton")
|
||||
/** The folders a Proton reads on ARM64. */
|
||||
private val PE_ARCHES = listOf("aarch64-windows", "i386-windows")
|
||||
/**
|
||||
* The DLLs that ARE the component. A swap clears exactly these before unpacking, so no file of
|
||||
* the previous version is left mixed in; anything else a Proton keeps beside them - GE's
|
||||
* openvr_api_dxvk.dll, which its launcher copies into every prefix and aborts without - stays.
|
||||
*/
|
||||
private val COMP_DLLS = mapOf(
|
||||
"dxvk" to setOf("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"),
|
||||
"vkd3d" to setOf("d3d12.dll", "d3d12core.dll"),
|
||||
)
|
||||
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
|
||||
|
||||
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
|
||||
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
|
||||
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
|
||||
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
|
||||
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
|
||||
data class ProtonView(
|
||||
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
|
||||
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
|
||||
val reappliedAt: Long = 0,
|
||||
)
|
||||
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
|
||||
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
// ------------------------------------------------------------------ paths
|
||||
|
||||
private fun root(context: Context) = LinuxRuntime.rootDir(context)
|
||||
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
|
||||
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
|
||||
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
|
||||
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
|
||||
private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() }
|
||||
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
|
||||
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
|
||||
/**
|
||||
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
|
||||
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
|
||||
*/
|
||||
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
|
||||
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
|
||||
|
||||
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
private fun loadState(context: Context): JSONObject =
|
||||
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
|
||||
if (!has("active")) put("active", JSONObject())
|
||||
if (!has("queued")) put("queued", JSONObject())
|
||||
}
|
||||
|
||||
private fun saveState(context: Context, state: JSONObject) {
|
||||
val f = stateFile(context)
|
||||
val tmp = File(f.parentFile, f.name + ".tmp")
|
||||
tmp.writeText(state.toString(2))
|
||||
tmp.renameTo(f)
|
||||
// Every change of choice reaches the launch wrappers at once.
|
||||
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
|
||||
|
||||
/** Takes over the Decky plugin's packages, originals and swap records, once. */
|
||||
private fun migratePlugin(context: Context) {
|
||||
val marker = File(dataDir(context), ".plugin-imported")
|
||||
if (marker.isFile) return
|
||||
val plugin = File(root(context), PLUGIN_DATA)
|
||||
if (plugin.isDirectory) {
|
||||
runCatching {
|
||||
File(plugin, "packages").listFiles()?.forEach { f ->
|
||||
if (f.isFile && f.name.endsWith(".wcp")) {
|
||||
val dst = File(packagesDir(context), f.name)
|
||||
if (!dst.exists()) f.copyTo(dst)
|
||||
}
|
||||
}
|
||||
val originals = File(plugin, "originals")
|
||||
if (originals.isDirectory) originals.copyRecursively(originalsDir(context), overwrite = false)
|
||||
val ps = File(plugin, "state.json")
|
||||
if (ps.isFile && !stateFile(context).isFile) ps.copyTo(stateFile(context))
|
||||
Log.i(TAG, "took over the Decky plugin's data from $plugin")
|
||||
}.onFailure { Log.w(TAG, "plugin data import", it) }
|
||||
}
|
||||
marker.writeText("1\n")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protons
|
||||
|
||||
private fun libraryRoots(context: Context): List<File> {
|
||||
val roots = mutableListOf(steam(context))
|
||||
val vdf = File(steam(context), "steamapps/libraryfolders.vdf")
|
||||
runCatching {
|
||||
Regex("\"path\"\\s+\"([^\"]+)\"").findAll(vdf.readText()).forEach {
|
||||
val h = host(context, it.groupValues[1])
|
||||
if (roots.none { r -> r.canonicalPath == h.canonicalPath }) roots.add(h)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
private fun displayName(dir: File): String = runCatching {
|
||||
Regex("\"display_name\"\\s+\"([^\"]+)\"").find(File(dir, "compatibilitytool.vdf").readText())?.groupValues?.get(1)
|
||||
}.getOrNull() ?: dir.name
|
||||
|
||||
private fun protonVersion(dir: File): String = runCatching {
|
||||
val parts = File(dir, "version").useLines { it.firstOrNull() ?: "" }.trim().split(Regex("\\s+"))
|
||||
if (parts.size > 1) parts[1] else parts.firstOrNull()?.ifEmpty { null } ?: "unknown"
|
||||
}.getOrDefault("unknown")
|
||||
|
||||
/** Every Proton tree with a Wine inside. Launch wrappers pointing at another Proton are left out. */
|
||||
fun protons(context: Context): List<Proton> {
|
||||
val found = LinkedHashMap<String, Proton>()
|
||||
val bases = listOf(File(steam(context), "compatibilitytools.d") to false) +
|
||||
libraryRoots(context).map { File(it, "steamapps/common") to true }
|
||||
// Not canonical: /data/user/0 is a link to /data/data on many devices, and the guest path is
|
||||
// what is left after removing the rootfs prefix exactly as the paths were built.
|
||||
val rootPath = root(context).absolutePath.trimEnd('/')
|
||||
for ((base, valve) in bases) {
|
||||
val entries = base.listFiles()?.sortedBy { it.name } ?: continue
|
||||
for (dir in entries) {
|
||||
if (!File(dir, WINE).isDirectory || !File(dir, "proton").isFile) continue
|
||||
val real = runCatching { dir.canonicalPath }.getOrDefault(dir.path)
|
||||
if (found.containsKey(real)) continue
|
||||
val guest = if (dir.absolutePath.startsWith(rootPath)) dir.absolutePath.removePrefix(rootPath) else dir.absolutePath
|
||||
found[real] = Proton(safeName(dir.name), displayName(dir), dir, guest, protonVersion(dir), valve)
|
||||
}
|
||||
}
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
private fun proton(context: Context, id: String) = protons(context).firstOrNull { it.id == id }
|
||||
?: throw IllegalStateException("No Proton with id $id")
|
||||
|
||||
/**
|
||||
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
|
||||
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
|
||||
*/
|
||||
fun inUse(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
|
||||
|
||||
/**
|
||||
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
|
||||
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
|
||||
*/
|
||||
fun gameRunning(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
|
||||
|
||||
private fun anyProcess(proton: Proton, match: (String, String) -> Boolean): Boolean {
|
||||
val needle = proton.guestPath.trimEnd('/') + "/"
|
||||
val procs = File("/proc").listFiles() ?: return false
|
||||
for (p in procs) {
|
||||
if (!p.name.all(Char::isDigit)) continue
|
||||
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
|
||||
if (cmd.isEmpty()) continue
|
||||
if (match(needle, String(cmd).replace('\u0000', ' '))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ component files
|
||||
|
||||
private fun readVersionFile(f: File): String = runCatching {
|
||||
val text = f.readText().trim()
|
||||
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
|
||||
}.getOrDefault("")
|
||||
|
||||
private fun fexVersion(dir: File): String {
|
||||
val dll = File(dir, FEX_FILES[0])
|
||||
if (!dll.isFile) return ""
|
||||
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
|
||||
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
|
||||
?: "FEX (build without a version name)"
|
||||
}
|
||||
|
||||
private fun compFiles(dir: File, comp: String): List<String> {
|
||||
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
|
||||
val base = File(dir, COMP_DIR.getValue(comp))
|
||||
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
|
||||
}
|
||||
|
||||
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
|
||||
|
||||
private fun fingerprint(dir: File, comp: String): Map<String, String> =
|
||||
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { Hashes.sha256(File(dir, it)) }
|
||||
|
||||
// ------------------------------------------------------------------ packages
|
||||
|
||||
private fun normalize(name: String) = name.removePrefix("./").trimStart('/')
|
||||
|
||||
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
|
||||
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
|
||||
|
||||
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
|
||||
}
|
||||
throw IllegalArgumentException("no profile.json in ${f.name}")
|
||||
}
|
||||
|
||||
private fun packageInfo(f: File): Package {
|
||||
val prof = readProfile(f)
|
||||
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
|
||||
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
|
||||
}
|
||||
|
||||
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
|
||||
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
|
||||
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
|
||||
private fun installFiles(context: Context, dir: File, comp: String, wcp: File): Map<String, String> {
|
||||
val staged = File(dataDir(context), "staging-${System.nanoTime()}")
|
||||
try {
|
||||
readWcp(wcp) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (e.isDirectory || !rel.startsWith("files/")) continue
|
||||
require(rel.split('/').none { it == ".." }) { "unsafe path $rel" }
|
||||
val out = File(staged, rel)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { tar.copyTo(it) }
|
||||
}
|
||||
}
|
||||
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
|
||||
COMP_DIR[comp]?.let { compDir ->
|
||||
for (arch in PE_ARCHES) {
|
||||
val target = File(dir, "$compDir/$arch")
|
||||
if (!File(staged, "$compDir/$arch").isDirectory || !target.isDirectory) continue
|
||||
target.listFiles()?.filter { it.name.lowercase() in COMP_DLLS.getValue(comp) }?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
val hashes = LinkedHashMap<String, String>()
|
||||
File(staged, "files").walkTopDown().filter { it.isFile }.forEach { src ->
|
||||
val rel = src.relativeTo(staged).path
|
||||
val dst = File(dir, rel)
|
||||
dst.parentFile?.mkdirs()
|
||||
val tmp = File(dst.parentFile, dst.name + ".dcm-new")
|
||||
src.copyTo(tmp, overwrite = true)
|
||||
if (rel.endsWith(".so")) tmp.setExecutable(true, false)
|
||||
tmp.setReadable(true, false)
|
||||
if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } }
|
||||
hashes[rel] = Hashes.sha256(dst)
|
||||
}
|
||||
return hashes
|
||||
} finally {
|
||||
staged.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeWcp(dest: File, profile: JSONObject, dir: File, rels: List<String>) {
|
||||
val tmp = File(dest.parentFile, dest.name + ".part")
|
||||
TarArchiveOutputStream(ZstdOutputStream(BufferedOutputStream(FileOutputStream(tmp), 1 shl 16), 9)).use { tar ->
|
||||
tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX)
|
||||
val bytes = profile.toString(2).toByteArray()
|
||||
tar.putArchiveEntry(TarArchiveEntry("./profile.json").apply { size = bytes.size.toLong() })
|
||||
tar.write(bytes); tar.closeArchiveEntry()
|
||||
for (rel in rels) {
|
||||
val f = File(dir, rel)
|
||||
tar.putArchiveEntry(TarArchiveEntry(f, "./$rel"))
|
||||
FileInputStream(f).use { it.copyTo(tar) }
|
||||
tar.closeArchiveEntry()
|
||||
}
|
||||
}
|
||||
tmp.renameTo(dest)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ originals
|
||||
|
||||
private fun originalFile(context: Context, p: Proton, comp: String) =
|
||||
File(originalsDir(context), "${p.id}/${safeName(p.version)}/$comp.wcp").apply { parentFile?.mkdirs() }
|
||||
|
||||
fun originals(context: Context, protonId: String): List<Original> {
|
||||
val base = File(originalsDir(context), protonId)
|
||||
val versions = base.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.lastModified() } ?: return emptyList()
|
||||
return versions.flatMap { v ->
|
||||
COMPONENTS.mapNotNull { comp ->
|
||||
val f = File(v, "$comp.wcp")
|
||||
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a Proton's shipped files for this component before they are first replaced in this
|
||||
* Proton build. A build already swapped into is not saved again: its files are ours.
|
||||
*/
|
||||
private fun ensureOriginal(context: Context, p: Proton, comp: String, state: JSONObject) {
|
||||
val dest = originalFile(context, p, comp)
|
||||
if (dest.isFile) return
|
||||
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
if (active != null && active.optString("protonVersion") == p.version) return
|
||||
val rels = compFiles(p.dir, comp)
|
||||
if (rels.isEmpty()) return
|
||||
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val profile = JSONObject()
|
||||
.put("type", TYPE_OF.getValue(comp))
|
||||
.put("versionName", "original-${p.version}")
|
||||
.put("versionCode", 0)
|
||||
.put("description", "Original · ${p.name} · ${p.version} · ${LABEL.getValue(comp)} $current".trim())
|
||||
.put("files", JSONArray(rels.map { JSONObject().put("source", it).put("target", "\${proton}/$it") }))
|
||||
writeWcp(dest, profile, p.dir, rels)
|
||||
Log.i(TAG, "saved original $comp of ${p.name} ${p.version}")
|
||||
}
|
||||
|
||||
/** Saves the originals of every Proton build not seen yet (first open, and after a Steam update). */
|
||||
fun snapshotAll(context: Context) = synchronized(lock) {
|
||||
migratePlugin(context)
|
||||
val state = loadState(context)
|
||||
for (p in protons(context)) for (comp in COMPONENTS) {
|
||||
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
|
||||
}
|
||||
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ view
|
||||
|
||||
fun snapshot(context: Context): Snapshot = synchronized(lock) {
|
||||
migratePlugin(context)
|
||||
val state = loadState(context)
|
||||
val reapplied = HashMap<String, Long>()
|
||||
runCatching {
|
||||
File(launchDir(context), "reapplied.log").forEachLine { line ->
|
||||
val parts = line.split('\t')
|
||||
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
|
||||
}
|
||||
}
|
||||
val views = protons(context).map { p ->
|
||||
val comps = COMPONENTS.associateWith { comp ->
|
||||
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
|
||||
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
val inUseLabel = when {
|
||||
active == null -> "Original"
|
||||
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
|
||||
else -> {
|
||||
val want = active.optJSONObject("files") ?: JSONObject()
|
||||
val fp = fingerprint(p.dir, comp)
|
||||
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
|
||||
else "Changed outside Components"
|
||||
}
|
||||
}
|
||||
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
Component(
|
||||
detected.ifEmpty { "not present" }, detail, inUseLabel,
|
||||
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
|
||||
q?.optString("label"),
|
||||
)
|
||||
}
|
||||
ProtonView(p, comps, originals(context, p.id), gameRunning(p), reapplied[p.dir.name] ?: 0)
|
||||
}
|
||||
Snapshot(views, packages(context))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ launch-time enforcement
|
||||
|
||||
/**
|
||||
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
|
||||
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
|
||||
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
|
||||
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
|
||||
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
|
||||
* what overwrote files in the first place.
|
||||
*/
|
||||
private fun syncLaunchState(context: Context, state: JSONObject) {
|
||||
val dir = launchDir(context)
|
||||
val store = File(dir, "store").apply { mkdirs() }
|
||||
val all = protons(context)
|
||||
val lines = StringBuilder()
|
||||
val keep = HashSet<String>()
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val p = all.firstOrNull { it.id == pid } ?: continue
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val a = comps.optJSONObject(comp) ?: continue
|
||||
if (a.optString("protonVersion") != p.version) continue
|
||||
val file = a.optString("file")
|
||||
val (key, wcp) = if (file.startsWith("original ")) {
|
||||
val v = file.removePrefix("original ")
|
||||
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
|
||||
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
|
||||
if (!wcp.isFile) continue
|
||||
val unpacked = File(store, key)
|
||||
if (!File(unpacked, ".complete").isFile) {
|
||||
unpacked.deleteRecursively()
|
||||
readWcp(wcp) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
|
||||
val out = File(unpacked, rel)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { tar.copyTo(it) }
|
||||
}
|
||||
}
|
||||
File(unpacked, ".complete").writeText("1\n")
|
||||
}
|
||||
keep += key
|
||||
val files = a.optJSONObject("files") ?: continue
|
||||
for (rel in files.keys()) {
|
||||
val src = File(unpacked, rel)
|
||||
if (!src.isFile) continue
|
||||
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
|
||||
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
|
||||
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
|
||||
val list = File(dir, "desired.tsv")
|
||||
val tmp = File(dir, "desired.tsv.tmp")
|
||||
tmp.writeText(lines.toString())
|
||||
tmp.renameTo(list)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ actions
|
||||
|
||||
/** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */
|
||||
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
val wcp = File(packagesDir(context), safeName(file))
|
||||
val info = packageInfo(wcp)
|
||||
val state = loadState(context)
|
||||
if (inUse(p)) {
|
||||
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
|
||||
saveState(context, state)
|
||||
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
|
||||
}
|
||||
applyPackage(context, p, info, wcp, state)
|
||||
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
|
||||
saveState(context, state)
|
||||
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
|
||||
}
|
||||
|
||||
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
|
||||
ensureOriginal(context, p, info.comp, state)
|
||||
val hashes = installFiles(context, p.dir, info.comp, wcp)
|
||||
state.sub("active").sub(p.id).put(info.comp, JSONObject()
|
||||
.put("file", wcp.name).put("label", info.version).put("protonVersion", p.version)
|
||||
.put("files", JSONObject(hashes.filterKeys { owned(info.comp, it) } as Map<*, *>))
|
||||
.put("at", System.currentTimeMillis() / 1000))
|
||||
Log.i(TAG, "swapped ${info.comp} ${info.version} into ${p.name} ${p.version}")
|
||||
}
|
||||
|
||||
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(wcp.isFile) { "That original bundle is not stored" }
|
||||
val state = loadState(context)
|
||||
if (inUse(p)) {
|
||||
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
|
||||
saveState(context, state)
|
||||
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
|
||||
}
|
||||
applyOriginal(context, p, comp, protonVersion, wcp, state)
|
||||
state.sub("queued").optJSONObject(p.id)?.remove(comp)
|
||||
saveState(context, state)
|
||||
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
|
||||
}
|
||||
|
||||
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
|
||||
val current = protonVersion == safeName(p.version)
|
||||
if (!current) ensureOriginal(context, p, comp, state)
|
||||
installFiles(context, p.dir, comp, wcp)
|
||||
val active = state.sub("active").sub(p.id)
|
||||
if (current) active.remove(comp)
|
||||
else active.put(comp, JSONObject().put("file", "original $protonVersion").put("label", "Original from $protonVersion")
|
||||
.put("protonVersion", p.version).put("files", JSONObject(fingerprint(p.dir, comp) as Map<*, *>)).put("at", System.currentTimeMillis() / 1000))
|
||||
}
|
||||
|
||||
/** Runs the swaps that waited for a game to close. Cheap when nothing is queued. */
|
||||
fun applyQueued(context: Context): List<String> = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
val queued = state.sub("queued")
|
||||
if (queued.length() == 0) return emptyList()
|
||||
val done = mutableListOf<String>()
|
||||
val all = protons(context)
|
||||
for (pid in queued.keys().asSequence().toList()) {
|
||||
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
|
||||
if (inUse(p)) continue
|
||||
val comps = queued.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys().asSequence().toList()) {
|
||||
val q = comps.optJSONObject(comp) ?: continue
|
||||
runCatching {
|
||||
if (q.optString("kind") == "original") {
|
||||
val v = q.optString("protonVersion")
|
||||
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
|
||||
} else {
|
||||
val wcp = File(packagesDir(context), q.optString("file"))
|
||||
applyPackage(context, p, packageInfo(wcp), wcp, state)
|
||||
}
|
||||
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
|
||||
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
|
||||
comps.remove(comp)
|
||||
}
|
||||
if (comps.length() == 0) queued.remove(pid)
|
||||
}
|
||||
saveState(context, state)
|
||||
done
|
||||
}
|
||||
|
||||
fun cancelQueued(context: Context, protonId: String, comp: String) = synchronized(lock) {
|
||||
val state = loadState(context)
|
||||
state.sub("queued").optJSONObject(protonId)?.remove(comp)
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
|
||||
val name = safeName(file)
|
||||
val state = loadState(context)
|
||||
val active = state.sub("active")
|
||||
for (pid in active.keys()) {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
|
||||
}
|
||||
val f = File(packagesDir(context), name)
|
||||
check(f.isFile) { "$name is not stored" }
|
||||
f.delete()
|
||||
"Deleted $name."
|
||||
}
|
||||
|
||||
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
|
||||
val p = proton(context, protonId)
|
||||
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
|
||||
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
|
||||
check(f.isFile) { "Not stored" }
|
||||
f.delete()
|
||||
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
|
||||
"Deleted the $protonVersion original of ${LABEL[comp]}."
|
||||
}
|
||||
|
||||
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
|
||||
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
|
||||
val info = packageInfo(source)
|
||||
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
|
||||
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
|
||||
info.copy(file = dest.name)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ catalog
|
||||
|
||||
/** The Nightlies listing: cached on disk, fetched again only when [force] (the refresh button). */
|
||||
fun catalog(context: Context, force: Boolean): Catalog {
|
||||
val cache = catalogFile(context)
|
||||
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
|
||||
if (!force) return Catalog(emptyList(), 0)
|
||||
val items = JSONArray()
|
||||
for ((tag, comp) in RELEASES) {
|
||||
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
|
||||
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.optString("name")
|
||||
if (!name.endsWith(".wcp")) continue
|
||||
// Only packages GitHub has a sha256 for are offered: the download is checked against it.
|
||||
if (!Hashes.isGithubSha256(a.optString("digest"))) continue
|
||||
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
|
||||
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
|
||||
}
|
||||
}
|
||||
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
|
||||
val tmp = File(cache.parentFile, cache.name + ".tmp")
|
||||
tmp.writeText(out.toString()); tmp.renameTo(cache)
|
||||
return parseCatalog(out)
|
||||
}
|
||||
|
||||
private fun parseCatalog(o: JSONObject): Catalog {
|
||||
val arr = o.optJSONArray("items") ?: JSONArray()
|
||||
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
|
||||
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
|
||||
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
|
||||
return Catalog(items, o.optLong("fetchedAt"))
|
||||
}
|
||||
|
||||
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
|
||||
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
|
||||
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
|
||||
val dest = File(packagesDir(context), safeName(item.file))
|
||||
val part = File(dest.parentFile, dest.name + ".part")
|
||||
part.delete()
|
||||
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
|
||||
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
|
||||
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
|
||||
synchronized(lock) { part.renameTo(dest) }
|
||||
return info.copy(file = dest.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object GameEnvironmentStore {
|
||||
private const val SETTINGS = "game-environment.json"
|
||||
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): GameEnvironment.Config {
|
||||
val file = AtomicFile(File(context.filesDir, SETTINGS))
|
||||
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
|
||||
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
fun decode(json: JSONObject): GameEnvironment.Config {
|
||||
require(json.getInt("version") == 1)
|
||||
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
|
||||
value?.keys()?.forEach { name ->
|
||||
require(GameEnvironment.validName(name))
|
||||
val entry = value.get(name)
|
||||
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
|
||||
put(name, if (entry == JSONObject.NULL) null else entry as String)
|
||||
}
|
||||
}
|
||||
val games = json.optJSONObject("games")
|
||||
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
|
||||
games?.keys()?.forEach { id ->
|
||||
require(id.isNotEmpty() && GameEnvironment.validScope(id))
|
||||
put(id, entries(games.getJSONObject(id)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun encode(config: GameEnvironment.Config): JSONObject {
|
||||
fun entries(values: Map<String, String?>) = JSONObject().apply {
|
||||
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
|
||||
}
|
||||
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
|
||||
config.games.forEach { (id, values) -> put(id, entries(values)) }
|
||||
})
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save(context: Context, config: GameEnvironment.Config) {
|
||||
val json = encode(config)
|
||||
decode(json)
|
||||
write(File(context.filesDir, SETTINGS), json.toString())
|
||||
publish(context, config)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
|
||||
}
|
||||
|
||||
private fun write(file: File, text: String) {
|
||||
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
try {
|
||||
output.write(text.toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's
|
||||
* drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at
|
||||
* "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but
|
||||
* Public becomes steamuser, and on the way out the chosen layout decides the user segment.
|
||||
*
|
||||
* Saves belong to the game's prefix (compatdata/<appid>/pfx), whichever Proton it runs with.
|
||||
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
|
||||
*/
|
||||
object GameSaves {
|
||||
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
|
||||
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
|
||||
WINLATOR("Winlator zip", "drive_c/", "xuser"),
|
||||
}
|
||||
|
||||
/** A game, its prefix, and the save folders found in it. */
|
||||
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
|
||||
val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true
|
||||
}
|
||||
|
||||
/** A folder of saves, relative to the steamuser profile. */
|
||||
data class SaveDir(val relPath: String, val files: Int, val bytes: Long)
|
||||
|
||||
private const val USER = "steamuser"
|
||||
|
||||
fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves")
|
||||
private fun backupsDir(): File = File(savesDir(), "backups")
|
||||
|
||||
/** [game] with its prefix and the saves found there; reads files, so not on the main thread. */
|
||||
fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList())
|
||||
|
||||
// ------------------------------------------------------------------ finding saves
|
||||
|
||||
/** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */
|
||||
private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow")
|
||||
private const val KEEP_THRESHOLD = 50
|
||||
|
||||
private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER")
|
||||
|
||||
/**
|
||||
* Folders under the save roots, one and two deep, whose names match the game's name or its folder's.
|
||||
* Nested matches keep the outer folder only.
|
||||
*/
|
||||
fun locate(prefix: File, game: Library.SteamGame): List<SaveDir> {
|
||||
val profile = profile(prefix)
|
||||
if (!profile.isDirectory) return emptyList()
|
||||
val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() }
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
val hits = LinkedHashMap<String, Int>()
|
||||
fun consider(dir: File) {
|
||||
val d = ident(dir.name)
|
||||
if (d.norm.isEmpty()) return
|
||||
val s = ids.maxOf { score(it, d) }
|
||||
if (s < KEEP_THRESHOLD) return
|
||||
val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/')
|
||||
if ((hits[rel] ?: -1) < s) hits[rel] = s
|
||||
}
|
||||
for (root in SAVE_ROOTS) {
|
||||
File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 ->
|
||||
consider(d1)
|
||||
d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider)
|
||||
}
|
||||
}
|
||||
val kept = ArrayList<String>()
|
||||
for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) {
|
||||
if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel)
|
||||
}
|
||||
return kept.map { rel ->
|
||||
var files = 0
|
||||
var bytes = 0L
|
||||
File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() }
|
||||
SaveDir(rel, files, bytes)
|
||||
}.filter { it.files > 0 }
|
||||
}
|
||||
|
||||
private data class Ident(val norm: String, val tokens: Set<String>)
|
||||
|
||||
private fun ident(raw: String): Ident {
|
||||
val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "")
|
||||
return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet())
|
||||
}
|
||||
|
||||
/** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */
|
||||
private fun score(a: Ident, b: Ident): Int {
|
||||
if (a.norm.isEmpty() || b.norm.isEmpty()) return 0
|
||||
if (a.norm == b.norm) return 100
|
||||
if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70
|
||||
if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() &&
|
||||
a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50
|
||||
return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0
|
||||
}
|
||||
|
||||
private fun levenshtein(a: String, b: String): Int {
|
||||
val prev = IntArray(b.length + 1) { it }
|
||||
val cur = IntArray(b.length + 1)
|
||||
for (i in 1..a.length) {
|
||||
cur[0] = i
|
||||
for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1)
|
||||
System.arraycopy(cur, 0, prev, 0, cur.size)
|
||||
}
|
||||
return prev[b.length]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ export
|
||||
|
||||
private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date())
|
||||
private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" }
|
||||
|
||||
/**
|
||||
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
|
||||
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
|
||||
*/
|
||||
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
dir.mkdirs()
|
||||
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
|
||||
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
|
||||
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
|
||||
return out to count
|
||||
}
|
||||
|
||||
private fun zipProfile(profile: File, roots: List<String>?, layout: Layout, out: File): Int {
|
||||
if (!profile.isDirectory) return 0
|
||||
val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile)
|
||||
var count = 0
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos ->
|
||||
for (top in scope) top.walkTopDown()
|
||||
.onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } }
|
||||
.filter { it.isFile }
|
||||
.forEach { f ->
|
||||
val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/')
|
||||
zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel"))
|
||||
f.inputStream().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ import
|
||||
|
||||
/**
|
||||
* Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser
|
||||
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
|
||||
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
|
||||
*/
|
||||
fun import(game: Game, zip: File): Pair<Int, File?> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
val driveC = File(prefix, "drive_c")
|
||||
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
|
||||
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
|
||||
|
||||
backupsDir().mkdirs()
|
||||
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
|
||||
val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup)
|
||||
if (backedUp == 0) backup.delete()
|
||||
|
||||
val base = driveC.canonicalFile
|
||||
var written = 0
|
||||
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
|
||||
var e = zis.nextEntry
|
||||
while (e != null) {
|
||||
remap(e.name)?.let { rel ->
|
||||
val out = File(driveC, rel).canonicalFile
|
||||
if (out.path.startsWith(base.path + File.separator)) {
|
||||
if (e.isDirectory) out.mkdirs()
|
||||
else {
|
||||
out.parentFile?.mkdirs()
|
||||
Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
written++
|
||||
}
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
e = zis.nextEntry
|
||||
}
|
||||
}
|
||||
return written to backup.takeIf { it.exists() }
|
||||
}
|
||||
|
||||
/** Which layout a zip was written in, from its first user folder; null when it has none. */
|
||||
fun layoutOf(zip: File): Layout? = runCatching {
|
||||
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
|
||||
var e = zis.nextEntry
|
||||
while (e != null) {
|
||||
val segs = driveCSegments(e.name)
|
||||
if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true))
|
||||
return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB
|
||||
e = zis.nextEntry
|
||||
}
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun looksLikeSaveZip(zip: File): Boolean = runCatching {
|
||||
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
|
||||
var e = zis.nextEntry
|
||||
while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry }
|
||||
false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */
|
||||
private fun remap(raw: String): String? {
|
||||
val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null
|
||||
if (segs.any { it == ".." }) return null
|
||||
// GameHub also packs its own launcher shortcuts; they are never saves.
|
||||
if (segs[0].equals("proton_shortcuts", true)) return null
|
||||
val last = segs.last().lowercase()
|
||||
if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null
|
||||
if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER
|
||||
return segs.joinToString("/")
|
||||
}
|
||||
|
||||
private fun driveCSegments(raw: String): MutableList<String>? {
|
||||
val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." }
|
||||
if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null
|
||||
return segs.drop(1).toMutableList()
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,20 @@ import java.nio.charset.StandardCharsets
|
||||
* progress, and lifting that out is the difference between "starting the Steam client" for three
|
||||
* minutes and a percentage that moves.
|
||||
*/
|
||||
class LoadingState(context: Context) {
|
||||
class LoadingState(context: Context, steam: Boolean = true) {
|
||||
var visible by mutableStateOf(true)
|
||||
var step by mutableStateOf("Starting the session…")
|
||||
var percent by mutableIntStateOf(-1)
|
||||
var elapsed by mutableStateOf("")
|
||||
var hint by mutableStateOf("")
|
||||
var ended by mutableStateOf(false)
|
||||
/** The technical side of an ended session (exit status, log path), under the advice in [step]. */
|
||||
var endedDetail by mutableStateOf<String?>(null)
|
||||
|
||||
private val hints = context.resources.getStringArray(com.droiddeck.launcher.R.array.loading_hints)
|
||||
// The desktop and the emulators get their own: the Steam ones talk about Steam.
|
||||
private val hints = context.resources.getStringArray(
|
||||
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
|
||||
)
|
||||
private val startedAt = SystemClock.elapsedRealtime()
|
||||
|
||||
/** Once a second: the clock and the hint. */
|
||||
@@ -35,10 +40,11 @@ class LoadingState(context: Context) {
|
||||
hint = hints[((seconds / 8) % hints.size).toInt()]
|
||||
}
|
||||
|
||||
fun showEnded(message: String) {
|
||||
fun showEnded(message: String, detail: String? = null) {
|
||||
visible = true
|
||||
ended = true
|
||||
step = message
|
||||
endedDetail = detail
|
||||
}
|
||||
|
||||
/** Re-reads the end of the log and updates the line and the bar. */
|
||||
|
||||
@@ -13,17 +13,28 @@ import java.io.File
|
||||
* force-stop the app before every launch. Everything of ours that should be alive between
|
||||
* sessions is inside the app process, so at session start anything else under our uid is a
|
||||
* leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name.
|
||||
*
|
||||
* The one exception is work the app started outside any session and is still waiting on - a
|
||||
* Flatpak install from the store - registered with [keep]: that process and everything under it
|
||||
* are left to finish.
|
||||
*/
|
||||
object OrphanReaper {
|
||||
private const val TAG = "OrphanReaper"
|
||||
private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>()
|
||||
|
||||
/** Spares [pid] and its descendants until [release]. */
|
||||
fun keep(pid: Int) { if (pid > 0) kept.add(pid) }
|
||||
|
||||
fun release(pid: Int) { kept.remove(pid) }
|
||||
|
||||
fun reap(reason: String): Int {
|
||||
val me = android.os.Process.myPid()
|
||||
val uid = android.os.Process.myUid()
|
||||
val spared = descendants(kept)
|
||||
var killed = 0
|
||||
File("/proc").listFiles()?.forEach { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@forEach
|
||||
if (pid == me || pid <= 1) return@forEach
|
||||
if (pid == me || pid <= 1 || pid in spared) return@forEach
|
||||
if (uidOf(pid) != uid) return@forEach
|
||||
val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" }
|
||||
try {
|
||||
@@ -38,6 +49,25 @@ object OrphanReaper {
|
||||
return killed
|
||||
}
|
||||
|
||||
/** [roots] and every process below them, by each process's parent in /proc/<pid>/stat. */
|
||||
private fun descendants(roots: Set<Int>): Set<Int> {
|
||||
if (roots.isEmpty()) return emptySet()
|
||||
val parent = HashMap<Int, Int>()
|
||||
File("/proc").listFiles()?.forEach { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@forEach
|
||||
val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach }
|
||||
// "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it.
|
||||
stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it }
|
||||
}
|
||||
val out = HashSet(roots)
|
||||
var grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun uidOf(pid: Int): Int {
|
||||
return try {
|
||||
File("/proc/$pid/status").useLines { lines ->
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PerformanceHintManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the
|
||||
* panel's frame period as its target and each presented frame's interval as the actual. When a
|
||||
* frame runs long the power HAL raises CPU clocks for the next one at once, instead of the
|
||||
* governor waiting for the load average to climb - the difference between a menu that stutters
|
||||
* on the first scroll and one that does not.
|
||||
*
|
||||
* The session may only carry this process's threads, so the guest (gamescope, the Steam client
|
||||
* under FEX) cannot join it; they are covered by the app-wide game category and sustained mode.
|
||||
* Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns
|
||||
* null and the log says so.
|
||||
*/
|
||||
object PerfHints {
|
||||
private const val TAG = "PerfHints"
|
||||
|
||||
@Volatile private var session: PerformanceHintManager.Session? = null
|
||||
@Volatile private var target = 0L
|
||||
/** Compositor thread only. */
|
||||
private var last = 0L
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
/** Reports leave the compositor thread: each one is a binder call to the power HAL. */
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } }
|
||||
|
||||
/** Opens the session once the compositor thread exists (it is started just before this). */
|
||||
fun arm(context: Context, refreshHz: Float) {
|
||||
if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return }
|
||||
val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong()
|
||||
val s = session
|
||||
if (s != null) {
|
||||
if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } }
|
||||
return
|
||||
}
|
||||
target = wanted
|
||||
val app = context.applicationContext
|
||||
var tries = 0
|
||||
main.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (session != null) return
|
||||
val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 }
|
||||
if (tid == 0) {
|
||||
if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid")
|
||||
return
|
||||
}
|
||||
open(app, tid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
private fun open(app: Context, tid: Int) {
|
||||
val pm = app.getSystemService(PerformanceHintManager::class.java)
|
||||
if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return }
|
||||
val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null }
|
||||
if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return }
|
||||
last = 0L
|
||||
session = s
|
||||
Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs")
|
||||
}
|
||||
|
||||
/** Compositor thread, once per presented game frame; the interval since the last is the actual. */
|
||||
@JvmStatic
|
||||
fun onFrame() {
|
||||
val s = session ?: return
|
||||
val now = System.nanoTime()
|
||||
val prev = last
|
||||
last = now
|
||||
if (prev == 0L) return
|
||||
val dt = now - prev
|
||||
// Under half a millisecond is a double present; over half a second is a pause, not a frame.
|
||||
if (dt < 500_000L || dt > 500_000_000L) return
|
||||
worker.execute {
|
||||
try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class PerfHud(context: Context) {
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
WaylandCompositor.setGameListener(null)
|
||||
WaylandCompositor.clearGameListener(listener)
|
||||
text = ""
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.Activity
|
||||
import android.app.GameManager
|
||||
import android.app.GameState
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
@@ -13,8 +12,10 @@ import android.util.Log
|
||||
* The manifest already makes the app a game (appCategory + game_mode_config); this is the
|
||||
* per-window half, applied when the session activity is created:
|
||||
*
|
||||
* - sustained performance mode (Android 7+): a clock floor that does not throttle away after
|
||||
* two minutes, which suits an hour in Big Picture better than a boost that fades;
|
||||
* - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at
|
||||
* a level the device can hold indefinitely rather than holding a floor, which cost games and
|
||||
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
|
||||
* the game;
|
||||
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
|
||||
* 120 Hz for the menu, not only when a game votes for it;
|
||||
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
|
||||
@@ -27,15 +28,7 @@ object PerfMode {
|
||||
private const val TAG = "PerfMode"
|
||||
|
||||
fun apply(a: Activity): String {
|
||||
val parts = ArrayList<String>(3)
|
||||
|
||||
// Sustained performance mode.
|
||||
parts += if (Build.VERSION.SDK_INT >= 24) {
|
||||
val pm = a.getSystemService(PowerManager::class.java)
|
||||
if (pm?.isSustainedPerformanceModeSupported == true) {
|
||||
try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" }
|
||||
} else "sustained mode unsupported"
|
||||
} else "sustained mode needs Android 7"
|
||||
val parts = ArrayList<String>(2)
|
||||
|
||||
// The fastest display mode at the panel's current size.
|
||||
parts += try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
@@ -7,7 +8,6 @@ import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import org.json.JSONArray
|
||||
|
||||
/** Immediate download and installation of the optional ARM64 Proton builds. */
|
||||
@@ -21,7 +21,9 @@ object ProtonExtras {
|
||||
|
||||
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
|
||||
|
||||
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long)
|
||||
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
|
||||
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
|
||||
val sha256: String? = null)
|
||||
|
||||
val tools = listOf(
|
||||
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
@@ -99,14 +101,24 @@ object ProtonExtras {
|
||||
return "Download was incomplete; the partial file is kept for a resumable retry"
|
||||
}
|
||||
|
||||
asset.sha512?.let { checksumUrl ->
|
||||
onProgress("Verifying download", -1)
|
||||
val checksumText = Downloader.downloadString(checksumUrl)
|
||||
val expected = checksumText?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
|
||||
if (expected != null && !expected.equals(sha512(archive), ignoreCase = true)) {
|
||||
archive.delete()
|
||||
return "Checksum mismatch; the download was deleted"
|
||||
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
|
||||
// release's own sha512 file. A download neither vouches for is not installed.
|
||||
onProgress("Verifying download", -1)
|
||||
val verified = when {
|
||||
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
|
||||
asset.sha512 != null -> {
|
||||
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
|
||||
?: return "Could not read the release checksum; try again"
|
||||
expected.equals(Hashes.sha512(archive), ignoreCase = true)
|
||||
}
|
||||
else -> {
|
||||
archive.delete()
|
||||
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
|
||||
}
|
||||
}
|
||||
if (!verified) {
|
||||
archive.delete()
|
||||
return "Checksum mismatch; the download was deleted"
|
||||
}
|
||||
|
||||
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
|
||||
@@ -170,6 +182,7 @@ object ProtonExtras {
|
||||
release.optString("tag_name"), name,
|
||||
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
|
||||
archive.optLong("size", 0L),
|
||||
Hashes.githubSha256(archive.optString("digest")),
|
||||
)
|
||||
}
|
||||
null
|
||||
@@ -179,19 +192,6 @@ object ProtonExtras {
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha512(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-512")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(4 * 1024 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun requestLines(context: Context): List<String> =
|
||||
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.session
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
|
||||
/**
|
||||
* Other apps on the device that sign into the user's Steam account with their own client. Steam
|
||||
@@ -34,31 +33,21 @@ object RivalClients {
|
||||
fun name(pkg: String): String = if (pkg == "com.xiaoji.egggame") "GameHub" else pkg
|
||||
|
||||
/**
|
||||
* Stop the installed rivals' background processes before the client starts, and say so. A
|
||||
* rival in the foreground survives this, so the wording covers that case too. Call on the main
|
||||
* thread; the Toast is shown from here.
|
||||
* Stop the installed rivals' background processes before the client starts. A rival in the
|
||||
* foreground survives this. The activity service call and its log messages are unchanged.
|
||||
*/
|
||||
fun stopBeforeSession(context: Context) {
|
||||
val rivals = installed(context)
|
||||
if (rivals.isEmpty()) return
|
||||
val who = name(rivals[0])
|
||||
Log.w(TAG, "competing Steam client installed: $rivals")
|
||||
var asked = false
|
||||
try {
|
||||
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
if (am != null) {
|
||||
for (pkg in rivals) am.killBackgroundProcesses(pkg)
|
||||
asked = true
|
||||
Log.i(TAG, "asked Android to stop background processes of $rivals")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "could not stop competing Steam clients", t)
|
||||
}
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (asked) "Closed $who in the background - it signs into your Steam account."
|
||||
else "If $who is open, close it first - it signs into your Steam account and will sign this client out.",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ object SessionFiles {
|
||||
* rename, so a session that still has one mapped keeps the file it opened.
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
GameEnvironmentStore.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
|
||||
@@ -38,7 +40,15 @@ 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",
|
||||
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
|
||||
"usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap",
|
||||
"usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak",
|
||||
"usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup",
|
||||
"usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run",
|
||||
// The user's own AppImages, extracted at import (AppImageManager).
|
||||
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
@@ -148,8 +158,161 @@ object SessionFiles {
|
||||
staged.delete()
|
||||
Log.e(TAG, "could not write ld.so.preload")
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
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/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
|
||||
} else ""
|
||||
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
|
||||
val movie = findObject(text, "StartupMovie", 0, text.length)
|
||||
if (movie != null) {
|
||||
val block = text.substring(movie.keyStart, movie.close + 1)
|
||||
val selectedId = scalar(block, "MovieID")
|
||||
val selectedPath = scalar(block, "LocalPath")
|
||||
if (selectedPath == path) {
|
||||
Log.i(TAG, "Steam startup movie is set to DroidDeck")
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
val updated = setScalar(block, "MovieID", "0", newline)
|
||||
.let { setScalar(it, "LocalPath", path, newline) }
|
||||
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
|
||||
} else {
|
||||
val steam = findSteamBlock(text)
|
||||
if (steam == null) {
|
||||
if (text.isNotBlank()) {
|
||||
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
|
||||
return
|
||||
}
|
||||
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
|
||||
val section = startupMovieSection("\t\t\t\t", path, newline)
|
||||
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
|
||||
} else {
|
||||
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
|
||||
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
|
||||
val section = startupMovieSection(indent + "\t", path, newline)
|
||||
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
|
||||
}
|
||||
}
|
||||
val staged = File(config.parentFile, config.name + ".staged")
|
||||
try {
|
||||
config.parentFile?.mkdirs()
|
||||
staged.writeText(text)
|
||||
if (!staged.renameTo(config)) {
|
||||
staged.delete()
|
||||
Log.e(TAG, "could not write Steam startup movie selection")
|
||||
} else {
|
||||
Log.i(TAG, "Steam startup movie default selected")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not write Steam startup movie selection", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findSteamBlock(text: String): VdfBlock? {
|
||||
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
|
||||
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
|
||||
val valve = findObject(text, "Valve", software.open + 1, software.close) ?: return null
|
||||
return findObject(text, "Steam", valve.open + 1, valve.close)
|
||||
}
|
||||
|
||||
private fun findObject(text: String, key: String, start: Int, end: Int): VdfBlock? {
|
||||
val pattern = Regex("(?m)^[\\t ]*\"${Regex.escape(key)}\"[\\t ]*(?:\\r?\\n[\\t ]*)?\\{")
|
||||
val match = pattern.find(text, start)?.takeIf { it.range.first < end && it.range.last < end } ?: return null
|
||||
val open = text.indexOf('{', match.range.first).takeIf { it < end } ?: return null
|
||||
var depth = 0
|
||||
var quoted = false
|
||||
var escaped = false
|
||||
for (index in open until end) {
|
||||
val char = text[index]
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false
|
||||
else if (char == '\\') escaped = true
|
||||
else if (char == '\"') quoted = false
|
||||
} else {
|
||||
when (char) {
|
||||
'\"' -> quoted = true
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) return VdfBlock(match.range.first, open, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun scalar(block: String, key: String): String? {
|
||||
val pattern = Regex("(?m)^[\\t ]*\"${Regex.escape(key)}\"[\\t ]+\"([^\"\\r\\n]*)\"")
|
||||
return pattern.find(block)?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
private fun setScalar(block: String, key: String, value: String, newline: String): String {
|
||||
val pattern = Regex("(?m)^([\\t ]*\"${Regex.escape(key)}\"[\\t ]+)\"[^\"\\r\\n]*\"([\\t ]*)$")
|
||||
val match = pattern.find(block)
|
||||
if (match != null) return block.replaceRange(match.range, match.groupValues[1] + "\"" + value + "\"" + match.groupValues[2])
|
||||
val close = block.lastIndexOf('}')
|
||||
if (close < 0) return block
|
||||
val lineStart = block.lastIndexOf('\n', close).let { if (it < 0) 0 else it + 1 }
|
||||
val indent = block.substring(lineStart, close).takeWhile { it == '\t' || it == ' ' } + "\t"
|
||||
val entry = indent + "\"" + key + "\"\t\t\"" + value + "\"" + newline
|
||||
return block.substring(0, lineStart) + entry + block.substring(lineStart)
|
||||
}
|
||||
|
||||
private fun startupMovieSection(indent: String, path: String, newline: String): String = listOf(
|
||||
indent + "\"StartupMovie\"",
|
||||
indent + "{",
|
||||
indent + "\t\"MovieID\"\t\t\"0\"",
|
||||
indent + "\t\"LocalPath\"\t\t\"" + path + "\"",
|
||||
indent + "}",
|
||||
).joinToString(newline)
|
||||
|
||||
/**
|
||||
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
|
||||
* folder rather than dug out of app-private storage - but the session script redirects its own
|
||||
|
||||
@@ -8,6 +8,8 @@ object SessionPrefs {
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
const val SUSPEND_NEVER = "never"
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
const val OSC_AUTO = "auto"
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
@@ -21,6 +23,27 @@ 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()
|
||||
}
|
||||
|
||||
/** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */
|
||||
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
|
||||
|
||||
fun setStoreEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("storeEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */
|
||||
fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false)
|
||||
|
||||
fun setAppImagesEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
|
||||
}
|
||||
|
||||
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
|
||||
|
||||
fun setHudEnabled(context: Context, on: Boolean) {
|
||||
@@ -136,8 +159,8 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("directAudio", on).apply()
|
||||
}
|
||||
|
||||
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
|
||||
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
|
||||
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
|
||||
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
|
||||
|
||||
/** Whether the app has already asked for the microphone once at start-up. */
|
||||
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
|
||||
@@ -181,14 +204,28 @@ object SessionPrefs {
|
||||
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
|
||||
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
|
||||
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
|
||||
* it storms, and the session dies seconds after Big Picture appears.
|
||||
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
|
||||
* does run, it sits beside every game under FEX for nothing a controller-first session needs
|
||||
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
|
||||
*/
|
||||
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
|
||||
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
|
||||
|
||||
fun setNoXalia(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("noXalia", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
|
||||
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
|
||||
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
|
||||
* whose GPU is waiting on the CPU.
|
||||
*/
|
||||
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
|
||||
|
||||
fun setGamescopeRealtime(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
|
||||
*
|
||||
@@ -204,8 +241,29 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
|
||||
}
|
||||
|
||||
/** 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)
|
||||
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.
|
||||
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
|
||||
* client's interface as many small render passes, each paying GMEM's load/store and binning,
|
||||
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
|
||||
*/
|
||||
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
|
||||
|
||||
fun setTuSysmem(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("tuSysmem", on).apply()
|
||||
@@ -225,11 +283,23 @@ object SessionPrefs {
|
||||
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
|
||||
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
|
||||
|
||||
/** `steam -steamdeck -steamos3`: the client as SteamOS runs it. Expects Deck hardware; untested. */
|
||||
/**
|
||||
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
|
||||
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
*/
|
||||
fun steamController(context: Context): String =
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */
|
||||
/**
|
||||
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
|
||||
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
|
||||
*/
|
||||
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
|
||||
|
||||
fun setZinkLazy(context: Context, on: Boolean) {
|
||||
@@ -286,11 +356,20 @@ object SessionPrefs {
|
||||
fun resolutionChosen(context: Context, mode: String): Boolean =
|
||||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||||
|
||||
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
|
||||
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
|
||||
/**
|
||||
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
|
||||
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
|
||||
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
|
||||
* given another preset.
|
||||
*/
|
||||
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
|
||||
|
||||
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
|
||||
|
||||
fun setFexPreset(context: Context, id: String) {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
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 +385,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 +422,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()
|
||||
|
||||
@@ -57,6 +57,9 @@ import java.util.Locale
|
||||
*/
|
||||
class SessionService : Service() {
|
||||
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
|
||||
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
|
||||
private var deckBinds: List<String> = emptyList()
|
||||
private val stopLock = Any()
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var sessionPid = -1
|
||||
@@ -84,7 +87,7 @@ 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()
|
||||
@@ -263,150 +266,17 @@ class SessionService : Service() {
|
||||
killStragglers()
|
||||
SessionFiles.stage(this, root)
|
||||
|
||||
// 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)
|
||||
val sessionDir = openSessionFolder()
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
SessionState.logFile = sessionLog
|
||||
SessionState.logDirectory = sessionDir
|
||||
SessionState.sessionId = sessionDir.name
|
||||
SessionState.eventsFile = File(sessionDir, "events.jsonl")
|
||||
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
|
||||
// Written first, so a session that dies in its first second still says what it ran on.
|
||||
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
|
||||
NetworkReport.write(this, File(sessionDir, "network.txt"))
|
||||
// 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"))
|
||||
|
||||
val size = SessionState.outputSize
|
||||
val guest = ArrayList<String>()
|
||||
guest.add("/usr/bin/env")
|
||||
guest.add("-i")
|
||||
guest.add("HOME=/root")
|
||||
guest.add("USER=root")
|
||||
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
|
||||
guest.add("TERM=xterm-256color")
|
||||
guest.add("LANG=C.UTF-8")
|
||||
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
|
||||
// session bundle sit hours off the device's. Bannerlator carries the same line.
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
|
||||
guest.add("XDG_SESSION_TYPE=wayland")
|
||||
guest.add("WAYLAND_DISPLAY=wayland-0")
|
||||
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
|
||||
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
|
||||
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
|
||||
guest.add("GALLIUM_DRIVER=zink")
|
||||
guest.add("LIBGL_KOPPER_DRI2=true")
|
||||
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
|
||||
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
|
||||
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
|
||||
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
|
||||
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
|
||||
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
|
||||
// build, and with two drivers every emulator compiled its shaders twice - once per way of
|
||||
// starting it (RPCS3's 6650 interpreter variants on each first boot).
|
||||
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
|
||||
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
|
||||
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
|
||||
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
|
||||
// Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an
|
||||
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
|
||||
// its authors advise for those GPUs and what nothing else in the list needs.
|
||||
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
|
||||
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
|
||||
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
|
||||
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
|
||||
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
|
||||
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
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"))
|
||||
SessionPrefs.writeForceFullscreenFlag(this)
|
||||
}
|
||||
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
|
||||
// whose seccomp answers its syscalls normally there is no reason to take it away.
|
||||
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,
|
||||
// the client's - whatever the experiment needs, without a build per attempt.
|
||||
extraEnv().forEach { guest.add(it) }
|
||||
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
|
||||
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
|
||||
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
|
||||
// sent only when it is a real restriction - a game has no pin of its own to undo.
|
||||
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
|
||||
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
|
||||
}
|
||||
// The game cores also pin the desktop and a program from the rail; without a choice the
|
||||
// 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") }
|
||||
addClientEnvironment(guest, runtimeDir, steamHere)
|
||||
|
||||
// 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
|
||||
// changes what games do and leaves the client alone. The microphone is its own opt-in on
|
||||
// top, and the helper only opens an input stream when asked - so a user who wants game
|
||||
// sound but no recording gets exactly that, and Android's recording indicator stays off.
|
||||
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
|
||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
// Both paths sit under the app's files directory, which the session binds at its own path,
|
||||
// so the same string is valid on both sides and nothing has to be translated.
|
||||
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
|
||||
val relaySocket = File(audioDir, "relay.sock")
|
||||
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
|
||||
|
||||
val audioLog = File(sessionDir, "audio.log")
|
||||
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
|
||||
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
|
||||
// fills the relay's ring and the relay, outside proot, drives the device.
|
||||
// The client's own sound: the classic AAudio sink unless the user chose the relay.
|
||||
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
|
||||
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
|
||||
pulse.setLogFile(audioLog)
|
||||
pulse.attach(this)
|
||||
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
|
||||
components.add(pulse)
|
||||
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
|
||||
// After the daemon in the list, so it can wait for the pipe the daemon makes.
|
||||
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
|
||||
relay.setLogFile(audioLog)
|
||||
relay.attach(this)
|
||||
components.add(relay)
|
||||
}
|
||||
if (wantsDirectAudio) {
|
||||
// Read by the Proton wrappers, which point Wine at the driver and name it in the
|
||||
// prefix. Absent, they take an early return and the game uses Proton's own audio - so
|
||||
// this variable is the whole of the selection.
|
||||
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
|
||||
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
|
||||
}
|
||||
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 ""))
|
||||
val pulse = startAudio(guest, sessionDir)
|
||||
|
||||
guest.add("BL_WIDTH=" + size.first)
|
||||
guest.add("BL_HEIGHT=" + size.second)
|
||||
@@ -424,28 +294,9 @@ class SessionService : Service() {
|
||||
|
||||
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
|
||||
}
|
||||
SessionState.deckPad = false
|
||||
deckBinds = emptyList()
|
||||
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
|
||||
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
|
||||
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
|
||||
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
|
||||
@@ -462,6 +313,7 @@ class SessionService : Service() {
|
||||
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).
|
||||
@@ -506,8 +358,309 @@ class SessionService : Service() {
|
||||
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
|
||||
FileUtils.clear(File(cacheDir, "shm"))
|
||||
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir)
|
||||
|
||||
val command = LinuxRuntime.command(
|
||||
this, 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.command(
|
||||
this, 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)
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
SessionState.logFile = sessionLog
|
||||
SessionState.logDirectory = sessionDir
|
||||
SessionState.sessionId = sessionDir.name
|
||||
SessionState.eventsFile = File(sessionDir, "events.jsonl")
|
||||
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
|
||||
// Written first, so a session that dies in its first second still says what it ran on.
|
||||
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
|
||||
NetworkReport.write(this, File(sessionDir, "network.txt"))
|
||||
// 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
|
||||
}
|
||||
|
||||
/** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */
|
||||
private fun addClientEnvironment(guest: MutableList<String>, runtimeDir: File, steamHere: Boolean) {
|
||||
guest.add("/usr/bin/env")
|
||||
guest.add("-i")
|
||||
guest.add("HOME=/root")
|
||||
guest.add("USER=root")
|
||||
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
|
||||
guest.add("TERM=xterm-256color")
|
||||
guest.add("LANG=C.UTF-8")
|
||||
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
|
||||
// session bundle sit hours off the device's. Bannerlator carries the same line.
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
|
||||
guest.add("XDG_SESSION_TYPE=wayland")
|
||||
guest.add("WAYLAND_DISPLAY=wayland-0")
|
||||
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
|
||||
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
|
||||
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
|
||||
guest.add("GALLIUM_DRIVER=zink")
|
||||
guest.add("LIBGL_KOPPER_DRI2=true")
|
||||
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
|
||||
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
|
||||
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
|
||||
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
|
||||
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
|
||||
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
|
||||
// build, and with two drivers every emulator compiled its shaders twice - once per way of
|
||||
// starting it (RPCS3's 6650 interpreter variants on each first boot).
|
||||
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
|
||||
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
|
||||
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
|
||||
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
|
||||
// Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an
|
||||
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
|
||||
// its authors advise for those GPUs and what nothing else in the list needs.
|
||||
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
|
||||
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
|
||||
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
|
||||
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
|
||||
// compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default).
|
||||
if (SessionPrefs.zinkLazy(this)) {
|
||||
guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
}
|
||||
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
|
||||
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
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"))
|
||||
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"))
|
||||
SessionPrefs.writeForceFullscreenFlag(this)
|
||||
}
|
||||
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
|
||||
// a slice of a core for gamepad navigation the session already has.
|
||||
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
|
||||
// gamescope's realtime Vulkan queues (the session script turns this into
|
||||
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
|
||||
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
// the client's - whatever the experiment needs, without a build per attempt.
|
||||
extraEnv().forEach { guest.add(it) }
|
||||
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
|
||||
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
|
||||
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
|
||||
// sent only when it is a real restriction - a game has no pin of its own to undo.
|
||||
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
|
||||
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
|
||||
}
|
||||
// The game cores also pin the desktop and a program from the rail; without a choice the
|
||||
// session script picks every core but the slowest cluster for those (program_cores).
|
||||
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
|
||||
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
|
||||
}
|
||||
|
||||
/** PulseAudio, and the DirectAudio relay when a path needs it; returns the daemon for suspend and resume. */
|
||||
private fun startAudio(guest: MutableList<String>, sessionDir: File): PulseAudioComponent {
|
||||
// PulseAudio always: the client is a native Linux program and has no other way to make a
|
||||
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
|
||||
// an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it
|
||||
// changes what games do and leaves the client alone. The microphone is its own opt-in on
|
||||
// top, and the helper only opens an input stream when asked - so a user who wants game
|
||||
// sound but no recording gets exactly that, and Android's recording indicator stays off.
|
||||
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
|
||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
// Both paths sit under the app's files directory, which the session binds at its own path,
|
||||
// so the same string is valid on both sides and nothing has to be translated.
|
||||
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
|
||||
val relaySocket = File(audioDir, "relay.sock")
|
||||
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
|
||||
|
||||
val audioLog = File(sessionDir, "audio.log")
|
||||
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
|
||||
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
|
||||
// fills the relay's ring and the relay, outside proot, drives the device.
|
||||
// The client's own sound: the classic AAudio sink unless the user chose the relay.
|
||||
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
|
||||
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
|
||||
pulse.setLogFile(audioLog)
|
||||
pulse.attach(this)
|
||||
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
|
||||
components.add(pulse)
|
||||
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
|
||||
// After the daemon in the list, so it can wait for the pipe the daemon makes.
|
||||
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
|
||||
relay.setLogFile(audioLog)
|
||||
relay.attach(this)
|
||||
components.add(relay)
|
||||
}
|
||||
if (wantsDirectAudio) {
|
||||
// Read by the Proton wrappers, which point Wine at the driver and name it in the
|
||||
// prefix. Absent, they take an early return and the game uses Proton's own audio - so
|
||||
// this variable is the whole of the selection.
|
||||
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
|
||||
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
|
||||
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
|
||||
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
|
||||
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
|
||||
// not there any more; a client that crashed never took its own down.
|
||||
fakeInputDir.listFiles()?.forEach { file ->
|
||||
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
|
||||
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
|
||||
}
|
||||
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
|
||||
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
|
||||
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
|
||||
// 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")
|
||||
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
|
||||
// In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over
|
||||
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
|
||||
// Decided only once its sysfs is in place: without it the client would find no Deck, and
|
||||
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
if (uinput) {
|
||||
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
|
||||
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
|
||||
} else if (SessionState.mode == MODE_STEAM) {
|
||||
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
|
||||
// for games the client starts only. Everywhere else (the desktop, a program from the
|
||||
// rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it
|
||||
// runs under Steam, so every SDL emulator came up with no controller.
|
||||
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
|
||||
}
|
||||
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
|
||||
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
|
||||
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
|
||||
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
|
||||
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
|
||||
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
|
||||
guest.add("FAKE_EVDEV_LOG=1")
|
||||
}
|
||||
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): ArrayList<String> {
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
if (controllersOn) binds.addAll(deckBinds)
|
||||
// The client's battery readout (the Quick Access Menu, the top bar) reads
|
||||
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
|
||||
// called "battery" and its files differ, so the client sees no battery at all. A directory
|
||||
@@ -563,85 +716,7 @@ 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.command(
|
||||
this, 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.command(
|
||||
this, 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) {
|
||||
@@ -699,6 +774,24 @@ 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) {
|
||||
@@ -714,6 +807,30 @@ 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
|
||||
@@ -826,8 +943,12 @@ 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()
|
||||
@@ -846,8 +967,11 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
if (!SessionState.running) return
|
||||
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))
|
||||
@@ -885,15 +1009,18 @@ 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)
|
||||
finishSessionStop(status)
|
||||
killGuestLeftovers()
|
||||
finishSessionStop(status, stoppedGen)
|
||||
}, "session-teardown").start()
|
||||
} else {
|
||||
finishSessionStop(status)
|
||||
finishSessionStop(status, stoppedGen)
|
||||
}
|
||||
}
|
||||
if (prootPid > 1) acquireLocks()
|
||||
@@ -1045,8 +1172,14 @@ 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"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
|
||||
private const val FIRST_VIRTUAL_PAD = 16
|
||||
|
||||
const val EXTRA_MODE = "mode"
|
||||
const val MODE_STEAM = "steam"
|
||||
@@ -1075,6 +1208,10 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
if (!SessionState.running) {
|
||||
SessionState.stopRequested = true
|
||||
return
|
||||
}
|
||||
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,27 @@ object SessionState {
|
||||
* gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */
|
||||
@JvmStatic var hdr = false
|
||||
|
||||
/** The pad is presented to the Steam client as a Steam Deck controller this session
|
||||
* (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by
|
||||
* the service as the session starts, which can be after the activity has resumed - hence
|
||||
* [deckPadListener]. */
|
||||
@Volatile private var deckPadValue = false
|
||||
@JvmStatic var deckPad: Boolean
|
||||
get() = deckPadValue
|
||||
set(value) {
|
||||
if (deckPadValue == value) return
|
||||
deckPadValue = value
|
||||
deckPadListener?.invoke()
|
||||
}
|
||||
|
||||
/** Told (on the thread that changed it) whenever [deckPad] changes. */
|
||||
@Volatile var deckPadListener: (() -> Unit)? = null
|
||||
|
||||
/** What the second screen shows, and on which display: kept for the session, so a screen
|
||||
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
|
||||
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
|
||||
@Volatile var secondScreenDisplay = -1
|
||||
|
||||
/** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */
|
||||
@Volatile
|
||||
var mode = "steam"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The session's pad, described to the Steam client as a Steam Deck controller.
|
||||
*
|
||||
* On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller
|
||||
* through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it
|
||||
* itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring);
|
||||
* what the client also needs is the sysfs it walks to find and identify that node, which a sandbox
|
||||
* shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and
|
||||
* returns the binds that put it in place:
|
||||
*
|
||||
* - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID
|
||||
* device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`;
|
||||
* - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists;
|
||||
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
|
||||
* runtime already binds there for the GPU);
|
||||
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
|
||||
* which libudev's enumeration leaves a device node out.
|
||||
*
|
||||
* Symlinks are absolute guest paths; proot resolves them inside the session.
|
||||
*/
|
||||
object SteamDeckPad {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */
|
||||
private const val MAJOR = 240
|
||||
private const val MINOR = 16
|
||||
private const val NODE = "hidraw$MINOR"
|
||||
private const val GUEST_DEVICES = "/sys/devices/droiddeck"
|
||||
private const val USB = "$GUEST_DEVICES/usb1"
|
||||
private const val INTERFACE = "$USB/1-1:1.2"
|
||||
private const val HID = "$INTERFACE/0003:28DE:1205.0001"
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
|
||||
private val REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
|
||||
0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07,
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
return try {
|
||||
FileUtils.clear(base)
|
||||
fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() }
|
||||
fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
|
||||
fun link(file: File, target: String) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.delete()
|
||||
Os.symlink(target, file.path)
|
||||
}
|
||||
|
||||
write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n")
|
||||
write(USB, "idVendor", "28de\n")
|
||||
write(USB, "idProduct", "1205\n")
|
||||
write(USB, "bcdDevice", "0100\n")
|
||||
write(USB, "manufacturer", "Valve Software\n")
|
||||
write(USB, "product", "Steam Deck Controller\n")
|
||||
write(USB, "serial", "$SERIAL\n")
|
||||
link(File(dir(USB), "subsystem"), "/sys/bus/usb")
|
||||
|
||||
write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n")
|
||||
write(INTERFACE, "bInterfaceNumber", "02\n")
|
||||
write(INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
|
||||
write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" +
|
||||
"HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" +
|
||||
"HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n")
|
||||
File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
|
||||
link(File(dir(HID), "subsystem"), "/sys/bus/hid")
|
||||
|
||||
write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n")
|
||||
write(HIDRAW, "dev", "$MAJOR:$MINOR\n")
|
||||
link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(HIDRAW), "device"), HID)
|
||||
|
||||
hidrawClass.mkdirs()
|
||||
link(File(hidrawClass, NODE), HIDRAW)
|
||||
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
|
||||
|
||||
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
|
||||
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.AppImageManager
|
||||
import java.io.File
|
||||
|
||||
/** The imported AppImages and the import in progress, for the Desktop page. */
|
||||
object AppImageState {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
|
||||
private set
|
||||
/** The file being imported and what the import is doing; null when idle. */
|
||||
var importing by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
/** Why the last import was refused, shown on the page until the next one. */
|
||||
var lastError by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val list = AppImageManager.list(app)
|
||||
main.post { items = list }
|
||||
}, "appimage-refresh").start()
|
||||
}
|
||||
|
||||
fun import(context: Context, file: File) {
|
||||
if (importing != null) return
|
||||
val app = context.applicationContext
|
||||
importing = file.name; stage = "Starting…"; lastError = null
|
||||
Thread({
|
||||
val problem = try {
|
||||
AppImageManager.import(app, file) { s -> main.post { stage = s } }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
val list = AppImageManager.list(app)
|
||||
main.post {
|
||||
importing = null; stage = null; items = list
|
||||
lastError = problem?.let { "${file.name}: $it" }
|
||||
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "appimage-import").start()
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
AppImageManager.remove(app, id)
|
||||
val list = AppImageManager.list(app)
|
||||
main.post { items = list }
|
||||
}, "appimage-remove").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an
|
||||
* app's AppStream data. Only what this device can install is kept - apps built for aarch64.
|
||||
*/
|
||||
object FlathubApi {
|
||||
private const val TAG = "FlathubApi"
|
||||
private const val BASE = "https://flathub.org/api/v2"
|
||||
private const val ARCH = "aarch64"
|
||||
private const val TIMEOUT_MS = 20_000
|
||||
|
||||
class AppSummary(
|
||||
val id: String, val name: String, val summary: String, val icon: String?,
|
||||
val developer: String?, val verified: Boolean, val installs: Int,
|
||||
)
|
||||
|
||||
class Screenshot(val thumb: String, val full: String)
|
||||
|
||||
class AppDetails(
|
||||
val id: String, val name: String, val summary: String, val description: String,
|
||||
val developer: String?, val icon: String?, val version: String?, val license: String?,
|
||||
val homepage: String?, val screenshots: List<Screenshot>, val categories: List<String>,
|
||||
val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List<String>,
|
||||
)
|
||||
|
||||
/** A category as Flathub's search filters it, with the name shown for it. */
|
||||
class Category(val id: String, val label: String)
|
||||
|
||||
val categories = listOf(
|
||||
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
|
||||
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
|
||||
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
|
||||
Category("Utility", "Utilities"),
|
||||
)
|
||||
|
||||
/** Front-page collections: popular, trending, recently-updated, recently-added. */
|
||||
fun collection(name: String, count: Int = 30): List<AppSummary>? {
|
||||
// The collections are not filtered by architecture; ask for more and keep what fits.
|
||||
val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null
|
||||
return hits(body)?.take(count)
|
||||
}
|
||||
|
||||
/** Search, optionally within one category (Flathub's main_categories facet). */
|
||||
fun search(query: String, category: String? = null, count: Int = 48): List<AppSummary>? {
|
||||
val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH))
|
||||
if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category))
|
||||
val payload = JSONObject().put("query", query).put("filters", filters)
|
||||
.put("hits_per_page", count).put("page", 1)
|
||||
val body = post("$BASE/search", payload.toString()) ?: return null
|
||||
return hits(body)
|
||||
}
|
||||
|
||||
/** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */
|
||||
fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png"
|
||||
|
||||
fun details(id: String): AppDetails? {
|
||||
val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
|
||||
val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
val shots = app.optJSONArray("screenshots")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null
|
||||
val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) }
|
||||
.map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") }
|
||||
.filter { it.second.startsWith("http") }.sortedBy { it.first }
|
||||
if (all.isEmpty()) null
|
||||
else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second,
|
||||
all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second)
|
||||
}
|
||||
} ?: emptyList()
|
||||
val release = app.optJSONArray("releases")?.optJSONObject(0)
|
||||
val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
|
||||
val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
|
||||
return AppDetails(
|
||||
id = app.optString("id", id),
|
||||
name = app.optString("name", id),
|
||||
summary = app.optString("summary"),
|
||||
description = plainText(app.optString("description")),
|
||||
developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
|
||||
icon = app.optString("icon").takeIf { it.startsWith("http") },
|
||||
version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" },
|
||||
license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" },
|
||||
homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") },
|
||||
screenshots = shots,
|
||||
categories = cats,
|
||||
runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() },
|
||||
downloadSize = summary?.optLong("download_size") ?: 0L,
|
||||
installedSize = summary?.optLong("installed_size") ?: 0L,
|
||||
arches = arches,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hits(body: String): List<AppSummary>? = try {
|
||||
val arr = JSONObject(body).getJSONArray("hits")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val arches = o.optJSONArray("arches")
|
||||
if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null
|
||||
if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null
|
||||
AppSummary(
|
||||
id = o.optString("app_id"),
|
||||
name = o.optString("name"),
|
||||
summary = o.optString("summary"),
|
||||
icon = o.optString("icon").takeIf { it.startsWith("http") },
|
||||
developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
|
||||
verified = o.optBoolean("verification_verified"),
|
||||
installs = o.optInt("installs_last_month"),
|
||||
)
|
||||
}.filter { it.id.isNotEmpty() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hits: $e"); null
|
||||
}
|
||||
|
||||
/** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */
|
||||
internal fun plainText(html: String): String = html
|
||||
.replace(Regex("(?i)<li>\\s*"), "• ")
|
||||
.replace(Regex("(?i)</(p|li|ul|ol)>"), "\n")
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.lines().map { it.trim().replace(Regex("\\s+"), " ") }
|
||||
.joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim()
|
||||
|
||||
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||
|
||||
private fun get(url: String): String? = request(url, null)
|
||||
|
||||
private fun post(url: String, json: String): String? = request(url, json)
|
||||
|
||||
private fun request(url: String, json: String?): String? {
|
||||
var c: HttpURLConnection? = null
|
||||
return try {
|
||||
c = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (json != null) {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
outputStream.use { it.write(json.toByteArray()) }
|
||||
}
|
||||
}
|
||||
if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null }
|
||||
else c.inputStream.bufferedReader().use { it.readText() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$url: $e"); null
|
||||
} finally {
|
||||
c?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.FlatpakManager
|
||||
|
||||
/**
|
||||
* The store's state for the life of the process, so an install carries on - and its progress
|
||||
* shows - while the user moves around the launcher or plays something meanwhile.
|
||||
*/
|
||||
object StoreState {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
var ready by mutableStateOf(false)
|
||||
private set
|
||||
var installed by mutableStateOf<List<FlatpakManager.App>>(emptyList())
|
||||
private set
|
||||
var updates by mutableStateOf<Set<String>>(emptySet())
|
||||
private set
|
||||
var checkingUpdates by mutableStateOf(false)
|
||||
private set
|
||||
/** Flathub has been asked this session, so an empty [updates] means up to date. */
|
||||
var updatesChecked by mutableStateOf(false)
|
||||
private set
|
||||
/** What is running: "setup", an app id, or "update-all"; null when idle. */
|
||||
var busy by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var percent by mutableStateOf(-1)
|
||||
private set
|
||||
|
||||
/** The front page's sections, by collection name; absent until loaded, empty list on failure. */
|
||||
val sections = mutableStateMapOf<String, List<FlathubApi.AppSummary>>()
|
||||
var sectionsFailed by mutableStateOf(false)
|
||||
private set
|
||||
val details = mutableStateMapOf<String, FlathubApi.AppDetails>()
|
||||
val detailsFailed = mutableStateMapOf<String, Boolean>()
|
||||
|
||||
var searchResults by mutableStateOf<List<FlathubApi.AppSummary>?>(null)
|
||||
private set
|
||||
var searching by mutableStateOf(false)
|
||||
private set
|
||||
private var searchSeq = 0
|
||||
|
||||
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val r = FlatpakManager.ready(app)
|
||||
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
|
||||
main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() }
|
||||
}, "store-refresh").start()
|
||||
}
|
||||
|
||||
fun loadSections(force: Boolean = false) {
|
||||
if (!force && sections.isNotEmpty()) return
|
||||
sectionsFailed = false
|
||||
Thread({
|
||||
var failed = false
|
||||
for ((key, _) in SECTIONS) {
|
||||
val apps = FlathubApi.collection(key, 18)
|
||||
if (apps == null) failed = true
|
||||
main.post { sections[key] = apps ?: emptyList() }
|
||||
}
|
||||
main.post { sectionsFailed = failed }
|
||||
}, "store-sections").start()
|
||||
}
|
||||
|
||||
fun loadDetails(id: String) {
|
||||
if (details.containsKey(id)) return
|
||||
detailsFailed.remove(id)
|
||||
Thread({
|
||||
val d = FlathubApi.details(id)
|
||||
main.post { if (d != null) details[id] = d else detailsFailed[id] = true }
|
||||
}, "store-details").start()
|
||||
}
|
||||
|
||||
fun search(query: String, category: String?) {
|
||||
val seq = ++searchSeq
|
||||
searching = true
|
||||
Thread({
|
||||
val r = FlathubApi.search(query, category)
|
||||
main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } }
|
||||
}, "store-search").start()
|
||||
}
|
||||
|
||||
fun clearSearch() { searchSeq++; searchResults = null; searching = false }
|
||||
|
||||
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
|
||||
if (busy != null) return
|
||||
val app = context.applicationContext
|
||||
busy = what; stage = "Starting…"; percent = -1
|
||||
Thread({
|
||||
val problem = try {
|
||||
work(app) { s, p -> main.post { stage = s; percent = p } }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
val r = FlatpakManager.ready(app)
|
||||
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
|
||||
main.post {
|
||||
busy = null; stage = null; percent = -1
|
||||
ready = r; installed = list
|
||||
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "store-$what").start()
|
||||
}
|
||||
|
||||
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
|
||||
|
||||
fun install(context: Context, id: String, name: String) =
|
||||
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
|
||||
|
||||
fun uninstall(context: Context, id: String, name: String) =
|
||||
run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } }
|
||||
|
||||
fun update(context: Context, id: String?, name: String) =
|
||||
run(context, id ?: "update-all", name) { c, p ->
|
||||
FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } }
|
||||
}
|
||||
|
||||
fun checkUpdates(context: Context) {
|
||||
if (checkingUpdates || busy != null) return
|
||||
val app = context.applicationContext
|
||||
checkingUpdates = true
|
||||
Thread({
|
||||
val u = FlatpakManager.updates(app)
|
||||
main.post {
|
||||
checkingUpdates = false
|
||||
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "store-updates").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
|
||||
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */
|
||||
private class InstalledItem(
|
||||
val name: String, val detail: String, val tag: String,
|
||||
val selected: Boolean, val removable: Boolean,
|
||||
/** Stored package file, or null for an original. */
|
||||
val file: String?,
|
||||
/** The Proton build an original belongs to. */
|
||||
val protonVersion: String?,
|
||||
)
|
||||
|
||||
private val GOLD = Color(0xFFF2C66D)
|
||||
private val ROW_SHAPE = RoundedCornerShape(10.dp)
|
||||
|
||||
/**
|
||||
* FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry.
|
||||
* A title row with the Nightlies refresh, then the Proton and the component (LB / RB turn it), what
|
||||
* is in use, and the installed packages beside the ones on the Nightlies - stacked when the page is
|
||||
* narrow. Tapping an installed row swaps it in; the refresh button is the only thing that goes online.
|
||||
*/
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ComponentsPage(
|
||||
snapshot: Snapshot?,
|
||||
catalog: List<CatalogItem>,
|
||||
catalogAt: Long,
|
||||
protonId: String?,
|
||||
comp: String,
|
||||
checking: Boolean,
|
||||
busy: String?,
|
||||
downloads: Map<String, Int>,
|
||||
onProton: (String) -> Unit,
|
||||
onComp: (String) -> Unit,
|
||||
onSwap: (String) -> Unit,
|
||||
onRestore: (String) -> Unit,
|
||||
onCancelQueued: () -> Unit,
|
||||
onDeletePackage: (String) -> Unit,
|
||||
onDeleteOriginal: (String) -> Unit,
|
||||
onDownload: (CatalogItem) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
requestInitialFocus: Boolean = true,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
BackHandler(onBack = onBack)
|
||||
var confirm by remember { mutableStateOf<Pair<String, () -> Unit>?>(null) }
|
||||
var confirmTitle by remember { mutableStateOf("") }
|
||||
var about by remember { mutableStateOf(false) }
|
||||
var protonMenu by remember { mutableStateOf(false) }
|
||||
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
|
||||
|
||||
val views = snapshot?.protons ?: emptyList()
|
||||
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
|
||||
val label = ComponentsManager.LABEL[comp] ?: comp
|
||||
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
|
||||
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
// ---- title, and the one thing that goes online -----------------------------------------
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
maxLines = 1, modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
}
|
||||
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
// ---- which Proton, which component ------------------------------------------------------
|
||||
val comps = ComponentsManager.COMPONENTS
|
||||
androidx.compose.foundation.layout.FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) {
|
||||
if (view != null) Box {
|
||||
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
|
||||
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
|
||||
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
|
||||
views.forEachIndexed { i, v ->
|
||||
val swaps = v.components.values.count { it.activeFile != null }
|
||||
MenuItem(
|
||||
v.proton.name, checked = v.proton.id == view.proton.id,
|
||||
detail = v.proton.version + (if (swaps > 0) " · $swaps swapped" else " · all original") + (if (v.inUseByGame) " · game running" else ""),
|
||||
focusRequester = if (i == 0) first else null,
|
||||
) { onProton(v.proton.id); protonMenu = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
|
||||
}
|
||||
|
||||
// ---- what is in use -----------------------------------------------------------------------
|
||||
if (view != null) {
|
||||
val st = view.components.getValue(comp)
|
||||
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface)
|
||||
.border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp),
|
||||
) {
|
||||
Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape))
|
||||
Text(
|
||||
(if (st.queued != null) "Next: ${st.queued} · after the game closes" else "$label in use: ${st.inUse}") + fixedAt,
|
||||
fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (st.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued)
|
||||
else if (!narrow) Text(
|
||||
if (view.inUseByGame) "Waits until the game closes" else "Changes apply the next time a game starts",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the lists ------------------------------------------------------------------------------
|
||||
when {
|
||||
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Reading the Protons…", fontSize = 14.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
view == null -> Text(
|
||||
"No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.",
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
else -> {
|
||||
val p = view.proton
|
||||
val st = view.components.getValue(comp)
|
||||
val build = ComponentsManager.safeName(p.version)
|
||||
val originals = view.originals.filter { it.comp == comp }
|
||||
val stored = snapshot.packages.filter { it.comp == comp }
|
||||
val activeIsOriginal = st.activeFile == null
|
||||
val rows = originals.map { o ->
|
||||
val isCurrent = o.protonVersion == build
|
||||
InstalledItem(
|
||||
"Original · ${o.protonVersion}",
|
||||
o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · earlier build",
|
||||
"ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion,
|
||||
)
|
||||
} + stored.map { s ->
|
||||
InstalledItem(s.version, String.format("%.1f MB", s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null)
|
||||
}
|
||||
val storedNames = snapshot.packages.map { it.file }.toSet()
|
||||
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(p.id, comp, requestInitialFocus) {
|
||||
if (requestInitialFocus) runCatching { first.requestFocus() }
|
||||
}
|
||||
|
||||
val installed: @Composable () -> Unit = {
|
||||
SettingsGroup("Installed · ${rows.size}") {
|
||||
if (rows.isEmpty()) Text(
|
||||
"Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
rows.forEachIndexed { i, item ->
|
||||
InstalledLine(
|
||||
item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier,
|
||||
onSelect = {
|
||||
if (item.selected) return@InstalledLine
|
||||
val waits = if (view.inUseByGame) " A game is running on this Proton, so it waits until the game closes." else " It applies the next time a game starts."
|
||||
if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle.$waits") { onSwap(item.file) }
|
||||
else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?$waits") { onRestore(item.protonVersion!!) }
|
||||
},
|
||||
onDelete = {
|
||||
if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) }
|
||||
else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val nightly: @Composable () -> Unit = {
|
||||
SettingsGroup("On the Nightlies · ${available.size}") {
|
||||
if (available.isEmpty()) Text(
|
||||
if (catalogAt == 0L) "Refresh to list the packages on the Nightlies." else "Nothing new since the last check.",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
available.forEach { d -> AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } }
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
|
||||
if (narrow) { installed(); nightly() }
|
||||
else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { installed() }
|
||||
Column(Modifier.weight(1f)) { nightly() }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
|
||||
SmallButton("Import .wcp", onClick = onImport)
|
||||
if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText("Restore this Proton's original", pal.signal) {
|
||||
ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(build) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (about) AlertDialog(
|
||||
onDismissRequest = { about = false },
|
||||
title = { Text("Components") },
|
||||
text = {
|
||||
Text(
|
||||
"FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" +
|
||||
"Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" +
|
||||
"Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\n\n" +
|
||||
"Each Proton build's own files are kept as its original, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.",
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = { FocusText("OK", pal.signal) { about = false } },
|
||||
)
|
||||
confirm?.let { (body, action) ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirm = null },
|
||||
title = { Text(confirmTitle) },
|
||||
text = { Text(body, fontSize = 14.sp) },
|
||||
// Opens on Cancel, so a stray A press on a controller never swaps or deletes anything.
|
||||
confirmButton = {
|
||||
val del = confirmTitle.startsWith("Delete")
|
||||
FocusText(if (del) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", if (del) colors.error else pal.signal) { confirm = null; action() }
|
||||
},
|
||||
dismissButton = {
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
|
||||
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick),
|
||||
) {
|
||||
if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small button. Resting: grey outline (blue text when [accent]). Focused or hovered: a solid blue
|
||||
* fill with an outline in the text colour - unmistakable in a column of identical buttons.
|
||||
*/
|
||||
@Composable
|
||||
private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(if (hot) pal.signal else colors.surfaceVariant)
|
||||
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (!enabled) colors.onSurfaceVariant else if (hot) pal.onSignal else if (accent) pal.signal else colors.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(text: String) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val (fg, bg) = when (text) {
|
||||
"ORIGINAL" -> GOLD to Color(0x22F2C66D)
|
||||
"IN USE" -> pal.onSignal to pal.signal
|
||||
"NEW" -> pal.good to Color(0x224CD37F)
|
||||
else -> colors.onSurfaceVariant to Color.White.copy(alpha = 0.07f)
|
||||
}
|
||||
Text(
|
||||
text.lowercase().replaceFirstChar { it.uppercase() }, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(bg).padding(horizontal = 7.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
|
||||
.controllerConfirm(onClick = onSelect)
|
||||
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) {
|
||||
if (item.selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Tag(item.tag)
|
||||
}
|
||||
Text(item.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
if (item.selected) Tag("IN USE")
|
||||
}
|
||||
// The trash column is always there (empty when the row can't be deleted) so rows line up.
|
||||
if (!item.removable) Spacer(Modifier.padding(end = 6.dp).size(40.dp))
|
||||
else {
|
||||
val delSrc = remember { MutableInteractionSource() }
|
||||
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
|
||||
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
|
||||
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
|
||||
.controllerConfirm(onClick = onDelete),
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(d.file.removeSuffix(".wcp"), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
"${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (progress == null) SmallButton("Download", enabled = enabled, accent = true, onClick = onDownload)
|
||||
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
@@ -41,12 +41,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
|
||||
)
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
SettingsRow("Colour", "Tint of the on-screen buttons", highlighted = tintOpen) {
|
||||
SettingsRow("Color", "Tint of the on-screen buttons", highlighted = tintOpen) {
|
||||
Box {
|
||||
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) {
|
||||
host.open = if (tintOpen) null else "controller-tint"
|
||||
}
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Colour") { firstItemFocus ->
|
||||
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Color") { firstItemFocus ->
|
||||
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
|
||||
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTint(color)
|
||||
@@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centres where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "Sticks appear when touched near their saved positions and hide when released", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton("Edit") { a.onEditLayout() }
|
||||
@@ -68,7 +68,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
}
|
||||
val remapped = c.mapping.count { (id, target) -> id != target }
|
||||
ActionRow("Button mapping", if (remapped == 0) "Every button sends its own input" else "$remapped of ${c.mapping.size} buttons remapped", "Configure", a.onMapping)
|
||||
ActionRow("Reset controller", "Restore the default colour, opacity, size, stick behaviour, mapping and layout", "Reset", a.onResetAll)
|
||||
ActionRow("Reset controller", "Restore the default color, opacity, size, stick behavior, mapping and layout", "Reset", a.onResetAll)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RadialGradient
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* The Steam Deck controller's back grips and trackpads, on the second screen.
|
||||
*
|
||||
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
|
||||
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
|
||||
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
|
||||
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
|
||||
* where something is being touched.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class DeckControlsPanel(
|
||||
context: Context,
|
||||
private val onClose: () -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
) : LinearLayout(context) {
|
||||
private enum class Tab(val label: String) { GRIPS("Grips"), LEFT("Left pad"), RIGHT("Right pad"), BOTH("Both") }
|
||||
|
||||
private val content = FrameLayout(context)
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
select(selected)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
DeckControls.releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun topBar(): View = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dp(12), dp(10), dp(12), dp(6))
|
||||
addView(roundButton("×", "Close second-screen controls", onClose))
|
||||
val tabs = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setPadding(dp(4), dp(4), dp(4), dp(4))
|
||||
for (tab in Tab.values()) {
|
||||
val view = TextView(context).apply {
|
||||
text = tab.label
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
isSingleLine = true
|
||||
setPadding(dp(14), 0, dp(14), 0)
|
||||
setOnClickListener { select(tab) }
|
||||
}
|
||||
tabViews[tab] = view
|
||||
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
}
|
||||
val centre = FrameLayout(context).apply {
|
||||
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER))
|
||||
}
|
||||
addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
|
||||
addView(roundButton("STEAM", "Open Steam menu", onSteamMenu, wide = true))
|
||||
addView(space(dp(8)))
|
||||
addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true))
|
||||
}
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
selected = tab
|
||||
DeckControls.releaseAll()
|
||||
for ((each, view) in tabViews) {
|
||||
val on = each == tab
|
||||
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
|
||||
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
|
||||
}
|
||||
content.removeAllViews()
|
||||
content.addView(
|
||||
when (tab) {
|
||||
Tab.GRIPS -> gripsGrid()
|
||||
Tab.LEFT -> singlePad(right = false)
|
||||
Tab.RIGHT -> singlePad(right = true)
|
||||
Tab.BOTH -> bothPads()
|
||||
},
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gripsGrid(): View = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
fun column(upper: View, lower: View) = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
|
||||
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
|
||||
}
|
||||
addView(column(GripButton(context, "L4", "Left · upper", DeckControls.L4), GripButton(context, "L5", "Left · lower", DeckControls.L5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
|
||||
addView(column(GripButton(context, "R4", "Right · upper", DeckControls.R4), GripButton(context, "R5", "Right · lower", DeckControls.R5)),
|
||||
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
|
||||
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
|
||||
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
|
||||
}
|
||||
|
||||
private fun bothPads(): View = LinearLayout(context).apply {
|
||||
orientation = VERTICAL
|
||||
setPadding(dp(12), dp(6), dp(12), dp(12))
|
||||
val pads = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
|
||||
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
|
||||
}
|
||||
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
|
||||
// Mirrored, as hands hold them: the upper grips outside, the lower ones in the middle.
|
||||
val grips = LinearLayout(context).apply {
|
||||
orientation = HORIZONTAL
|
||||
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
|
||||
row.forEachIndexed { index, (label, bit) ->
|
||||
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
|
||||
if (index > 0) leftMargin = dp(6)
|
||||
if (index < row.size - 1) rightMargin = dp(6)
|
||||
})
|
||||
}
|
||||
}
|
||||
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
|
||||
}
|
||||
|
||||
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
|
||||
TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = if (wide) 13f else 20f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = if (wide) 0.08f else 0f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0)
|
||||
minWidth = dp(44)
|
||||
layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44))
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) }
|
||||
|
||||
private fun outline(radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
setStroke(dp(1), LINE)
|
||||
}
|
||||
|
||||
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
|
||||
cornerRadius = radius * resources.displayMetrics.density
|
||||
setColor(fill)
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
/** A surface drawn the same way everywhere on the panel; pressed, it lights up in the accent. */
|
||||
private abstract class Surface(context: Context) : View(context) {
|
||||
protected val density = resources.displayMetrics.density
|
||||
protected val rect = RectF()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
color = TEXT_DIM
|
||||
}
|
||||
|
||||
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
|
||||
val inset = density
|
||||
rect.set(inset, inset, width - inset, height - inset)
|
||||
val radius = CORNER * density
|
||||
fill.color = if (lit) ACCENT_WASH else SURFACE
|
||||
canvas.drawRoundRect(rect, radius, radius, fill)
|
||||
stroke.color = if (lit) ACCENT else LINE
|
||||
stroke.strokeWidth = (if (lit) 2f else 1f) * density
|
||||
canvas.drawRoundRect(rect, radius, radius, stroke)
|
||||
}
|
||||
}
|
||||
|
||||
/** A held button: down while a finger is on it. */
|
||||
private open class HoldButton(
|
||||
context: Context,
|
||||
private val label: String,
|
||||
private val caption: String?,
|
||||
private val onChange: (Boolean) -> Unit,
|
||||
) : Surface(context) {
|
||||
private var down = false
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, down)
|
||||
val cx = width / 2f
|
||||
labelPaint.textSize = min(height * 0.3f, 30 * density)
|
||||
labelPaint.color = if (down) ACCENT else TEXT
|
||||
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
|
||||
canvas.drawText(label, cx, labelY, labelPaint)
|
||||
if (caption != null) {
|
||||
captionPaint.textSize = 12 * density
|
||||
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> set(true)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(pressed: Boolean) {
|
||||
if (down == pressed) return
|
||||
down = pressed
|
||||
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onChange(pressed)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
|
||||
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
|
||||
|
||||
private class ClickBar(context: Context, right: Boolean) :
|
||||
HoldButton(context, "Click", if (right) "Right trackpad" else "Left trackpad", { DeckControls.setClick(right, it) })
|
||||
|
||||
/**
|
||||
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
|
||||
* while a second finger is down.
|
||||
*/
|
||||
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicked = false
|
||||
private var fingerX = 0f
|
||||
private var fingerY = 0f
|
||||
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
|
||||
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
|
||||
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicked)
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
guide.strokeWidth = density
|
||||
val reach = min(width, height) / 2f - 24 * density
|
||||
canvas.drawCircle(cx, cy, reach, guide)
|
||||
canvas.drawCircle(cx, cy, reach / 2f, guide)
|
||||
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
|
||||
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
|
||||
captionPaint.textSize = 12 * density
|
||||
captionPaint.color = if (touching) ACCENT else TEXT_DIM
|
||||
canvas.drawText(if (right) "RIGHT TRACKPAD" else "LEFT TRACKPAD", cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
canvas.drawCircle(fingerX, fingerY, radius, glow)
|
||||
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
touch(event.getX(0), event.getY(0))
|
||||
click(event.pointerCount > 1)
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> click(true)
|
||||
MotionEvent.ACTION_POINTER_UP -> {
|
||||
// The finger that lifted may be the first; the pad follows whichever stays.
|
||||
val stays = if (event.actionIndex == 0) 1 else 0
|
||||
touch(event.getX(stays), event.getY(stays))
|
||||
click(event.pointerCount - 1 > 1)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
click(false)
|
||||
touching = false
|
||||
DeckControls.setPad(right, touching = false)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun touch(x: Float, y: Float) {
|
||||
fingerX = x.coerceIn(0f, width.toFloat())
|
||||
fingerY = y.coerceIn(0f, height.toFloat())
|
||||
touching = true
|
||||
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun click(down: Boolean) {
|
||||
if (clicked == down) return
|
||||
clicked = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
DeckControls.setClick(right, down)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
|
||||
private var selected = Tab.BOTH
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
|
||||
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
|
||||
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
|
||||
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
|
||||
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
|
||||
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
|
||||
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
|
||||
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
|
||||
}
|
||||
}
|
||||
@@ -194,12 +194,12 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
|
||||
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (row.tag.isNotEmpty()) Text(
|
||||
row.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
|
||||
row.tag, fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
if (row.removable) {
|
||||
@@ -221,7 +221,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
|
||||
|
||||
/** A text button that shows where controller focus is: an outline in its own colour. */
|
||||
@Composable
|
||||
private fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
|
||||
/**
|
||||
* The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup,
|
||||
* a game's launch settings and the session drawer, so they say the same thing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FrameGenMenu(
|
||||
host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean,
|
||||
onPick: (engine: String, multiplier: Int) -> Unit,
|
||||
) {
|
||||
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
|
||||
val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam"
|
||||
fun pick(e: String, m: Int) { onPick(e, m); host.open = null }
|
||||
MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) }
|
||||
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) }
|
||||
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil.compose.AsyncImage
|
||||
import java.io.File
|
||||
|
||||
// Cover art: posters, the art grid and its tiles.
|
||||
|
||||
internal class Tile(
|
||||
val title: String, val sub: String?, val art: File?, val key: String,
|
||||
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
|
||||
val showFooter: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun Poster(art: File?, name: String, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val t = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) }
|
||||
Box(
|
||||
modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f)
|
||||
.graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false }
|
||||
.clip(Shape12).background(artBrush(hueOf(name))),
|
||||
) {
|
||||
if (art != null) CoverImage(art, Modifier.fillMaxSize())
|
||||
else Text(name, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
|
||||
val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) }
|
||||
// Big enough to recognise a game by its art and read its name under it; icon tiles are squares.
|
||||
val minSize = if (square) 76.dp else if (wide) 120.dp else 96.dp
|
||||
val gap = 12.dp
|
||||
// Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy
|
||||
// grid composes only the rows on screen, so a press towards the next row bounced back among
|
||||
// the visible tiles. A few hundred tiles lay out fine; the page's scroll follows the focused one.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val avail = maxWidth - 8.dp
|
||||
val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1)
|
||||
val tileWidth = (avail - gap * (cols - 1)) / cols
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) {
|
||||
for (row in tiles.chunked(cols)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) {
|
||||
for (t in row) key(t.key) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
|
||||
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableInteractionSource, hot: Boolean, track: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "tileScale")
|
||||
val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift")
|
||||
val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev")
|
||||
val pal = LocalPalette.current
|
||||
val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing")
|
||||
Column(
|
||||
modifier = track
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) }
|
||||
.clip(Shape12)
|
||||
.background(colors.surface)
|
||||
.border(2.5.dp, ring, Shape12)
|
||||
.alpha(if (t.dim && !hot) 0.55f else 1f)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().shine(hot)) {
|
||||
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap)
|
||||
}
|
||||
if (t.showFooter) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||
Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (t.sub != null) Text(t.sub, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header -
|
||||
* is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop.
|
||||
*/
|
||||
@Composable
|
||||
private fun CoverImage(art: File, modifier: Modifier) {
|
||||
var wideArt by remember(art) { mutableStateOf(false) }
|
||||
Box(modifier) {
|
||||
if (wideArt) {
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp),
|
||||
)
|
||||
Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)))
|
||||
}
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop,
|
||||
onSuccess = { state ->
|
||||
val size = state.painter.intrinsicSize
|
||||
if (size.width > size.height * 1.1f) wideArt = true
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
|
||||
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
|
||||
when {
|
||||
art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
else CoverImage(art, Modifier.fillMaxSize())
|
||||
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
|
||||
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
|
||||
else -> {
|
||||
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f))))
|
||||
Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(6.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.File
|
||||
|
||||
// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators.
|
||||
|
||||
@Composable
|
||||
internal fun Pane(
|
||||
s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier,
|
||||
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
|
||||
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) {
|
||||
val backdropArt: File? = when {
|
||||
s.pageKey != null && page != null -> null
|
||||
selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }?.art
|
||||
selected == "games" -> s.steamGames.maxByOrNull { it.lastPlayed }?.art
|
||||
selected.startsWith("rom:") -> romFor(s, selected)?.second?.art
|
||||
else -> null
|
||||
}
|
||||
Backdrop(backdropArt)
|
||||
AnimatedContent(
|
||||
// Picking another game changes the detail beside the list, not the whole page.
|
||||
targetState = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected,
|
||||
transitionSpec = {
|
||||
(fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
|
||||
.apply { targetContentZIndex = 1f }
|
||||
},
|
||||
label = "pane",
|
||||
) { key ->
|
||||
if (page != null && key == s.pageKey) page()
|
||||
else Content(s, if (key == "games") selected else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Backdrop(art: File?) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Crossfade(targetState = art, animationSpec = Motion.tw(700), label = "backdrop") { a ->
|
||||
// A game's own art, blurred, where it has some; otherwise the plain ground with one quiet
|
||||
// glow of the theme's signal colour - a colour per title hash read as noise.
|
||||
if (a != null && Build.VERSION.SDK_INT >= 31) {
|
||||
Box(modifier = Modifier.fillMaxSize().alpha(0.26f).blur(70.dp)) {
|
||||
AsyncImage(model = a, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f })
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(pal.signal.copy(alpha = 0.07f), Color.Transparent), center = Offset.Zero, radius = 1400f)))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f)))))
|
||||
}
|
||||
|
||||
private fun romFor(s: FrontEndState, selected: String): Pair<Library.Emulator, Library.Rom>? {
|
||||
val parts = selected.split(":")
|
||||
val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } ?: return null
|
||||
val g = e.games.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) ?: return null
|
||||
return e to g
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier,
|
||||
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
|
||||
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
|
||||
val narrow = LocalNarrowPane.current
|
||||
val padH = if (narrow) 16.dp else 22.dp
|
||||
val padV = if (narrow) 12.dp else 18.dp
|
||||
// Setup scrolls inside itself, under its tabs.
|
||||
if (selected == "setup") {
|
||||
Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) {
|
||||
SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Steam is a full-bleed wall of the library with Play over it.
|
||||
if (selected == "steam") {
|
||||
SteamHome(s, a, modifier)
|
||||
return
|
||||
}
|
||||
// The Store scrolls and navigates inside itself (its tabs and an app's page).
|
||||
if (selected == "store" && s.storeEnabled) {
|
||||
StorePage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
// The Games tab lays out its own list and detail.
|
||||
if (selected == "games" || selected.startsWith("app:")) {
|
||||
GamesPage(s, a, selected, onSelect, modifier)
|
||||
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 (s.storeEnabled) Rise(5) { InstalledAppsGrid(a) }
|
||||
if (s.appImagesEnabled) Rise(5) { AppImagesSection(a, s.ready) }
|
||||
if (available.isNotEmpty()) {
|
||||
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
|
||||
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
|
||||
}
|
||||
}
|
||||
selected.startsWith("emu:") -> {
|
||||
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
|
||||
if (e == null) Note("Not installed.") else {
|
||||
val pkgId = Library.packageId(e.id)
|
||||
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
|
||||
Rise(0) {
|
||||
BackLink("Desktop") { onSelect("desktop") }
|
||||
}
|
||||
Rise(1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
|
||||
Column {
|
||||
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(
|
||||
e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed",
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.installed) {
|
||||
Rise(3) {
|
||||
Actions {
|
||||
PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) }
|
||||
BusyChip(s)
|
||||
SecondaryButton("ROMs folder", onClick = a.onRoms)
|
||||
if (pkg != null) SecondaryButton(
|
||||
if (pkg.kind == "appimage") "Remove" else "Hide",
|
||||
enabled = s.packageBusyId == null && !s.sessionRunning,
|
||||
) { a.onRemovePackage(pkg.id) }
|
||||
}
|
||||
}
|
||||
Rise(4) { SectionTitle("Games", e.games.size.toString()) }
|
||||
if (e.games.isEmpty()) Rise(5) {
|
||||
Note(
|
||||
if (s.romsDir == null) "Choose a ROMs folder."
|
||||
else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch."
|
||||
else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.",
|
||||
)
|
||||
}
|
||||
else Rise(5, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
|
||||
}
|
||||
} else {
|
||||
if (pkg != null) Rise(2) {
|
||||
Actions {
|
||||
PrimaryButton(
|
||||
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}",
|
||||
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
|
||||
) { a.onInstallPackage(pkg.id) }
|
||||
if (s.sessionRunning) ActionChip("Stop session to install", ok = false)
|
||||
else if (!s.ready) ActionChip("Runtime required", ok = false)
|
||||
}
|
||||
}
|
||||
Rise(3) {
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
|
||||
Note(when {
|
||||
s.packageCatalogLoading -> "Loading install details…"
|
||||
pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable."
|
||||
!s.ready -> "Install the Linux runtime from Setup before installing desktop apps."
|
||||
s.sessionRunning -> "Stop the active session before installing desktop apps."
|
||||
pkg.notes.isNotBlank() -> pkg.notes
|
||||
else -> "Install ${e.name} into the Linux desktop runtime."
|
||||
})
|
||||
}
|
||||
}
|
||||
if (s.packageBusyId == pkg?.id) Rise(4) {
|
||||
val stage = s.packageStage
|
||||
Text(
|
||||
if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…",
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
if (pkg?.kind == "tar") Rise(5) { Note("Hiding this package takes it off Desktop; its files stay in the Linux runtime.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.startsWith("rom:") -> {
|
||||
val pair = romFor(s, selected)
|
||||
if (pair == null) Note("That game is gone from the ROMs folder.") else {
|
||||
val (e, g) = pair
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BackLink(e.name) { onSelect("emu:${e.id}") }
|
||||
Eyebrow("Desktop · ${e.system}")
|
||||
}
|
||||
}
|
||||
Rise(1) { Title(g.name) }
|
||||
Rise(2) {
|
||||
Row {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Actions {
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
|
||||
PrimaryButton("Launch in ${e.name}", enabled = !s.busy, main = true) { a.onRom(g) }
|
||||
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
|
||||
}
|
||||
}
|
||||
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
|
||||
}
|
||||
}
|
||||
val others = e.games.filter { it !== g }
|
||||
if (others.isNotEmpty()) {
|
||||
Rise(3) { SectionTitle("Also in ${e.name}", null) }
|
||||
Rise(4, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(others.map { x ->
|
||||
val index = e.games.indexOf(x)
|
||||
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
|
||||
}, wide = others.none { it.art != null })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Note("That page is gone. Pick a section on the left.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Linux desktop itself, above the emulators that run in it. */
|
||||
@Composable
|
||||
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val actions: @Composable () -> Unit = {
|
||||
Actions {
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop)
|
||||
Cog(onClick = a.onDesktopSettings)
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
|
||||
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!narrow) actions()
|
||||
}
|
||||
if (narrow) actions()
|
||||
}
|
||||
}
|
||||
|
||||
/** Emulators as wide tiles - three across, or a list on a narrow page. */
|
||||
@Composable
|
||||
private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSelect: (String) -> Unit) {
|
||||
val columns = if (LocalNarrowPane.current) 1 else 3
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
|
||||
emulators.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, e ->
|
||||
key(e.id) {
|
||||
EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") }
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One emulator: its icon, system and games. One not yet installed says so in words and keeps full
|
||||
* contrast - dimming it read as disabled rather than one press from installing.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "emuScale")
|
||||
val system = e.system.replaceFirstChar { it.uppercase() }
|
||||
val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("tile:emu:${e.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!e.installed) Text(
|
||||
"Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
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) }
|
||||
// Only games added to the library; Steam titles keep their saves with Steam Cloud.
|
||||
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
|
||||
}
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as
|
||||
* either, or open the save folder found for it. The saves are looked for when the menu opens.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) {
|
||||
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val saves by androidx.compose.runtime.produceState<List<com.droiddeck.launcher.session.GameSaves.SaveDir>?>(null, g.gameId, open) {
|
||||
if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
val found = saves
|
||||
val summary = when {
|
||||
found == null -> "Looking for this game's saves…"
|
||||
found.isEmpty() -> "No save folder found by name: an export takes the whole user folder."
|
||||
else -> {
|
||||
val mb = found.sumOf { it.bytes } / 1048576.0
|
||||
(if (found.size == 1) found[0].relPath else "${found.size} save folders") + " · ${found.sumOf { it.files }} files · " +
|
||||
(if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
SecondaryButton("Manage saves", compact = true) { open = !open }
|
||||
AnchoredMenu(open, onDismiss = { open = false }, title = "Game saves", note = summary) { first ->
|
||||
MenuItem("Import saves…", checked = false, detail = "A GameHub or Winlator zip · the saves there now are backed up first", focusRequester = first) {
|
||||
open = false; a.onSaveImport(g)
|
||||
}
|
||||
MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") {
|
||||
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB)
|
||||
}
|
||||
MenuItem("Export as Winlator zip…", checked = false, detail = "Saves under xuser, for Winlator, WinNative and Bannerlator") {
|
||||
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR)
|
||||
}
|
||||
found?.firstOrNull()?.let { d ->
|
||||
MenuItem("Open save folder", checked = false, detail = d.relPath) {
|
||||
open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
|
||||
@Composable
|
||||
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
|
||||
Text(
|
||||
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameList(
|
||||
games: List<Library.SteamGame>, current: Library.SteamGame,
|
||||
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// Laid out whole, as the art grid is: the pad's focus search only finds rows that exist.
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
|
||||
Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
|
||||
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
|
||||
}
|
||||
for (g in games) key(g.appId) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
|
||||
@Composable
|
||||
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val focused by src.collectIsFocusedAsState()
|
||||
val hovered by src.collectIsHoveredAsState()
|
||||
LaunchedEffect(focused) { if (focused && !selected) onSelect() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
|
||||
.clip(Shape12)
|
||||
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
|
||||
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
|
||||
.controllerConfirm(onClick = onLaunch)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) {
|
||||
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played",
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A page's title, with room at its right for a status chip. */
|
||||
@Composable
|
||||
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
title, fontSize = if (LocalNarrowPane.current) 22.sp else 26.sp, fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground, maxLines = 1,
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */
|
||||
@Composable
|
||||
internal fun BusyChip(s: FrontEndState) {
|
||||
if (s.busy) ActionChip(if (s.percent >= 0) "Runtime busy · ${s.percent}%" else "Runtime busy", ok = false)
|
||||
}
|
||||
|
||||
/** Whether Steam can start, said where Play is rather than only in Setup. */
|
||||
@Composable
|
||||
internal fun RuntimeChip(s: FrontEndState) = when {
|
||||
s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false)
|
||||
!s.ready -> Chip("Runtime installs on first Play", ok = false)
|
||||
s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false)
|
||||
else -> Chip("● Runtime ready", ok = true)
|
||||
}
|
||||
|
||||
/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */
|
||||
private fun lastPlayedText(lastPlayed: Long): String? {
|
||||
if (lastPlayed <= 0L) return null
|
||||
val span = android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||
lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return "Last played " + span.replaceFirstChar { it.lowercase() }
|
||||
}
|
||||
|
||||
private fun libraryLabel(library: String): String = when (library) {
|
||||
"internal" -> "Internal storage"
|
||||
Library.ADDED -> "Added game"
|
||||
else -> library
|
||||
}
|
||||
|
||||
/**
|
||||
* A game's wide banner: Steam's hero art where the client cached one, else its capsule blurred to
|
||||
* fill the width. The copy sits bottom-left over a scrim of the ground colour so it always reads;
|
||||
* the art takes the banner's size, so a banner given a minimum height grows to fit its copy.
|
||||
*/
|
||||
@Composable
|
||||
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
|
||||
val image = g.hero ?: g.art
|
||||
if (image != null) AsyncImage(
|
||||
model = image, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.matchParentSize()
|
||||
.then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier),
|
||||
)
|
||||
Spacer(
|
||||
Modifier.matchParentSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to colors.background.copy(alpha = 0.92f),
|
||||
0.55f to colors.background.copy(alpha = 0.6f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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.Storefront
|
||||
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") }
|
||||
if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, onFocus = { onFocusSelect("store") }) { onSelect("store") }
|
||||
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
// Home mode's extra section goes last, so it shifts nothing above it.
|
||||
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
}
|
||||
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) {
|
||||
val barWidth = if (iconOnly) 44.dp else 60.dp
|
||||
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth))
|
||||
else LinearProgressIndicator(modifier = Modifier.width(barWidth))
|
||||
Text(if (s.percent >= 0) "${s.percent}%" else if (iconOnly) "…" else "Working", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
var lastRunning by remember { mutableStateOf("") }
|
||||
if (s.running != null) lastRunning = s.running
|
||||
AnimatedVisibility(
|
||||
s.running != null,
|
||||
enter = expandVertically(Motion.sp(0.75f)) + fadeIn(Motion.tw(300)),
|
||||
exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)),
|
||||
) { ResumeRailItem(lastRunning, compact, iconOnly, a.onResume) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RailItem(
|
||||
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean,
|
||||
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val frontFocus = LocalFrontFocus.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val focused by src.collectIsFocusedAsState()
|
||||
val hovered by src.collectIsHoveredAsState()
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
if (frontFocus != null) LaunchedEffect(focused) {
|
||||
if (focused) {
|
||||
frontFocus.focusedRail = key
|
||||
onFocus()
|
||||
}
|
||||
else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null
|
||||
}
|
||||
val fg by animateColorAsState(
|
||||
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
|
||||
Motion.tw(220), label = "railFg",
|
||||
)
|
||||
val fill by animateColorAsState(
|
||||
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
|
||||
Motion.tw(220), label = "railFill",
|
||||
)
|
||||
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing")
|
||||
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
|
||||
.clip(Shape14)
|
||||
.background(fill)
|
||||
.border(2.dp, ring, Shape14)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
|
||||
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
|
||||
) {
|
||||
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
|
||||
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (compact) 20.dp else 22.dp))
|
||||
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
|
||||
}
|
||||
if (badge) Box(
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
|
||||
.size(8.dp).clip(CircleShape).background(AttentionAmber),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The running session, one press from anywhere in the launcher. */
|
||||
@Composable
|
||||
private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, onResume: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val focused by src.collectIsFocusedAsState()
|
||||
val hovered by src.collectIsHoveredAsState()
|
||||
val pulse = rememberInfiniteTransition(label = "pulse")
|
||||
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(Motion.ms(1600).coerceAtLeast(1), easing = Motion.Ease), RepeatMode.Restart), label = "ring")
|
||||
// Animations off: the live dot alone, no pulsing ring.
|
||||
val still = Motion.scale == 0f
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp)
|
||||
.width(if (iconOnly) 52.dp else 80.dp)
|
||||
.clip(Shape14)
|
||||
.background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f))
|
||||
.border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
|
||||
.semantics { contentDescription = "Resume $name" }
|
||||
.padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) {
|
||||
if (!still) Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape))
|
||||
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good))
|
||||
}
|
||||
// Icons only: the live dot alone says something is running; its description says what.
|
||||
if (!iconOnly) {
|
||||
Text("Resume", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false)
|
||||
Text(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,448 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.PriorityHigh
|
||||
import androidx.compose.material.icons.outlined.Extension
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material.icons.outlined.VideogameAsset
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
|
||||
|
||||
private enum class CheckState { OK, WARN, BUSY }
|
||||
|
||||
/** One requirement in Setup's system check: a status mark, what it is, and at most one action. */
|
||||
@Composable
|
||||
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val tint = when (state) {
|
||||
CheckState.OK -> pal.good
|
||||
CheckState.WARN -> AttentionAmber
|
||||
CheckState.BUSY -> pal.signal
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(if (state == CheckState.WARN) AttentionAmber.copy(alpha = 0.07f) else Color.Transparent)
|
||||
.heightIn(min = 60.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(tint.copy(alpha = 0.16f))) {
|
||||
Icon(
|
||||
when (state) {
|
||||
CheckState.OK -> Icons.Filled.Check
|
||||
CheckState.WARN -> Icons.Filled.PriorityHigh
|
||||
CheckState.BUSY -> Icons.Filled.Refresh
|
||||
},
|
||||
contentDescription = when (state) {
|
||||
CheckState.OK -> "Done"
|
||||
CheckState.WARN -> "Needs attention"
|
||||
CheckState.BUSY -> "Working"
|
||||
},
|
||||
tint = tint, modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
if (detail != null) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (action != null) action()
|
||||
}
|
||||
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SetupPanel(
|
||||
s: FrontEndState,
|
||||
a: FrontEndActions,
|
||||
onOpenDeveloperOptions: () -> Unit,
|
||||
onRequestWirelessAdb: (Boolean) -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
var processLimitBusy by remember { mutableStateOf(false) }
|
||||
var processLimitMessage by remember { mutableStateOf<String?>(null) }
|
||||
val setProcessLimit: (Boolean) -> Unit = { enabled ->
|
||||
processLimitBusy = true
|
||||
processLimitMessage = null
|
||||
a.onSetPhantomProcessLimit(enabled) { error ->
|
||||
processLimitBusy = false
|
||||
if (error == null) {
|
||||
a.onRefreshPhantomStatus()
|
||||
} else {
|
||||
processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device."
|
||||
onRequestWirelessAdb(enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
val runtime = when {
|
||||
s.busy -> "Working…"
|
||||
!s.ready -> "Install"
|
||||
s.available != null && s.available != s.installed -> "Update"
|
||||
else -> "Manage"
|
||||
}
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val gpuOk = remember { DeviceSupport.adreno() }
|
||||
val gpuName = remember { DeviceSupport.gpuName() }
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
val checks = 4
|
||||
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
|
||||
// Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page.
|
||||
val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About")
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
// The control a controller was on went with the old tab: it lands on the new tab itself.
|
||||
LaunchedEffect(tab) {
|
||||
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { tabFocus[tab].requestFocus() }
|
||||
}
|
||||
}
|
||||
Rise(0, Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().bumpers(
|
||||
onPrevious = { pick((tab + tabs.size - 1) % tabs.size) },
|
||||
onNext = { pick((tab + 1) % tabs.size) },
|
||||
),
|
||||
) {
|
||||
PageHeader("Setup") {
|
||||
Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", ok = readyCount == checks)
|
||||
}
|
||||
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
SectionTitle("System check", null)
|
||||
// What Steam needs, one row each: green when done, one button when not. The
|
||||
// process-limit controls only open under their row.
|
||||
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
CheckRow(
|
||||
if (gpuOk) CheckState.OK else CheckState.WARN,
|
||||
if (gpuOk) "Device supported" else "GPU not supported",
|
||||
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
|
||||
)
|
||||
CheckRow(
|
||||
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
|
||||
"Linux runtime",
|
||||
when {
|
||||
s.busy -> if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage
|
||||
!s.ready -> "Not installed · about 3 GB, installed on the first Play"
|
||||
s.available != null && s.available != s.installed -> "${s.installed ?: "Installed"} · update available"
|
||||
else -> "${s.installed ?: "Installed"} · up to date"
|
||||
},
|
||||
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
|
||||
CheckRow(
|
||||
if (limitBlocks) CheckState.WARN else CheckState.OK,
|
||||
"Child-process limit",
|
||||
when (s.phantomProcessStatus) {
|
||||
PhantomProcessStatus.ENABLED -> "On · Android may close Steam. Turning it off takes a minute"
|
||||
PhantomProcessStatus.UNSET -> "Not set · the ROM default may close Steam. Turning it off takes a minute"
|
||||
PhantomProcessStatus.UNREADABLE -> "Could not be checked · turning it off takes a minute"
|
||||
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
|
||||
},
|
||||
) {
|
||||
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
|
||||
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
|
||||
SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails }
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
|
||||
// One sentence and at most three buttons. The computer route and its
|
||||
// raw command live on the full page Wireless debugging opens.
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
|
||||
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
Actions {
|
||||
if (limitBlocks) {
|
||||
PrimaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions)
|
||||
SecondaryButton("Use Wireless debugging", compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
|
||||
SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus)
|
||||
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
|
||||
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
|
||||
}
|
||||
}
|
||||
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
|
||||
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
|
||||
}
|
||||
}
|
||||
CheckRow(
|
||||
if (signedIn) CheckState.OK else CheckState.WARN,
|
||||
"Steam account",
|
||||
s.offlineAccount?.let { if (s.offline) "Signed in as $it · offline mode" else "Signed in as $it" } ?: "Press Play and sign in to Steam",
|
||||
divider = false,
|
||||
)
|
||||
}
|
||||
SectionTitle("Tools", null)
|
||||
ToolGrid(s, a)
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
if (controller != null && a.controller != null) SettingsGroup("Controller") {
|
||||
ControllerRows(host, s.oscMode, controller, a.controller)
|
||||
}
|
||||
if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.")
|
||||
}
|
||||
2 -> {
|
||||
SettingsGroup("Session") {
|
||||
ChoiceRow(
|
||||
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
SettingsRow("Frame generation", "Select the frame generation mode") {
|
||||
Box {
|
||||
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
|
||||
FrameGenMenu(s, a, host)
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
|
||||
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
|
||||
ToggleRow(
|
||||
host, "offline", "Offline mode",
|
||||
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
|
||||
s.offline, enabled = s.offlineAccount != null,
|
||||
) { a.onOffline() }
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup("Launcher") {
|
||||
SettingsRow("Theme", "Choose the launcher appearance") {
|
||||
Box {
|
||||
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = "Theme") { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToggleRow(
|
||||
host, "home-screen", "Use as a Home screen",
|
||||
if (s.homeScreenEnabled) "DroidDeck can be the phone's Home app" else "Off: DroidDeck is never offered as a Home app",
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", "Fullscreen",
|
||||
if (s.launcherFullscreen) "Hide the Android status and navigation bars" else "Show the Android status and navigation bars",
|
||||
s.launcherFullscreen,
|
||||
) { a.onLauncherFullscreen(it) }
|
||||
if (s.homeScreenEnabled) {
|
||||
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
|
||||
}
|
||||
}
|
||||
SettingsGroup("Linux apps (beta)") {
|
||||
ToggleRow(
|
||||
host, "store-enabled", "Flathub Store",
|
||||
if (s.storeEnabled) "The Store is in the menu. Some apps may not start; logs are in Download/DroidDeck"
|
||||
else "Off: install Linux apps and games from Flathub with Flatpak",
|
||||
s.storeEnabled,
|
||||
) { a.onStoreEnabled(it) }
|
||||
ToggleRow(
|
||||
host, "appimages-enabled", "AppImages",
|
||||
if (s.appImagesEnabled) "Add AppImage is on the Desktop page. ARM64 (aarch64) AppImages only"
|
||||
else "Off: import ARM64 AppImages from your storage",
|
||||
s.appImagesEnabled,
|
||||
) { a.onAppImagesEnabled(it) }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
SettingsGroup("About") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user