mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
Merge branch 'main' into stretch-to-panel
This commit is contained in:
commit
d659a03467
118 files changed
+5493
-1028
No files matched your search
@@ -374,6 +374,7 @@ jobs:
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
BUILD_OUTCOME: ${{ steps.build.outcome }}
|
||||
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
MARK="<!-- droiddeck-pr-build -->"
|
||||
@@ -387,7 +388,15 @@ jobs:
|
||||
success)
|
||||
echo "### Build passed"
|
||||
echo
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. A maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
|
||||
echo
|
||||
if [ "$SAME_REPO" = true ]; then
|
||||
echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
|
||||
else
|
||||
echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
|
||||
fi
|
||||
echo
|
||||
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
|
||||
;;
|
||||
failure)
|
||||
echo "### ERROR: Build failed"
|
||||
@@ -515,6 +524,8 @@ jobs:
|
||||
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.
|
||||
Commit: $GITHUB_SHA
|
||||
VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
|
||||
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:
|
||||
@@ -576,3 +587,9 @@ jobs:
|
||||
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"
|
||||
|
||||
catalog:
|
||||
needs: announce
|
||||
if: ${{ always() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && needs.announce.result != 'skipped' }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -2,14 +2,16 @@ name: PR Release Cleanup
|
||||
run-name: remove PR #${{ github.event.pull_request.number }}'s test build
|
||||
|
||||
# A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it
|
||||
# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing
|
||||
# (a merged fix is in the next Preview). pull_request_target for the publisher app's secrets; nothing
|
||||
# from the PR is checked out or run.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ closed ]
|
||||
branches: [ main ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
@@ -30,10 +32,18 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="pr-$PR_NUMBER"
|
||||
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
|
||||
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
else
|
||||
echo "No test build for PR #$PR_NUMBER"
|
||||
fi
|
||||
found=0
|
||||
for tag in "pr-$PR_NUMBER" "pr-$PR_NUMBER.0"; do
|
||||
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
|
||||
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
|
||||
|
||||
catalog:
|
||||
needs: cleanup
|
||||
if: ${{ always() }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -1,31 +1,52 @@
|
||||
name: PR Release Publish
|
||||
run-name: sign PR #${{ inputs.pr_number }} with the release key
|
||||
run-name: "sign PR #${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }} with the release key"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Open pull request
|
||||
description: Open pull request number
|
||||
required: true
|
||||
type: number
|
||||
type: string
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
branches: [main]
|
||||
# A PR from a branch of this repo is signed on its own after each green build: only people who
|
||||
# can push here can change it. A fork's PR waits for a maintainer's publish-test label.
|
||||
workflow_run:
|
||||
workflows: [Build APK]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-release-${{ inputs.pr_number }}
|
||||
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
|
||||
(github.event_name == 'pull_request_target' && github.event.label.name == 'publish-test') ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
github.event.workflow_run.pull_requests[0] != null)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
pr_number: ${{ steps.gate.outputs.pr_number }}
|
||||
run_id: ${{ steps.gate.outputs.run_id }}
|
||||
head_sha: ${{ steps.gate.outputs.head_sha }}
|
||||
base_sha: ${{ steps.gate.outputs.base_sha }}
|
||||
version_code: ${{ steps.gate.outputs.version_code }}
|
||||
label: ${{ steps.gate.outputs.label }}
|
||||
skip: ${{ steps.gate.outputs.skip }}
|
||||
automatic: ${{ steps.gate.outputs.automatic }}
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -35,25 +56,64 @@ jobs:
|
||||
- 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: Check the PR and build
|
||||
- name: Check the requester, PR, and build
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REQUESTER: ${{ github.actor }}
|
||||
PR_NUMBER: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
BUILT_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number '$PR_NUMBER'"; exit 1; }
|
||||
# The automatic path stands down quietly, never failing: a draft, a PR labelled
|
||||
# no-test-build, or a build that a later push has already overtaken.
|
||||
skip() { echo "::notice::Not publishing PR #$PR_NUMBER: $1"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
if [ "$EVENT_NAME" = workflow_run ]; then
|
||||
echo "automatic=true" >> "$GITHUB_OUTPUT"
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
[ "$(jq -r .state <<<"$pr")" = open ] || skip "it is not open"
|
||||
[ "$(jq -r .draft <<<"$pr")" = false ] || skip "it is a draft"
|
||||
[ "$(jq -r .base.ref <<<"$pr")" = main ] || skip "it does not target main"
|
||||
[ "$(jq -r .head.repo.full_name <<<"$pr")" = "$GITHUB_REPOSITORY" ] || skip "it comes from a fork"
|
||||
jq -e 'any(.labels[]; .name == "no-test-build")' <<<"$pr" >/dev/null && skip "it is labelled no-test-build"
|
||||
[ "$(jq -r .head.sha <<<"$pr")" = "$BUILT_SHA" ] || skip "a newer push is building"
|
||||
fi
|
||||
if [ "$EVENT_NAME" = pull_request_target ]; then
|
||||
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$REQUESTER/permission" --jq .permission)
|
||||
case "$permission" in
|
||||
admin|maintain|write) ;;
|
||||
*) echo "::error::@$REQUESTER does not have permission to publish release-signed test builds"; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
jq -e '.state == "open" and .draft == false and .base.ref == "main"' <<<"$pr" >/dev/null
|
||||
head=$(jq -r '.head.sha' <<<"$pr")
|
||||
base=$(jq -r '.base.sha' <<<"$pr")
|
||||
|
||||
run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \
|
||||
--event pull_request --commit "$head" \
|
||||
--json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 |
|
||||
jq -r --arg head "$head" '[.[] | select(.headSha == $head)] |
|
||||
sort_by([.createdAt, .attempt]) | last |
|
||||
select(.status == "completed" and .conclusion == "success") | .databaseId // empty')
|
||||
[[ -n "$run_id" ]] || { echo '::error::The latest PR build is not green'; exit 1; }
|
||||
# The label is often added while the PR's build is still running, or just after a push
|
||||
# before it has started: wait for the latest build of the head commit to finish (a PR
|
||||
# build takes about 4 minutes) rather than fail and need the label added again.
|
||||
run_id=
|
||||
for attempt in $(seq 1 60); do
|
||||
latest=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml \
|
||||
--event pull_request --commit "$head" \
|
||||
--json databaseId,headSha,status,conclusion,createdAt,attempt -L 100 |
|
||||
jq -c --arg head "$head" '[.[] | select(.headSha == $head)] |
|
||||
sort_by([.createdAt, .attempt]) | last // empty')
|
||||
if [[ -n "$latest" && $(jq -r .status <<<"$latest") == completed ]]; then
|
||||
conclusion=$(jq -r .conclusion <<<"$latest")
|
||||
[[ "$conclusion" == success ]] || { echo "::error::The latest PR build is not green ($conclusion)"; exit 1; }
|
||||
run_id=$(jq -r .databaseId <<<"$latest")
|
||||
break
|
||||
fi
|
||||
status=not-started
|
||||
[[ -z "$latest" ]] || status=$(jq -r .status <<<"$latest")
|
||||
echo "Waiting for the PR build of ${head:0:7} to finish ($status)"
|
||||
sleep 30
|
||||
done
|
||||
[[ -n "$run_id" ]] || { echo '::error::The PR build did not finish within 30 minutes'; exit 1; }
|
||||
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id")
|
||||
jq -e --arg head "$head" --arg base "$base" --argjson number "$PR_NUMBER" '
|
||||
.event == "pull_request" and .head_sha == $head and .conclusion == "success" and
|
||||
@@ -85,8 +145,11 @@ jobs:
|
||||
.head.sha == $head and .base.sha == $base
|
||||
' <<<"$current" >/dev/null
|
||||
{
|
||||
echo "pr_number=$PR_NUMBER"
|
||||
echo "run_id=$run_id"
|
||||
echo "head_sha=$head"
|
||||
echo "base_sha=$base"
|
||||
echo "version_code=${candidate##* }"
|
||||
echo "label=PR.$PR_NUMBER-${head:0:7}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -100,6 +163,7 @@ jobs:
|
||||
label: ${{ needs.authorize.outputs.label }}
|
||||
artifact: droiddeck-pr-signed
|
||||
secrets: inherit
|
||||
if: ${{ needs.authorize.outputs.skip != 'true' }}
|
||||
|
||||
publish:
|
||||
needs: [authorize, sign]
|
||||
@@ -107,7 +171,8 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -119,7 +184,7 @@ jobs:
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
@@ -143,10 +208,13 @@ jobs:
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Publish to DroidDeck-CI
|
||||
id: publish
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
BASE_SHA: ${{ needs.authorize.outputs.base_sha }}
|
||||
VERSION_CODE: ${{ needs.authorize.outputs.version_code }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=Droid-Deck/DroidDeck-CI
|
||||
@@ -157,15 +225,20 @@ jobs:
|
||||
|
||||
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
Commit: $HEAD_SHA
|
||||
Base: $BASE_SHA
|
||||
VersionCode: $VERSION_CODE
|
||||
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)
|
||||
\`\`\`"
|
||||
first=false
|
||||
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
|
||||
gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
first=true
|
||||
fi
|
||||
echo "first=$first" >> "$GITHUB_OUTPUT"
|
||||
gh release upload "$tag" signed/* -R "$repo" --clobber
|
||||
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
# Drop the previous build's files: anything this build did not just upload.
|
||||
@@ -175,3 +248,97 @@ jobs:
|
||||
while IFS= read -r id; do
|
||||
gh api --method DELETE "repos/$repo/releases/assets/$id" >/dev/null
|
||||
done
|
||||
|
||||
published=$(gh release view "$tag" -R "$repo" --json tagName,assets)
|
||||
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<<"$published" >/dev/null
|
||||
release="$GITHUB_SERVER_URL/$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=$release" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$GITHUB_SERVER_URL/$repo/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Whether the PR's branch is in this repo
|
||||
id: same-repo
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
run: |
|
||||
head_repo=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.repo.full_name)
|
||||
echo "same=$([ "$head_repo" = "$GITHUB_REPOSITORY" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clear the publish request label
|
||||
if: ${{ github.event_name == 'pull_request_target' && steps.publish.outcome == 'success' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
run: |
|
||||
gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/publish-test"
|
||||
|
||||
- name: Report the signed build on the PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
SAME_REPO: ${{ steps.same-repo.outputs.same }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
short=${HEAD_SHA:0:7}
|
||||
mark='<!-- droiddeck-pr-release -->'
|
||||
if [ "$SAME_REPO" = true ]; then
|
||||
NEXT='Each green build of a new push is signed and published here on its own. Label the PR `no-test-build` to stop that.'
|
||||
else
|
||||
NEXT='Pushes to this PR do not re-sign automatically. After a new green build, a maintainer can add the `publish-test` label again.'
|
||||
fi
|
||||
body=$(cat <<EOF
|
||||
$mark
|
||||
## Signed test build
|
||||
|
||||
A release-signed build of this PR at \`$short\` is ready. It installs over Stable or Preview and keeps app data.
|
||||
|
||||
[Download the standard APK]($APK) · [All variants and checksums]($RELEASE)
|
||||
|
||||
It is also available in **DroidDeck → Updates → Test builds → PR #$PR_NUMBER**.
|
||||
|
||||
$NEXT
|
||||
EOF
|
||||
)
|
||||
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
|
||||
if [ -n "$id" ]; then
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
|
||||
fi
|
||||
|
||||
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
|
||||
# only a PR's first test build is, and later ones update its release quietly.
|
||||
- name: Post to Discord
|
||||
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
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
|
||||
short=${HEAD_SHA:0:7}
|
||||
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
|
||||
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$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"
|
||||
|
||||
catalog:
|
||||
needs: publish
|
||||
if: ${{ always() && needs.publish.result != 'skipped' }}
|
||||
uses: ./.github/workflows/update-catalog.yml
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Update app catalog
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published, edited, deleted, unpublished]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: update-app-catalog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
catalog:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Always use the current trusted generator, even when a release event points at an older tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Regenerate catalog.json
|
||||
env:
|
||||
SOURCE_GITHUB_TOKEN: ${{ github.token }}
|
||||
CI_GITHUB_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/release/update_catalog.py
|
||||
@@ -7,14 +7,20 @@
|
||||
|
||||
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"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
|
||||
|
||||
<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. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
|
||||
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/Droid-Deck/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.
|
||||
|
||||
## Community
|
||||
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
|
||||
|
||||
## Build
|
||||
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
|
||||
@@ -25,4 +31,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
@@ -55,6 +55,8 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO
|
||||
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
|
||||
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
// The release key's certificate: a build signed with it, from CI or a computer, updates in place.
|
||||
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
@@ -83,6 +85,7 @@ android {
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
|
||||
buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\""
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* a game become more of these nodes instead of vanishing.
|
||||
*/
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <climits>
|
||||
#include <cstdint>
|
||||
@@ -38,6 +39,9 @@
|
||||
#include <sys/inotify.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
@@ -295,21 +299,52 @@ static void controller_fork_child() {
|
||||
|
||||
namespace Logger {
|
||||
int log_enabled;
|
||||
// FAKE_EVDEV_LOG_FILE: the session's pad.log, shared by every process the library is loaded in
|
||||
// (the client, each game), so a pad report has the guest side in one file of the session folder.
|
||||
// Opened with the raw syscall: open() is this library's own hook.
|
||||
static int log_fd = -1;
|
||||
static char log_name[17];
|
||||
|
||||
void init() {
|
||||
log_enabled = getenv("FAKE_EVDEV_LOG") && atoi(getenv("FAKE_EVDEV_LOG"));
|
||||
const char *path = getenv("FAKE_EVDEV_LOG_FILE");
|
||||
if (log_enabled && path && *path)
|
||||
log_fd = static_cast<int>(syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644));
|
||||
if (prctl(PR_GET_NAME, log_name) != 0) strcpy(log_name, "?");
|
||||
}
|
||||
|
||||
void log(const char *message, ...) {
|
||||
if (!log_enabled)
|
||||
return;
|
||||
|
||||
if (log_fd < 0) {
|
||||
va_list args;
|
||||
va_start(args, message);
|
||||
vfprintf(stderr, message, args);
|
||||
va_end(args);
|
||||
std::cerr.flush();
|
||||
return;
|
||||
}
|
||||
// One write per line: O_APPEND keeps lines from different processes whole.
|
||||
char line[512];
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_REALTIME, &now);
|
||||
struct tm local;
|
||||
localtime_r(&now.tv_sec, &local);
|
||||
int n = snprintf(line, sizeof(line), "%02d:%02d:%02d.%03ld %d %s: ", local.tm_hour, local.tm_min,
|
||||
local.tm_sec, now.tv_nsec / 1000000, getpid(), log_name);
|
||||
va_list args;
|
||||
va_start(args, message);
|
||||
vfprintf(stderr, message, args);
|
||||
if (n > 0 && n < static_cast<int>(sizeof(line)))
|
||||
n += vsnprintf(line + n, sizeof(line) - n, message, args);
|
||||
va_end(args);
|
||||
|
||||
std::cerr.flush();
|
||||
if (n <= 0) return;
|
||||
if (n >= static_cast<int>(sizeof(line))) {
|
||||
n = sizeof(line) - 1;
|
||||
line[n - 1] = '\n';
|
||||
}
|
||||
ssize_t ignored = write(log_fd, line, n);
|
||||
(void)ignored;
|
||||
}
|
||||
} // namespace Logger
|
||||
|
||||
@@ -649,31 +684,43 @@ open_fake_input_ring(const char *event, int flags) {
|
||||
// does not exist here, so the game was left with no controller at all. With
|
||||
// FAKE_EVDEV_STEAM_VIRTUAL set the pad carries the virtual gamepad's identity for every process
|
||||
// but the client's own, which must keep seeing the pad as the physical one it reads.
|
||||
__attribute__((visibility("hidden"))) static bool presents_steam_virtual() {
|
||||
// Whether this process is the Steam client itself. Asked once: a process never becomes another.
|
||||
__attribute__((visibility("hidden"))) static bool is_steam_client() {
|
||||
static int known;
|
||||
if (known == 0) {
|
||||
const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL");
|
||||
char exe[PATH_MAX];
|
||||
ssize_t length = enabled && atoi(enabled) ? readlink("/proc/self/exe", exe, sizeof(exe) - 1) : -1;
|
||||
ssize_t length = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
|
||||
bool client = false;
|
||||
if (length > 0) {
|
||||
exe[length] = '\0';
|
||||
const char *name = strrchr(exe, '/');
|
||||
client = !strncmp(name ? name + 1 : exe, "steam", 5);
|
||||
}
|
||||
known = length > 0 && !client ? 1 : -1;
|
||||
known = client ? 1 : -1;
|
||||
}
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine places a
|
||||
// gamepad's axes by their position among the ones it advertises, so the triggers have to sit
|
||||
__attribute__((visibility("hidden"))) static bool presents_steam_virtual() {
|
||||
static int known;
|
||||
if (known == 0) {
|
||||
const char *enabled = getenv("FAKE_EVDEV_STEAM_VIRTUAL");
|
||||
known = enabled && atoi(enabled) && !is_steam_client() ? 1 : -1;
|
||||
}
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
// The virtual gamepad is an X-Box 360 pad, whose triggers are ABS_Z and ABS_RZ. Wine and SDL place
|
||||
// a gamepad's axes by their position among the ones it advertises, so the triggers have to sit
|
||||
// where that pad has them: as ABS_GAS and ABS_BRAKE they follow the sticks, the right stick
|
||||
// lands on the left trigger, and a released right trigger reads as the right stick held up.
|
||||
// A pad made through /dev/uinput is that pad for real, whoever reads it.
|
||||
// lands on the left trigger, and a released right trigger reads as the right stick held up. That
|
||||
// is every reader but the Steam client, which maps the pad from what it finds (controller.txt)
|
||||
// and has always been shown ABS_GAS/ABS_BRAKE: a game Steam Input is off for reads this pad
|
||||
// itself (the Xbox 360 setting), and so do the desktop's programs. A pad made through
|
||||
// /dev/uinput is that pad for real, whoever reads it.
|
||||
__attribute__((visibility("hidden"))) static bool
|
||||
presents_xbox_triggers(const FakeController &fake) {
|
||||
return fake.uinput || presents_steam_virtual();
|
||||
return fake.uinput || !is_steam_client();
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static uint16_t
|
||||
@@ -1256,6 +1303,14 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
// What the client starts with, so a pad log says whether it was offered a Deck at all.
|
||||
__attribute__((constructor)) static void log_client_start() {
|
||||
config();
|
||||
if (process_is_steam_client())
|
||||
Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
|
||||
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) {
|
||||
return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH);
|
||||
}
|
||||
@@ -1364,12 +1419,16 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
|
||||
__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
if (!process_is_steam_client()) {
|
||||
// Only the client reads the Deck; a game sees Steam Input's virtual pad instead.
|
||||
Logger::log("deck: %s refused: not the Steam client\n", DECK_HIDRAW_PATH);
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
std::string ring_path = get_ring_path_for_slot(0);
|
||||
int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (ring_fd < 0) {
|
||||
Logger::log("deck: %s refused: ring %s not openable (%s)\n", DECK_HIDRAW_PATH,
|
||||
ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno));
|
||||
errno = ENODEV;
|
||||
return -1;
|
||||
}
|
||||
@@ -1382,12 +1441,15 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, ring_fd, 0);
|
||||
syscall(SYS_close, ring_fd);
|
||||
if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast<FakeInputRingHeader *>(mapping))) {
|
||||
Logger::log("deck: %s refused: ring %s %s\n", DECK_HIDRAW_PATH, ring_path.c_str(),
|
||||
mapping == MAP_FAILED ? "could not be mapped" : "has no valid header");
|
||||
if (mapping != MAP_FAILED) munmap(mapping, mapping_size);
|
||||
errno = ENODEV;
|
||||
return -1;
|
||||
}
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) {
|
||||
Logger::log("deck: %s refused: socketpair failed (%s)\n", DECK_HIDRAW_PATH, strerror(errno));
|
||||
munmap(mapping, mapping_size);
|
||||
return -1;
|
||||
}
|
||||
@@ -1426,8 +1488,18 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
// into /sys, so those paths are said to be sysfs; nothing else is touched.
|
||||
static constexpr long SYSFS_FS_MAGIC = 0x62656572;
|
||||
|
||||
// FAKE_DECK_SYSFS_LISTING: stand-ins for the listings of /sys, /sys/class and /sys/bus
|
||||
// (SteamDeckPad.listingDir), for a client that is not allowed to list the real ones.
|
||||
__attribute__((visibility("hidden"))) static const char *deck_listing_dir() {
|
||||
static const char *dir = getenv("FAKE_DECK_SYSFS_LISTING");
|
||||
return dir && *dir ? dir : nullptr;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) {
|
||||
return path && (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17));
|
||||
if (!path) return false;
|
||||
if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true;
|
||||
const char *listing = deck_listing_dir();
|
||||
return listing && !strncmp(path, listing, strlen(listing));
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) {
|
||||
@@ -1439,17 +1511,32 @@ __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) {
|
||||
return is_deck_sysfs_path(path);
|
||||
}
|
||||
|
||||
// Once per process: proof the client's enumeration reached the Deck's sysfs at all. No line here
|
||||
// and no open of /dev/hidraw16 means the client never looked; a line here and no open means it
|
||||
// looked and turned the device down.
|
||||
__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *how, int result) {
|
||||
static std::atomic<bool> noted{false};
|
||||
if (!noted.exchange(true))
|
||||
Logger::log("deck: sysfs checked (%s%s)\n", how, result == 0 ? "" : ", failed");
|
||||
}
|
||||
|
||||
EXPORT int fstatfs(int fd, struct statfs *buf) {
|
||||
static auto real = reinterpret_cast<int (*)(int, struct statfs *)>(dlsym(RTLD_NEXT, "fstatfs"));
|
||||
int result = real(fd, buf);
|
||||
if (result == 0 && fake_deck_enabled() && is_deck_sysfs_fd(fd)) buf->f_type = SYSFS_FS_MAGIC;
|
||||
if (fake_deck_enabled() && is_deck_sysfs_fd(fd)) {
|
||||
note_deck_sysfs("fstatfs", result);
|
||||
if (result == 0) buf->f_type = SYSFS_FS_MAGIC;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
EXPORT int statfs(const char *path, struct statfs *buf) {
|
||||
static auto real = reinterpret_cast<int (*)(const char *, struct statfs *)>(dlsym(RTLD_NEXT, "statfs"));
|
||||
int result = real(path, buf);
|
||||
if (result == 0 && fake_deck_enabled() && is_deck_sysfs_path(path)) buf->f_type = SYSFS_FS_MAGIC;
|
||||
if (fake_deck_enabled() && is_deck_sysfs_path(path)) {
|
||||
note_deck_sysfs(path, result);
|
||||
if (result == 0) buf->f_type = SYSFS_FS_MAGIC;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1471,6 +1558,15 @@ __attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request
|
||||
}
|
||||
|
||||
// Feature reports carry the report id (0) first, then the Valve message: type, length, payload.
|
||||
// The client repeats some feature reports (settings, rumble) for as long as it has the pad; each
|
||||
// is logged the first time and then at 2, 4, 8... so the log shows what it asked and how often
|
||||
// without a line per report.
|
||||
__attribute__((visibility("hidden"))) static void log_deck_feature(const char *what, uint8_t feature) {
|
||||
static std::atomic<unsigned> counts[2][256];
|
||||
unsigned n = ++counts[what[0] == 's'][feature];
|
||||
if ((n & (n - 1)) == 0) Logger::log("deck: feature 0x%02x %s (x%u)\n", feature, what, n);
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int
|
||||
deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
|
||||
size_t size = _IOC_SIZE(op);
|
||||
@@ -1484,7 +1580,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
|
||||
int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8;
|
||||
send_vibration(left, right, left || right ? 1000 : 0, 0);
|
||||
}
|
||||
Logger::log("deck: feature 0x%02x set\n", buf[1]);
|
||||
log_deck_feature("set", buf[1]);
|
||||
return static_cast<int>(size);
|
||||
}
|
||||
uint8_t reply[DECK_REPORT_BYTES + 1] = {};
|
||||
@@ -1514,7 +1610,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
|
||||
reply[1] = deck.pending_feature;
|
||||
break;
|
||||
}
|
||||
Logger::log("deck: feature 0x%02x read\n", deck.pending_feature);
|
||||
log_deck_feature("read", deck.pending_feature);
|
||||
size_t length = std::min(size, sizeof(reply));
|
||||
memcpy(buf, reply, length);
|
||||
return static_cast<int>(length);
|
||||
@@ -1558,6 +1654,80 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) {
|
||||
}
|
||||
}
|
||||
|
||||
// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any
|
||||
// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy
|
||||
// denies directories a permissive device lets through. The client's /sys failures that are not
|
||||
// ENOENT are logged, each path once, so pad.log names the directory that stopped the scan.
|
||||
__attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) {
|
||||
int saved_errno = errno;
|
||||
std::string path;
|
||||
if (saved_errno == ENOENT || !pathname || !process_is_steam_client()) {
|
||||
errno = saved_errno;
|
||||
return path;
|
||||
}
|
||||
if (pathname[0] == '/' || dirfd == AT_FDCWD) {
|
||||
path = pathname;
|
||||
} else {
|
||||
char link[64], dir[PATH_MAX];
|
||||
snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd);
|
||||
ssize_t length = readlink(link, dir, sizeof(dir) - 1);
|
||||
if (length <= 0) {
|
||||
errno = saved_errno;
|
||||
return path;
|
||||
}
|
||||
path.assign(dir, length);
|
||||
path += '/';
|
||||
path += pathname;
|
||||
}
|
||||
if (path.compare(0, 5, "/sys/") != 0 && path != "/sys") {
|
||||
errno = saved_errno;
|
||||
return {};
|
||||
}
|
||||
static std::mutex mutex;
|
||||
static std::vector<std::string> *seen = new std::vector<std::string>();
|
||||
bool first = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
if (seen->size() < 64 && std::find(seen->begin(), seen->end(), path) == seen->end()) {
|
||||
seen->push_back(path);
|
||||
first = true;
|
||||
}
|
||||
}
|
||||
if (first) Logger::log("sysfs: %s not readable (%s)\n", path.c_str(), strerror(saved_errno));
|
||||
errno = saved_errno;
|
||||
return path;
|
||||
}
|
||||
|
||||
// An enforcing SELinux policy (every retail phone; the Thor only with it forced on) refuses an app
|
||||
// a listing of /sys, /sys/class and /sys/bus. libudev lists /sys/class to find the subsystems to
|
||||
// descend into and gives up its whole scan when it cannot, so the client never reached hidraw and
|
||||
// found no Deck. The client is listed the stand-ins instead - hidraw under class - and opens
|
||||
// /sys/class/hidraw itself, which is ours and readable. Only on that refusal, only for the client.
|
||||
__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path) {
|
||||
const char *listing = deck_listing_dir();
|
||||
if (!path || !listing || errno != EACCES || !fake_deck_enabled() || !process_is_steam_client()) return {};
|
||||
std::string p(path);
|
||||
while (p.size() > 1 && p.back() == '/') p.pop_back();
|
||||
int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1;
|
||||
if (which < 0) return {};
|
||||
static const char *const names[] = {"sys", "class", "bus"};
|
||||
static std::atomic<int> logged{0};
|
||||
if (!(logged.fetch_or(1 << which) & (1 << which)))
|
||||
Logger::log("sysfs: %s listed from the Deck's stand-in\n", p.c_str());
|
||||
return std::string(listing) + "/" + names[which];
|
||||
}
|
||||
|
||||
EXPORT DIR *opendir(const char *name) {
|
||||
static auto real = reinterpret_cast<DIR *(*)(const char *)>(dlsym(RTLD_NEXT, "opendir"));
|
||||
DIR *dir = real(name);
|
||||
if (!dir) {
|
||||
note_sys_failure(AT_FDCWD, name);
|
||||
std::string listing = deck_listing_for(name);
|
||||
if (!listing.empty()) dir = real(listing.c_str());
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
EXPORT int open(const char *pathname, int flags, ...) {
|
||||
va_list va;
|
||||
mode_t mode;
|
||||
@@ -1621,6 +1791,10 @@ EXPORT int open(const char *pathname, int flags, ...) {
|
||||
fd = my_open(pathname, flags, mode);
|
||||
else
|
||||
fd = my_open(pathname, flags);
|
||||
if (fd < 0) {
|
||||
std::string listing = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str());
|
||||
if (!listing.empty()) fd = my_open(listing.c_str(), flags);
|
||||
}
|
||||
|
||||
if (fake_path)
|
||||
free(fake_path);
|
||||
@@ -1691,6 +1865,10 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
fd = my_openat(dirfd, pathname, flags, mode);
|
||||
else
|
||||
fd = my_openat(dirfd, pathname, flags);
|
||||
if (fd < 0) {
|
||||
std::string listing = deck_listing_for(note_sys_failure(dirfd, pathname).c_str());
|
||||
if (!listing.empty()) fd = my_openat(AT_FDCWD, listing.c_str(), flags);
|
||||
}
|
||||
|
||||
if (fake_path)
|
||||
free(fake_path);
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_alpha.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_beta.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_chain.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_common.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_delta.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless
|
||||
// Scaling frame-generation chain.
|
||||
//
|
||||
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to
|
||||
// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk
|
||||
// (PancakeTAS). Bannerlator is GPL-3.0.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
|
||||
// by @maxjivi05. GPL-3.0-or-later.
|
||||
|
||||
#include "lsfg_dll.h"
|
||||
#include "lsfg_dxbc.h"
|
||||
@@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) {
|
||||
return status;
|
||||
}
|
||||
|
||||
Variant dllVariant(const std::string& dllPath, bool preferFp16) {
|
||||
if (dllPath.empty()) return Variant::None;
|
||||
|
||||
PeImage img;
|
||||
if (!img.open(dllPath)) return Variant::None;
|
||||
|
||||
ResourceTable table;
|
||||
if (parseResources(img, table) != DllStatus::Ok) return Variant::None;
|
||||
|
||||
Variant v = selectVariant(table, preferFp16);
|
||||
if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated;
|
||||
return v;
|
||||
}
|
||||
|
||||
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) {
|
||||
if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled;
|
||||
|
||||
@@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b
|
||||
return DllStatus::Ok;
|
||||
}
|
||||
|
||||
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
|
||||
bool& outMatches) {
|
||||
outMatches = false;
|
||||
if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable;
|
||||
|
||||
CacheHeader header{};
|
||||
if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable;
|
||||
|
||||
PeImage img;
|
||||
if (!img.open(dllPath)) return DllStatus::NotInstalled;
|
||||
|
||||
outMatches = header.sourceSize == (uint64_t)img.size()
|
||||
&& header.sourceHash == fnv1a64(img.data(), img.size());
|
||||
return DllStatus::Ok;
|
||||
}
|
||||
|
||||
DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) {
|
||||
outVariant = Variant::None;
|
||||
CacheHeader header{};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// ship as RCDATA resources inside Lossless.dll from the user's own paid copy
|
||||
// of Lossless Scaling (Steam app 993090). Every implementation - upstream
|
||||
// lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their
|
||||
// own DLL and extracts the resources on device. Bannerlator does the same, and
|
||||
// own DLL and extracts the resources on device. DroidDeck does the same, and
|
||||
// bundles nothing.
|
||||
//
|
||||
// The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a
|
||||
@@ -26,9 +26,9 @@
|
||||
// Both producers fill the same shader-id -> SPIR-V words map, so everything
|
||||
// downstream of this file is source-agnostic.
|
||||
//
|
||||
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is
|
||||
// credited to Camille LaVey / the Eden Emulator Project, and which follows
|
||||
// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
|
||||
// by @maxjivi05. GPL-3.0-or-later.
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdint>
|
||||
@@ -98,18 +98,11 @@ const char* variantName(Variant v);
|
||||
// reported as valid - the translator handles it.
|
||||
DllStatus validateDll(const std::string& dllPath);
|
||||
|
||||
// Which producer would be used for this DLL.
|
||||
Variant dllVariant(const std::string& dllPath, bool preferFp16);
|
||||
|
||||
// Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath
|
||||
// (via temp file + rename, so a failed build cannot leave a half-written
|
||||
// cache behind). Records which producer ran in the cache header.
|
||||
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16);
|
||||
|
||||
// Is the cache current for this DLL? Compares source size + content hash.
|
||||
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
|
||||
bool& outMatches);
|
||||
|
||||
// Load a previously built cache.
|
||||
DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator.
|
||||
//
|
||||
// Follows upstream lsfg-vk's src/extract/trans.cpp, including its
|
||||
// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp
|
||||
// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored
|
||||
// under cpp/thirdparty/dxbc.
|
||||
// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and
|
||||
// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator
|
||||
// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed,
|
||||
// vendored under cpp/thirdparty/dxbc.
|
||||
|
||||
#include "lsfg_dxbc.h"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// See lsfg_engine.h.
|
||||
//
|
||||
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
|
||||
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
|
||||
// @maxjivi05, where it runs on gamescope's output in the compositor.
|
||||
|
||||
#include "lsfg_engine.h"
|
||||
|
||||
@@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
|
||||
// nothing. Copy only when generating, on the one priming frame that
|
||||
// precedes generation, or while the governor is warming up towards a
|
||||
// probe - so the ring is populated by the time it is needed.
|
||||
const bool needHistory = generations > 0 || primeHistory_
|
||||
const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame()
|
||||
|| (governorEnabled_ && governor_.wantsHistory());
|
||||
if (needHistory) {
|
||||
copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height});
|
||||
@@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
|
||||
// regardless of the game's own settings, and it fed straight back into the
|
||||
// governor: the chain made the source rate collapse, the governor saw the
|
||||
// collapse and refused to generate, and refusing did not stop the chain.
|
||||
if (warm_ && generations > 0) chain_->DispatchShared(cmd, count);
|
||||
if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count);
|
||||
}
|
||||
|
||||
void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex,
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
// unavoidable cost of interpolating rather than extrapolating; no placement of
|
||||
// this code changes it.
|
||||
//
|
||||
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
|
||||
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
|
||||
// @maxjivi05, where it runs on gamescope's output in the compositor.
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdint>
|
||||
@@ -109,6 +110,10 @@ private:
|
||||
// generation last stopped.
|
||||
bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; }
|
||||
|
||||
// With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the
|
||||
// shared chain have to follow every frame it keeps warm, not just the ones that generate.
|
||||
bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; }
|
||||
|
||||
Device device_{};
|
||||
std::string cachePath_;
|
||||
std::unique_ptr<LsfgShaders> shaders_;
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_gamma.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_generate.hpp"
|
||||
#include "lsfg_dll.h"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass
|
||||
return (jint)lsfg::validateDll(toStdString(env, dllPath));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath,
|
||||
jboolean preferFp16) {
|
||||
return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath,
|
||||
jstring cachePath, jboolean preferFp16) {
|
||||
@@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass,
|
||||
preferFp16 == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass,
|
||||
jstring cachePath,
|
||||
jstring dllPath) {
|
||||
bool matches = false;
|
||||
const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath),
|
||||
toStdString(env, dllPath), matches);
|
||||
return (jboolean)(status == lsfg::DllStatus::Ok && matches);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) {
|
||||
lsfg::Variant variant = lsfg::Variant::None;
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_mipmaps.hpp"
|
||||
#include "lsfg_dll.h"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_pacer.hpp"
|
||||
|
||||
@@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f;
|
||||
constexpr float SOURCE_STALE_SECONDS = 0.5f;
|
||||
constexpr float DISCONTINUITY_SECONDS = 0.25f;
|
||||
constexpr float HEADROOM_EPSILON = 0.02f;
|
||||
// A target is aimed just under, so rounding never puts output past the panel's refresh rate.
|
||||
constexpr float TARGET_MARGIN = 0.1f;
|
||||
constexpr float CREDIT_EPSILON = 1.0e-4f;
|
||||
constexpr float SOURCE_ACCUM_FLOOR = 0.01f;
|
||||
constexpr uint32_t MIN_RATE_SAMPLES = 12;
|
||||
@@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) {
|
||||
if (target_rate > 0.0f && config.refresh_rate > 0.0f) {
|
||||
target_rate = std::min(target_rate, config.refresh_rate);
|
||||
}
|
||||
if (target_rate > 0.0f) target_rate -= TARGET_MARGIN;
|
||||
|
||||
if (target_rate == 0.0f) {
|
||||
// No explicit target rate: the multiplier is the multiplier, full stop.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_shaders.hpp"
|
||||
#include "lsfg_common.hpp"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// ============================================================================
|
||||
// lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global.
|
||||
//
|
||||
// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan
|
||||
// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan
|
||||
// through a per-context VkTable (`vk_`), because an adrenotools-loaded driver
|
||||
// lives in an isolated linker namespace and shares no global symbols with the
|
||||
// system loader. The LSFG chain, ported from eden by way of WinNative, calls
|
||||
// system loader. The LSFG chain, ported from Eden by way of WinNative, calls
|
||||
// through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain
|
||||
// code to thread a table through every call, this shim exposes exactly the 34
|
||||
// entry points the chain uses and is filled from the renderer's own table when
|
||||
|
||||
@@ -541,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
|
||||
wl_shm_buffer_end_access(shm);
|
||||
}
|
||||
|
||||
/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below
|
||||
* copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to
|
||||
* change the cursor. A torn read is only a missed or an extra snapshot. */
|
||||
int banner_cursor_serial(void) {
|
||||
return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED);
|
||||
}
|
||||
|
||||
/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...].
|
||||
* Returns the number of ints written, or 0 if out is too small. */
|
||||
int banner_cursor_snapshot(int *out, int cap) {
|
||||
|
||||
@@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri
|
||||
static _Atomic int g_kind = VKP_FG_ENGINE_LSFG;
|
||||
static _Atomic int g_armed;
|
||||
static _Atomic int g_mult = 2;
|
||||
static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */
|
||||
static _Atomic int g_model = 3;
|
||||
static _Atomic int g_preset = 2;
|
||||
static _Atomic int g_cfg_dirty = 1;
|
||||
@@ -51,6 +52,7 @@ static struct {
|
||||
|
||||
/* ---- engine + generation ring (compositor thread) ------------------------------------ */
|
||||
static int g_engine_kind = -1; /* kind of the running engine, -1 = none */
|
||||
static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */
|
||||
static int g_engine_ok;
|
||||
/* The engine of this kind (with this cache) could not start or build its chain; not retried
|
||||
* every frame - a new engine selection or a new cache clears it. */
|
||||
@@ -92,15 +94,20 @@ static int64_t now_ns(void) {
|
||||
void vkp_framegen_set_engine(int kind) {
|
||||
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG;
|
||||
atomic_store(&g_kind, kind);
|
||||
atomic_store(&g_failed_kind, -1);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_armed(int armed, int multiplier) {
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) {
|
||||
if (target_fps < 0) target_fps = 0;
|
||||
/* Adaptive sizes the ring and the swapchain for the most it may ever generate. */
|
||||
if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1;
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1;
|
||||
if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1);
|
||||
if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1);
|
||||
atomic_store(&g_armed, armed ? 1 : 0);
|
||||
LOGD("framegen: set_armed(%d, x%d)", armed, multiplier);
|
||||
LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path) {
|
||||
@@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) {
|
||||
}
|
||||
|
||||
static int ensure_engine(int kind) {
|
||||
if (g_engine_ok && g_engine_kind == kind) return 1;
|
||||
const int cache_gen = atomic_load(&g_cache_gen);
|
||||
if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen))
|
||||
return 1;
|
||||
if (start_failed_for(kind)) return 0;
|
||||
if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; }
|
||||
char *path = NULL;
|
||||
@@ -293,7 +302,7 @@ static int ensure_engine(int kind) {
|
||||
if (g_cache_path) path = strdup(g_cache_path);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (kind == VKP_FG_ENGINE_LSFG && !path) {
|
||||
FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)");
|
||||
FGLOG("LSFG Native can't start: no shader cache from Lossless.dll");
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
@@ -305,29 +314,39 @@ static int ensure_engine(int kind) {
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
g_engine_ok = 1; g_engine_kind = kind;
|
||||
g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen;
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
g_built_w = g_built_h = 0;
|
||||
FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info());
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int g_logged_mult;
|
||||
/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */
|
||||
static const char *describe_mode(char *buf, size_t n, int mult, int target) {
|
||||
if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1);
|
||||
else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s");
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int g_logged_mult, g_logged_target;
|
||||
static void log_arm_transition(int armed, int kind, int mult) {
|
||||
const int target = atomic_load(&g_target);
|
||||
char mode[96];
|
||||
if (armed == g_was_armed) {
|
||||
if (armed && mult != g_logged_mult) {
|
||||
g_logged_mult = mult;
|
||||
FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1);
|
||||
if (armed && (mult != g_logged_mult || target != g_logged_target)) {
|
||||
g_logged_mult = mult; g_logged_target = target;
|
||||
FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target));
|
||||
}
|
||||
return;
|
||||
}
|
||||
g_was_armed = armed;
|
||||
if (armed) {
|
||||
g_logged_mult = mult;
|
||||
g_logged_mult = mult; g_logged_target = target;
|
||||
float flow, hz;
|
||||
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
|
||||
FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
|
||||
"with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz);
|
||||
FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
|
||||
"with the interpolated frames ahead of them", fge_engine_name(kind),
|
||||
describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz);
|
||||
} else {
|
||||
FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated);
|
||||
g_generating_logged = 0;
|
||||
@@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
if (atomic_exchange(&g_cfg_dirty, 0)) {
|
||||
float flow, hz;
|
||||
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
|
||||
fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset));
|
||||
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
|
||||
atomic_load(&g_model), atomic_load(&g_preset));
|
||||
}
|
||||
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
|
||||
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
|
||||
@@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
0, 0, NULL, 0, NULL, nb, bars);
|
||||
if (!g_generating_logged) {
|
||||
g_generating_logged = 1;
|
||||
FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)",
|
||||
fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s",
|
||||
char mode[96];
|
||||
FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind),
|
||||
describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h,
|
||||
fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
|
||||
|
||||
/* ---- app controls (any thread) ------------------------------------------------------- */
|
||||
void vkp_framegen_set_engine(int kind);
|
||||
/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */
|
||||
void vkp_framegen_set_armed(int armed, int multiplier);
|
||||
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */
|
||||
/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs);
|
||||
* armed = 0 stops generating (the ring and the engine stay for a re-arm). */
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
|
||||
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path);
|
||||
/* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */
|
||||
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
|
||||
|
||||
@@ -197,11 +197,11 @@ int fge_unavailable(void) {
|
||||
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
|
||||
}
|
||||
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset) {
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset) {
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz);
|
||||
}
|
||||
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
|
||||
@@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path);
|
||||
void fge_stop(void);
|
||||
/* The engine came up but later found it cannot generate (chain build failed). */
|
||||
int fge_unavailable(void);
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset);
|
||||
/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset);
|
||||
/* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same
|
||||
* (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt);
|
||||
|
||||
@@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; }
|
||||
int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; }
|
||||
void fge_stop(void) {}
|
||||
int fge_unavailable(void) { return 1; }
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) {
|
||||
(void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset) {
|
||||
(void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
|
||||
}
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; }
|
||||
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
|
||||
|
||||
@@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz,
|
||||
jboolean armed, jint multiplier) {
|
||||
jboolean armed, jint multiplier, jint targetFps) {
|
||||
(void)env; (void)clazz;
|
||||
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier);
|
||||
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
#include <jni.h>
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
@@ -25,6 +26,7 @@ extern int banner_wayland_run(void);
|
||||
extern void banner_wayland_send_pointer(int action, int x, int y);
|
||||
extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y);
|
||||
extern int banner_cursor_snapshot(int *out, int cap);
|
||||
extern int banner_cursor_serial(void);
|
||||
extern void banner_wayland_send_key(int evdev, int state);
|
||||
extern void banner_wayland_send_scene_input(int type, int a, int b);
|
||||
extern void banner_wayland_vsync(int64_t frame_time_ns);
|
||||
@@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The serial nativeCursorSnapshot would return, without copying anything. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) {
|
||||
(void)env; (void)clazz;
|
||||
return banner_cursor_serial();
|
||||
}
|
||||
|
||||
/* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
|
||||
@@ -670,3 +679,29 @@ JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
|
||||
banner_host_text_delete(before, after);
|
||||
}
|
||||
|
||||
/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the
|
||||
* device's, not this process's: it outlives the fd and the app, so the caller clears it at stop
|
||||
* and again at every app start. No root; the kernel's thermal limits still apply. */
|
||||
extern void adrenotools_set_turbo(bool turbo);
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
(void)env; (void)clazz;
|
||||
adrenotools_set_turbo(on == JNI_TRUE);
|
||||
}
|
||||
|
||||
/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering
|
||||
* it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between
|
||||
* scheduling groups by priority, and a guest process must stay where Android put the app. Returns
|
||||
* the nice value it is left at, or 100 when it could not be read. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) {
|
||||
(void)env; (void)clazz;
|
||||
errno = 0;
|
||||
int before = getpriority(PRIO_PROCESS, (id_t)tid);
|
||||
if (before == -1 && errno != 0) return 100;
|
||||
if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice);
|
||||
errno = 0;
|
||||
int after = getpriority(PRIO_PROCESS, (id_t)tid);
|
||||
return after == -1 && errno != 0 ? 100 : after;
|
||||
}
|
||||
@@ -3,15 +3,22 @@ package com.droiddeck.launcher
|
||||
import android.app.Application
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.session.CrashHandler
|
||||
import com.droiddeck.launcher.session.GpuClockPin
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's
|
||||
* sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs: a new install's first read decides it.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
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 compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
|
||||
@@ -10,6 +10,10 @@ import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import com.droiddeck.launcher.gpu.DriverBundle
|
||||
import com.droiddeck.launcher.gpu.DriverPairs
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
@@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
import com.droiddeck.launcher.ui.GpuDriversState
|
||||
import com.droiddeck.launcher.ui.PairRow
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and
|
||||
* - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the
|
||||
* Linux (runtime) and Android (display) lists as before, each picked on its own - except a
|
||||
* bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher
|
||||
* screen keeps one and hands it to the Components page's GPU drivers tab.
|
||||
*/
|
||||
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
|
||||
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
@@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
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 linuxSelected by mutableStateOf("")
|
||||
val gpu: GpuInfo = GpuInfo.detect()
|
||||
// Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made.
|
||||
var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO)
|
||||
var pairRows by mutableStateOf<List<PairRow>>(emptyList())
|
||||
/** The pair being installed, and how far along. */
|
||||
var pairBusy by mutableStateOf<String?>(null)
|
||||
var pairPercent by mutableIntStateOf(-1)
|
||||
/** What Auto last did or found, for the line under the pair in use. */
|
||||
var autoStatus by mutableStateOf("")
|
||||
/** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */
|
||||
var activeBundle by mutableStateOf<String?>(null)
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
)
|
||||
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
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else DriverRow(
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
|
||||
@@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
true, origin(id),
|
||||
)
|
||||
}
|
||||
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
|
||||
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
|
||||
linuxSelected = SessionPrefs.linuxDriver(activity)
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
@@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
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)))
|
||||
for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
}
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
activeBundle = DriverBundle.active(activity)?.label
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
}
|
||||
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
fun refreshPairs() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val recommended = DriverPairs.recommendedKey(gpu, pairs)
|
||||
pairRows = pairs.map { p ->
|
||||
val bundle = p.bundle?.let { installedBundle(it) }
|
||||
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
|
||||
val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) }
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId,
|
||||
)
|
||||
}.sortedByDescending { it.recommended }
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean) {
|
||||
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
SessionPrefs.setGpuDriverMode(activity, mode)
|
||||
autoStatus = ""
|
||||
if (auto) ensureAuto(force = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto: make the recommended pair for this GPU the one installed and set. Looks online when
|
||||
* [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and
|
||||
* a new pair a day is plenty - otherwise works from the last check. Once per app start on its
|
||||
* own; the pair it replaces is removed, anything the user imported or picked is left alone.
|
||||
*/
|
||||
fun ensureAuto(force: Boolean) {
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val key = DriverPairs.recommendedKey(gpu, pairs)
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
}, "gpu-driver-auto").start()
|
||||
}
|
||||
|
||||
/** Manual: install (if need be) and set both halves of a pair. */
|
||||
fun selectPair(key: String) {
|
||||
if (pairBusy != null) return
|
||||
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
|
||||
installPair(pair, auto = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download what is missing of [pair], then set both halves - only once both are in, so a
|
||||
* failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the
|
||||
* ones the new pair replaces are removed.
|
||||
*/
|
||||
private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) {
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
var downloaded = false
|
||||
val result = runCatching {
|
||||
val zips = pair.assets
|
||||
val ids = zips.mapIndexed { i, asset ->
|
||||
installedIdOf(asset)
|
||||
?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true }
|
||||
}
|
||||
if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids
|
||||
}
|
||||
ui.post {
|
||||
pairBusy = null
|
||||
pairPercent = -1
|
||||
result.onSuccess { (displayId, linuxId) ->
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "gpu-driver-pair").start()
|
||||
}
|
||||
|
||||
/** The bundle a release bundle was downloaded as, while both its halves are installed. */
|
||||
private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? =
|
||||
TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) }
|
||||
|
||||
/** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */
|
||||
private fun installedIdOf(asset: TurnipReleases.Asset): String? = when {
|
||||
asset.bundle -> installedBundle(asset)?.id
|
||||
asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled)
|
||||
else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled)
|
||||
}
|
||||
|
||||
/**
|
||||
* One release asset, downloaded, checked and installed through the importer; returns its id,
|
||||
* a bundle's id for a bundle.
|
||||
*/
|
||||
private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String {
|
||||
var file: File? = null
|
||||
try {
|
||||
file = TurnipReleases.download(activity, asset, progress)
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = when {
|
||||
asset.bundle -> DriverBundle.install(activity, uri).id
|
||||
asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else -> TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
}
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
return id
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* library - then say what happened. A refusal's message is the user-facing reason. A bundle,
|
||||
* from any import, installs both halves and is set as both drivers at once. [linux] null (the
|
||||
* tab's own Import .zip) takes a single driver to whichever list its libc belongs in.
|
||||
*/
|
||||
fun importDriver(uri: Uri, linux: Boolean) {
|
||||
fun importDriver(uri: Uri, linux: Boolean?) {
|
||||
val name = activity.displayNameOf(uri)
|
||||
Thread({
|
||||
var bundle: DriverBundle.Bundle? = null
|
||||
val problem = try {
|
||||
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
else TurnipDriver(activity).installFromZip(uri, name)
|
||||
when {
|
||||
DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri)
|
||||
linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
linux == false -> TurnipDriver(activity).installFromZip(uri, name)
|
||||
else -> try {
|
||||
TurnipDriver(activity).installFromZip(uri, name)
|
||||
} catch (display: IllegalArgumentException) {
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
@@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
"Import failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Imported ${name ?: "driver"}",
|
||||
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "import-driver").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half
|
||||
* sets the bundle's other half in the other list as well.
|
||||
*/
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
setMode(false)
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */
|
||||
private fun useBundle(bundle: DriverBundle.Bundle): String {
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
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, "")
|
||||
}
|
||||
if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "")
|
||||
} else {
|
||||
val td = TurnipDriver(activity)
|
||||
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
|
||||
@@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** 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 }
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == 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])
|
||||
@@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip and WinNative…"
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
@@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
@@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
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)
|
||||
installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } }
|
||||
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)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import android.Manifest
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Intent
|
||||
@@ -27,7 +28,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
@@ -100,11 +101,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var percent by mutableIntStateOf(-1)
|
||||
private var failed by mutableStateOf(false)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
|
||||
@@ -132,6 +135,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var zinkLazy by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(true)
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(null)
|
||||
@@ -157,6 +161,12 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
|
||||
}
|
||||
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
|
||||
}
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
@@ -166,6 +176,26 @@ class MainActivity : ComponentActivity() {
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
}
|
||||
|
||||
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
ui.post { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
ui.post {
|
||||
lossless = state
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
|
||||
private fun importSaves(name: String, game: () -> GameSaves.Game) {
|
||||
if (SessionState.running) {
|
||||
@@ -235,6 +265,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
@@ -317,6 +348,7 @@ class MainActivity : ComponentActivity() {
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
@@ -335,8 +367,8 @@ class MainActivity : ComponentActivity() {
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
|
||||
lsfgReady = LsfgNative.isInstalled(this),
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
isHomeApp = homeAppSelected,
|
||||
@@ -412,10 +444,13 @@ class MainActivity : ComponentActivity() {
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
onRuntime = { onRuntimeButton() },
|
||||
onFrameGenPick = { engine, multiplier ->
|
||||
FrameGen.set(this, engine, multiplier)
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this, mode)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onProtons = { openProtons() },
|
||||
onComponents = { focusContent -> openComponents(focusContent) },
|
||||
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
|
||||
@@ -441,6 +476,16 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}, "share-logs").start()
|
||||
},
|
||||
onClearLogs = {
|
||||
Thread({
|
||||
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
|
||||
ui.post {
|
||||
android.widget.Toast.makeText(this,
|
||||
if (cleared == 0) "No session logs to clear." else "Cleared $cleared session log folder${if (cleared == 1) "" else "s"}.",
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "clear-logs").start()
|
||||
},
|
||||
onOffline = {
|
||||
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
|
||||
offline = OfflineMode.enabled(this)
|
||||
@@ -566,6 +611,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
|
||||
|
||||
@@ -589,6 +635,7 @@ class MainActivity : ComponentActivity() {
|
||||
if (!SessionState.running) Thread({
|
||||
SessionArtifacts.finishAbandoned(this)
|
||||
SessionArtifacts.scrubOlder(this)
|
||||
SessionArtifacts.prune(this)
|
||||
}, "finish-abandoned").start()
|
||||
}
|
||||
|
||||
@@ -625,11 +672,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = ComponentsManager.COMPONENTS
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
|
||||
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
|
||||
}
|
||||
@@ -646,8 +693,11 @@ class MainActivity : ComponentActivity() {
|
||||
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
|
||||
}
|
||||
refreshPhantomStatus()
|
||||
syncLossless()
|
||||
// Opening the app and coming back from a session both land here.
|
||||
updates.onResume()
|
||||
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
|
||||
drivers.ensureAuto(force = false)
|
||||
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
|
||||
// session has just ended). Cheap when nothing is queued.
|
||||
if (!SessionState.running) Thread({
|
||||
@@ -774,14 +824,16 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true) {
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
|
||||
focusComponentsContent = focusContent
|
||||
if (tab != null) components.compComp = tab
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showMapping = false
|
||||
showComponents = true
|
||||
components.refreshComponents(snapshotFirst = true)
|
||||
drivers.refreshDrivers()
|
||||
}
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
@@ -816,6 +868,22 @@ class MainActivity : ComponentActivity() {
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
|
||||
onBack = { showComponents = false },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
onAuto = { on -> drivers.setMode(on) },
|
||||
onPair = { key -> drivers.selectPair(key) },
|
||||
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
|
||||
// Picking one driver on its own is Manual: Auto would put its pair back.
|
||||
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
|
||||
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -882,10 +950,8 @@ class MainActivity : ComponentActivity() {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
hdr = hdrOn, hdrReason = hdrReason,
|
||||
linuxRows = drivers.linuxRows,
|
||||
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
|
||||
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
@@ -901,13 +967,12 @@ class MainActivity : ComponentActivity() {
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
|
||||
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
@@ -918,15 +983,8 @@ class MainActivity : ComponentActivity() {
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRefreshReleases = { drivers.checkLatestTurnip() },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
@@ -959,6 +1017,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamDeckMode = on
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
},
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
@@ -992,6 +1051,7 @@ class MainActivity : ComponentActivity() {
|
||||
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
|
||||
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
|
||||
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
|
||||
@@ -1000,6 +1060,7 @@ class MainActivity : ComponentActivity() {
|
||||
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
|
||||
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
|
||||
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
|
||||
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
|
||||
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
|
||||
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
|
||||
onClientCore = { core, on ->
|
||||
@@ -1045,12 +1106,14 @@ class MainActivity : ComponentActivity() {
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
refreshAddedGames()
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
@@ -1097,6 +1160,7 @@ class MainActivity : ComponentActivity() {
|
||||
zinkLazy = SessionPrefs.zinkLazy(this)
|
||||
noXalia = SessionPrefs.noXalia(this)
|
||||
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
guestHostname = SessionPrefs.guestHostname(this)
|
||||
refreshPhantomStatus()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.content.Intent
|
||||
@@ -23,6 +24,7 @@ import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -32,9 +34,10 @@ 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.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
@@ -95,7 +98,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
|
||||
|
||||
/**
|
||||
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
|
||||
* it starts. The ring behind the Deck (libfakeinput's /dev/hidraw16) opens on the pad's first
|
||||
* input otherwise - on a phone with nothing attached, the first touch of the on-screen
|
||||
* controls, seconds after the client had looked, found no controller and never set the touch
|
||||
* mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a
|
||||
* Deck instead.
|
||||
*/
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
false -> ", ring NOT open"
|
||||
})
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
@@ -169,9 +190,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
|
||||
private var frameGenMultiplier by mutableStateOf(2)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
@@ -186,7 +209,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
followPanelRefresh(displayId)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's rate, followed for the rest of the session. It is read once at start, and a
|
||||
* panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode
|
||||
* asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and
|
||||
* its games paced against a vblank that no longer came. (WinNative 53836ca9.)
|
||||
*/
|
||||
private fun followPanelRefresh(displayId: Int) {
|
||||
if (!CompositorHost.isStarted) return
|
||||
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
|
||||
if (display.displayId != displayId) return
|
||||
val hz = display.refreshRate
|
||||
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
|
||||
}
|
||||
private var isHomeApp by mutableStateOf(false)
|
||||
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
|
||||
@@ -197,15 +237,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* press, and a user without one must not be left with no way to answer Big Picture.
|
||||
*/
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls()
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
|
||||
SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
|
||||
// A Flatpak app or AppImage is named by its arguments, which the name lookup reads here.
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
|
||||
}
|
||||
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
@@ -215,9 +264,15 @@ 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: 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))
|
||||
// Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap
|
||||
// divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device
|
||||
// says why. The cap is the session's: an activity re-created mid-session keeps it.
|
||||
if (!SessionState.running) {
|
||||
SessionState.fpsLimit = SessionPrefs.fpsLimit(
|
||||
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
// PulseAudio are media playback); they are never forwarded to the guest.
|
||||
@@ -241,6 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
}
|
||||
SessionState.deckPadListener = deckPadListener
|
||||
openDeckRing("activity created")
|
||||
// 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 {
|
||||
@@ -315,6 +371,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
isFocusableInTouchMode = true
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
@@ -336,6 +393,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Opening the drawer takes the controller away from the game: release its pad.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
padBridge?.releaseAll()
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val requested = sessionOverlay.requestFocus()
|
||||
@@ -359,8 +417,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
|
||||
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
@@ -369,11 +427,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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)
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
applyFrameGen()
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(
|
||||
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
@@ -445,6 +507,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshHomeApp() {
|
||||
@@ -551,7 +614,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun shareCurrentSessionLogs() {
|
||||
val folder = SessionPaths.current()
|
||||
// Once a session has ended its folder has been handed on (SessionPaths.take), and the
|
||||
// ended screen's Share logs is for that one.
|
||||
val folder = SessionPaths.current() ?: SessionPaths.lastEnded()
|
||||
if (folder == null || !folder.isDirectory) {
|
||||
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
@@ -578,9 +643,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
frameGenEngine = FrameGen.engine(this)
|
||||
frameGenMultiplier = FrameGen.multiplier(this)
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
@@ -775,7 +838,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
CompositorHost.startOrAttach(
|
||||
holder.surface, runtimeDir.path,
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
|
||||
)
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
@@ -884,10 +947,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hz = refreshHz()
|
||||
Thread({
|
||||
val problem = FrameGen.apply(this, hz)
|
||||
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "frame-gen").start()
|
||||
}
|
||||
|
||||
/** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
// ── Session state ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Keeps the loading overlay current from the session log, and its clock moving. */
|
||||
@@ -1256,6 +1344,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
*/
|
||||
private fun syncClientCursor(): Boolean {
|
||||
if (!CompositorHost.isStarted) return false
|
||||
// Asked on every pointer move; the image is copied only when the client has changed it.
|
||||
if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false
|
||||
val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf)
|
||||
if (n < 6) return false
|
||||
val serial = cursorBuf[0]
|
||||
@@ -1466,6 +1556,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
|
||||
/** Which controller the session has, so a pad report says what was plugged in and when. */
|
||||
private fun logInputDevice(what: String, deviceId: Int) {
|
||||
val d = InputDevice.getDevice(deviceId) ?: return
|
||||
if (!PadBridge.isFromController(d)) return
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "controller %s: \"%s\" (%04x:%04x, id %d, sources 0x%x)",
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
|
||||
@@ -91,6 +91,7 @@ internal class UpdatesMenu(private val activity: Activity, private val ui: Handl
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
|
||||
AppUpdates.installBlock(r)?.let { error = it; return }
|
||||
if (SessionState.running) { error = "Stop the running session first, then update"; return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
|
||||
@@ -113,19 +113,24 @@ object DeviceReport {
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
|
||||
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: ""))
|
||||
val turnip = TurnipDriver(context)
|
||||
val androidChoice = SessionPrefs.androidDriver(context)
|
||||
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
|
||||
k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim())
|
||||
k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", "))
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
val id = SessionPrefs.linuxDriver(context, m)
|
||||
run {
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)"
|
||||
else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() +
|
||||
lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } +
|
||||
if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]"
|
||||
k("Linux driver ($m)", label)
|
||||
k("Linux driver", label)
|
||||
}
|
||||
k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path)
|
||||
|
||||
@@ -169,7 +174,7 @@ object DeviceReport {
|
||||
|
||||
h("Device switch files in Download")
|
||||
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
|
||||
"droiddeck-osc", "droiddeck-no-pad", "droiddeck-pad-log",
|
||||
"droiddeck-osc", "droiddeck-no-pad",
|
||||
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
|
||||
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
|
||||
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.util.zip.CRC32
|
||||
/** Windows game folders shared with the Steam session. */
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
private const val LIBRARY = "/mnt/droiddeck-sd"
|
||||
private const val LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
@@ -65,7 +67,7 @@ object AddedGames {
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/")
|
||||
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -108,7 +110,8 @@ object AddedGames {
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* An all-in-one driver package: one zip carrying both halves of a matched pair, the display
|
||||
* (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another,
|
||||
* indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers):
|
||||
*
|
||||
* ```
|
||||
* manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}}
|
||||
* android/ meta.json (AdrenoTools) + libvulkan_freedreno.so
|
||||
* linux/ meta.json + libvulkan_freedreno.so
|
||||
* ```
|
||||
*
|
||||
* Installing one puts each half through its own importer, with that importer's checks, and
|
||||
* remembers the two as one bundle: picking either half sets both, and deleting either deletes both,
|
||||
* so a bundle is always used whole. Zips of the older kind, one driver each, never reach this.
|
||||
*/
|
||||
object DriverBundle {
|
||||
private const val TAG = "DriverBundle"
|
||||
private const val PREFS = "driver_bundles"
|
||||
private const val MANIFEST = "manifest.json"
|
||||
/** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */
|
||||
private const val MAX_BYTES = 512L shl 20
|
||||
|
||||
class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String)
|
||||
|
||||
/** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */
|
||||
class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) {
|
||||
/** "DD-Turnip 0.1.0". */
|
||||
val label: String get() = if (version.isEmpty()) name else "$name $version"
|
||||
}
|
||||
|
||||
/** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */
|
||||
fun parseManifest(json: JSONObject): Manifest? {
|
||||
val drivers = json.optJSONObject("drivers") ?: return null
|
||||
val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null
|
||||
val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null
|
||||
if (android == linux) return null
|
||||
return Manifest(
|
||||
json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"),
|
||||
json.optString("tag"), android, linux,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanPath(path: String): String? {
|
||||
val p = path.trim().trim('/')
|
||||
return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } }
|
||||
}
|
||||
|
||||
/** True when the zip at [uri] is a bundle rather than a single driver. */
|
||||
fun isBundle(context: Context, uri: Uri): Boolean = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST }
|
||||
?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "reading $uri as a bundle", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes
|
||||
* the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip
|
||||
* is not a usable bundle, [IOException] on read failures.
|
||||
*/
|
||||
fun install(context: Context, uri: Uri): Bundle {
|
||||
val work = File(context.cacheDir, "bundle-${System.nanoTime()}")
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
var androidId: String? = null
|
||||
try {
|
||||
unzip(context, uri, work)
|
||||
val manifest = File(work, MANIFEST).takeIf { it.isFile }
|
||||
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
|
||||
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
|
||||
val androidDir = File(work, manifest.androidPath)
|
||||
val linuxDir = File(work, manifest.linuxPath)
|
||||
|
||||
val androidStage = td.newStagingDir()
|
||||
androidId = try {
|
||||
androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) }
|
||||
td.adopt(androidStage, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(androidStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val linuxStage = lm.newStagingDir()
|
||||
val linuxId = try {
|
||||
val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") }
|
||||
library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME))
|
||||
val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() }
|
||||
lm.adopt(linuxStage, library?.name, meta, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(linuxStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" })
|
||||
val saved = registry(context)
|
||||
var id = base
|
||||
var n = 2
|
||||
while (saved.contains(id)) id = "$base-${n++}"
|
||||
val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId)
|
||||
saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version)
|
||||
.put("android", androidId).put("linux", linuxId).toString()).apply()
|
||||
Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId")
|
||||
return bundle
|
||||
} catch (e: Exception) {
|
||||
androidId?.let { td.remove(it) }
|
||||
throw e
|
||||
} finally {
|
||||
FileUtils.delete(work)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unzip(context: Context, uri: Uri, dest: File) {
|
||||
val root = dest.canonicalFile
|
||||
var total = 0L
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
for (entry in generateSequence { zip.nextEntry }) {
|
||||
if (entry.isDirectory) continue
|
||||
val out = File(root, entry.name).canonicalFile
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { sink ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val r = zip.read(buf)
|
||||
if (r <= 0) break
|
||||
total += r
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
|
||||
sink.write(buf, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("cannot open $uri")
|
||||
}
|
||||
|
||||
/** Every installed bundle whose two halves are both still there. */
|
||||
fun all(context: Context): List<Bundle> {
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
return registry(context).all.mapNotNull { (id, raw) ->
|
||||
runCatching {
|
||||
val j = JSONObject(raw as String)
|
||||
Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux"))
|
||||
}.getOrNull()
|
||||
}.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id }
|
||||
}
|
||||
|
||||
fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id }
|
||||
|
||||
/** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */
|
||||
fun containing(context: Context, driverId: String, linux: Boolean): Bundle? =
|
||||
all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId }
|
||||
|
||||
/** The bundle both drivers are set to right now, if they are one. */
|
||||
fun active(context: Context): Bundle? {
|
||||
val android = SessionPrefs.androidDriver(context)
|
||||
val linux = SessionPrefs.linuxDriver(context)
|
||||
return all(context).firstOrNull { it.androidId == android && it.linuxId == linux }
|
||||
}
|
||||
|
||||
/** Set both halves as the drivers in use. */
|
||||
fun select(context: Context, bundle: Bundle) {
|
||||
SessionPrefs.setAndroidDriver(context, bundle.androidId)
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
/** Delete both halves; a driver still set to either goes back to its default. */
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "")
|
||||
registry(context).edit().remove(bundle.id).apply()
|
||||
Log.i(TAG, "removed bundle ${bundle.id}")
|
||||
}
|
||||
|
||||
private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo.Family
|
||||
|
||||
/**
|
||||
* The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux
|
||||
* build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a
|
||||
* patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app
|
||||
* installs and selects both halves of one pair at once.
|
||||
*
|
||||
* - Banners-Turnip publishes each variant as a set in every release (`Turnip-<tag><variant>.zip`
|
||||
* and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the
|
||||
* newest release that carries both halves.
|
||||
* - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with
|
||||
* their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance).
|
||||
* - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the
|
||||
* newest bundle, and it can never be mixed.
|
||||
*/
|
||||
object DriverPairs {
|
||||
const val BANNER = "banner"
|
||||
const val BANNER_A8XX = "banner-a8xx"
|
||||
const val BANNER_710 = "banner-710-720-test"
|
||||
const val BANNER_ONEUI = "banner-8g2-oneui"
|
||||
const val WN_BALANCED = "wn-b"
|
||||
const val WN_PERFORMANCE = "wn-p"
|
||||
const val DD_TURNIP = "dd-turnip"
|
||||
/** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */
|
||||
fun ddTurnip(tag: String) = "$DD_TURNIP:$tag"
|
||||
|
||||
class DriverPair(
|
||||
val key: String,
|
||||
/** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */
|
||||
val name: String,
|
||||
/** The families the pair is built for; the list only offers it on those unless asked. */
|
||||
val families: Set<Family>,
|
||||
val display: TurnipReleases.Asset?,
|
||||
val linux: TurnipReleases.Asset?,
|
||||
/** Both halves in one zip, instead of [display] and [linux]. */
|
||||
val bundle: TurnipReleases.Asset? = null,
|
||||
) {
|
||||
val complete: Boolean get() = bundle != null || (display != null && linux != null)
|
||||
/** The zips to download for the pair: the bundle, or the two halves. */
|
||||
val assets: List<TurnipReleases.Asset> get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null)
|
||||
/** "r4 · 1.19 + 0.1.2": the build of each half. */
|
||||
val version: String
|
||||
get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") }
|
||||
fun suits(gpu: GpuInfo) = gpu.family in families ||
|
||||
(gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families))
|
||||
}
|
||||
|
||||
private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN)
|
||||
|
||||
private fun describe(key: String): Pair<String, Set<Family>> = when {
|
||||
key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO
|
||||
else -> describeFixed(key)
|
||||
}
|
||||
|
||||
private fun describeFixed(key: String): Pair<String, Set<Family>> = when (key) {
|
||||
BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX)
|
||||
BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX)
|
||||
BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW)
|
||||
BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX)
|
||||
WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO
|
||||
WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO
|
||||
else -> key to ALL_ADRENO
|
||||
}
|
||||
|
||||
/** Every pair the last release check found, in the order the list shows them. */
|
||||
fun from(check: TurnipReleases.Check?): List<DriverPair> {
|
||||
val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() }
|
||||
val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE)
|
||||
return assets.map { it.pair }.distinct()
|
||||
.sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } }
|
||||
.map { key ->
|
||||
val (name, families) = describe(key)
|
||||
val halves = assets.filter { it.pair == key }
|
||||
halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) }
|
||||
// The list keeps the newest release of each half; for Banners-Turnip the two halves
|
||||
// must come from the same release, so a set missing one half in its newest release
|
||||
// is offered incomplete rather than mixed with an older one.
|
||||
val display = halves.firstOrNull { !it.linux }
|
||||
val linux = halves.firstOrNull { it.linux }
|
||||
val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag
|
||||
DriverPair(key, name, families, display, if (sameRelease) linux else null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest
|
||||
* official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other
|
||||
* 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip
|
||||
* release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for
|
||||
* the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno).
|
||||
*/
|
||||
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>): String? = when (gpu.family) {
|
||||
Family.NOT_ADRENO -> null
|
||||
Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED
|
||||
Family.A7XX_LOW -> BANNER_710
|
||||
Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER
|
||||
Family.A6XX -> BANNER
|
||||
// KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one.
|
||||
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
|
||||
}
|
||||
|
||||
/** The complete DD-Turnip pair with the highest version, or null when none is listed. */
|
||||
fun newestDdTurnip(pairs: List<DriverPair>): String? = pairs
|
||||
.filter { it.key.startsWith("$DD_TURNIP:") && it.complete }
|
||||
.maxByOrNull { p ->
|
||||
Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured
|
||||
?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L
|
||||
}?.key
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
/**
|
||||
@@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
*
|
||||
* Both engines generate on gamescope's final output inside our compositor, so they work for any
|
||||
* game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the
|
||||
* shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor
|
||||
* shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor
|
||||
* setter is a value store on the native side, so this can be applied before the compositor is
|
||||
* up and changed live from the main screen while a session runs.
|
||||
*/
|
||||
@@ -26,62 +27,77 @@ object FrameGen {
|
||||
private const val FLOW_SCALE_WINFG = 0.60f
|
||||
private const val FLOW_SCALE_LSFG = 0.80f
|
||||
|
||||
/** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */
|
||||
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
|
||||
|
||||
private const val PREFS = "frame_gen"
|
||||
|
||||
fun engine(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
|
||||
fun multiplier(context: Context): Int =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4)
|
||||
|
||||
fun set(context: Context, engine: String, multiplier: Int) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("engine", engine).putInt("multiplier", multiplier).apply()
|
||||
/** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */
|
||||
data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) {
|
||||
companion object {
|
||||
val OFF = Mode(ENGINE_OFF)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
|
||||
fun label(context: Context): String = label(engine(context), multiplier(context))
|
||||
fun engine(context: Context): String = mode(context).engine
|
||||
|
||||
fun label(engine: String, multiplier: Int): String = when (engine) {
|
||||
ENGINE_WINFG -> "Win-FG $multiplier×"
|
||||
ENGINE_LSFG -> "LSFG $multiplier×"
|
||||
else -> "Off"
|
||||
fun mode(context: Context): Mode {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
val target = prefs.getInt("target", 0)
|
||||
if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target)
|
||||
return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4))
|
||||
}
|
||||
|
||||
fun set(context: Context, mode: Mode) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply()
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */
|
||||
fun label(context: Context): String = label(context, mode(context))
|
||||
|
||||
fun label(context: Context, mode: Mode): String = when {
|
||||
mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier)
|
||||
mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target)
|
||||
mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier)
|
||||
else -> context.getString(R.string.frame_gen_off)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the saved setting into the compositor. Returns a message for the user when the
|
||||
* engine cannot run (LSFG without Lossless Scaling), or null.
|
||||
* engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated
|
||||
* Lossless.dll is prepared.
|
||||
*/
|
||||
fun apply(context: Context, refreshHz: Float): String? {
|
||||
val engine = engine(context)
|
||||
val multiplier = multiplier(context)
|
||||
var problem: String? = null
|
||||
when (engine) {
|
||||
val mode = mode(context)
|
||||
when (mode.engine) {
|
||||
ENGINE_LSFG -> {
|
||||
val status = LsfgNative.ensureCache(context)
|
||||
if (status != LsfgNative.STATUS_OK) {
|
||||
problem = if (status == LsfgNative.STATUS_NOT_INSTALLED)
|
||||
"LSFG needs Lossless Scaling installed from the Steam client"
|
||||
else "LSFG: " + LsfgNative.statusName(status)
|
||||
Log.w(TAG, problem)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
val status = Lossless.sync(context)
|
||||
val cache = Lossless.cacheFile(context)
|
||||
if (status != LsfgNative.STATUS_OK || cache == null) {
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
val problem = context.getString(
|
||||
if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed,
|
||||
)
|
||||
Log.w(TAG, "$problem (status $status)")
|
||||
return problem
|
||||
}
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG)
|
||||
WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath)
|
||||
WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target)
|
||||
}
|
||||
ENGINE_WINFG -> {
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG)
|
||||
WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
|
||||
}
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
}
|
||||
Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz")
|
||||
return problem
|
||||
Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz")
|
||||
return null
|
||||
}
|
||||
|
||||
private const val TAG = "FrameGen"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
val name: String,
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
/** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */
|
||||
A7XX_LOW("Adreno 710/720/722"),
|
||||
A7XX("Adreno 7xx"),
|
||||
A6XX("Adreno 6xx"),
|
||||
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
|
||||
ADRENO_UNKNOWN("Adreno"),
|
||||
NOT_ADRENO("Not an Adreno GPU"),
|
||||
}
|
||||
|
||||
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
|
||||
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
}
|
||||
|
||||
/** One line for the device card and the system check. */
|
||||
val supportText: String
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun detect(): GpuInfo {
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
model >= 800 -> Family.A8XX
|
||||
model in listOf(710, 720, 722) -> Family.A7XX_LOW
|
||||
model >= 700 -> Family.A7XX
|
||||
model >= 600 -> Family.A6XX
|
||||
else -> Family.ADRENO_UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager {
|
||||
* Linux driver; {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installDriver(Uri zipUri, String displayName) throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
String soName = null;
|
||||
@@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager {
|
||||
}
|
||||
}
|
||||
// The zip's own freedreno_icd.aarch64.json is dropped on purpose: its
|
||||
// library_path is relative to itself, and we write an absolute one below.
|
||||
// library_path is relative to itself, and adopt() writes an absolute one.
|
||||
}
|
||||
}
|
||||
String id = adopt(tmpDir, soName, zipMeta, displayName);
|
||||
keep = true;
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in
|
||||
* the zip), write its manifest and meta.json, and move it in. Returns the id; the caller
|
||||
* deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
|
||||
try {
|
||||
if (soName == null) {
|
||||
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
|
||||
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
|
||||
@@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager {
|
||||
if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json");
|
||||
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir);
|
||||
return id;
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IOException("manifest write failed: " + e.getMessage());
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.security.DigestOutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090).
|
||||
*
|
||||
* The copy the Steam client installs, in any library it installs to, is taken by
|
||||
* itself and taken again whenever the client updates it. Once the session has seen the account
|
||||
* own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes
|
||||
* or another is imported. The DLL is copied in and its shaders are built into a cache named after
|
||||
* its contents, so a different DLL hands the compositor a different path and its engine reloads.
|
||||
* Everything here blocks: call it off the main thread.
|
||||
*/
|
||||
object Lossless {
|
||||
enum class Source { NONE, STEAM, IMPORT }
|
||||
|
||||
enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED }
|
||||
|
||||
data class State(val owned: Boolean, val ready: Boolean, val source: Source) {
|
||||
companion object {
|
||||
val NONE = State(owned = false, ready = false, source = Source.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "Lossless"
|
||||
private const val PREFS = "lossless"
|
||||
private const val KEY_OWNED = "owned"
|
||||
private const val KEY_ID = "id"
|
||||
private const val KEY_SOURCE = "source"
|
||||
private const val KEY_STEAM_STAMP = "steamStamp"
|
||||
private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll"
|
||||
private const val UNCHANGED = -1
|
||||
|
||||
fun state(context: Context): State {
|
||||
val source = prefs(context).getString(KEY_SOURCE, null)
|
||||
?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE
|
||||
return State(owned(context), cacheFile(context)?.isFile == true, source)
|
||||
}
|
||||
|
||||
/** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */
|
||||
fun owned(context: Context): Boolean {
|
||||
if (prefs(context).getBoolean(KEY_OWNED, false)) return true
|
||||
if (!ownedFlag(context).exists()) return false
|
||||
markOwned(context)
|
||||
return true
|
||||
}
|
||||
|
||||
fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) }
|
||||
|
||||
/** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */
|
||||
@Synchronized
|
||||
fun sync(context: Context): Int {
|
||||
val steam = steamDll(context)
|
||||
if (steam != null) {
|
||||
markOwned(context)
|
||||
val stamp = "${steam.length()}:${steam.lastModified()}"
|
||||
if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) {
|
||||
val status = adopt(context, steam, Source.STEAM)
|
||||
if (status != LsfgNative.STATUS_OK && status != UNCHANGED) {
|
||||
Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}")
|
||||
}
|
||||
prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply()
|
||||
}
|
||||
}
|
||||
return ensureBuilt(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun import(context: Context, dll: File): ImportResult {
|
||||
if (!owned(context)) return ImportResult.NOT_OWNED
|
||||
return when (adopt(context, dll, Source.IMPORT)) {
|
||||
UNCHANGED -> ImportResult.UNCHANGED
|
||||
LsfgNative.STATUS_OK -> ImportResult.UPDATED
|
||||
LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE,
|
||||
LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID
|
||||
else -> ImportResult.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
fun message(context: Context, result: ImportResult): String = context.getString(
|
||||
when (result) {
|
||||
ImportResult.UPDATED -> R.string.lsfg_imported
|
||||
ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged
|
||||
ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned
|
||||
ImportResult.INVALID -> R.string.lsfg_import_invalid
|
||||
ImportResult.FAILED -> R.string.lsfg_import_failed
|
||||
},
|
||||
)
|
||||
|
||||
private fun ensureBuilt(context: Context): Int {
|
||||
val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED
|
||||
val cache = cacheFor(context, id)
|
||||
if (usable(cache)) return LsfgNative.STATUS_OK
|
||||
val dll = storedDll(context)
|
||||
if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED
|
||||
return build(dll, cache)
|
||||
}
|
||||
|
||||
private fun adopt(context: Context, source: File, from: Source): Int {
|
||||
val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged")
|
||||
try {
|
||||
val id = copyHashing(source, staged)
|
||||
if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED
|
||||
val cache = cacheFor(context, id)
|
||||
var status = LsfgNative.nativeValidateDll(staged.path)
|
||||
if (status == LsfgNative.STATUS_OK) status = build(staged, cache)
|
||||
if (status != LsfgNative.STATUS_OK) {
|
||||
cache.delete()
|
||||
return status
|
||||
}
|
||||
if (!staged.renameTo(storedDll(context))) {
|
||||
cache.delete()
|
||||
return LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
}
|
||||
prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply()
|
||||
removeStale(context, id)
|
||||
Log.i(TAG, "Lossless.dll from $source ($from) ready as $id")
|
||||
return LsfgNative.STATUS_OK
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.w(TAG, "could not copy $source", e)
|
||||
return LsfgNative.STATUS_UNREADABLE_FILE
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "could not take $source", t)
|
||||
return LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
} finally {
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(dll: File, cache: File): Int = try {
|
||||
val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true)
|
||||
Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" +
|
||||
if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "")
|
||||
status
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "shader cache ${cache.name}", t)
|
||||
LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
}
|
||||
|
||||
private fun usable(cache: File): Boolean = cache.isFile &&
|
||||
runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false)
|
||||
|
||||
private fun copyHashing(source: File, target: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
source.inputStream().use { input ->
|
||||
DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) }
|
||||
}
|
||||
return digest.digest().take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun removeStale(context: Context, keep: String) {
|
||||
File(context.filesDir, "lsfg-native").deleteRecursively()
|
||||
dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** The newest copy in any library the client installs to: its own, the card's, the Games folders. */
|
||||
private fun steamDll(context: Context): File? {
|
||||
val libraries = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
|
||||
GameStorage.effective(context)?.path?.let(::File),
|
||||
) + SessionPrefs.addedGamesDirs(context).map(::File)
|
||||
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
private fun markOwned(context: Context) {
|
||||
if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply()
|
||||
}
|
||||
|
||||
private fun statusName(status: Int): String =
|
||||
runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status")
|
||||
|
||||
private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned")
|
||||
private fun dir(context: Context) = File(context.filesDir, "lsfg")
|
||||
private fun storedDll(context: Context) = File(dir(context), "Lossless.dll")
|
||||
private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache")
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -1,99 +1,30 @@
|
||||
package com.droiddeck.launcher.gpu;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling
|
||||
* and cached on device.
|
||||
*
|
||||
* <p>The 25 compute shaders are not redistributable. They live as resources inside the user's
|
||||
* paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the
|
||||
* Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped
|
||||
* read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth
|
||||
* doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the
|
||||
* DLL's size and hash. Call {@link #ensureCache} off the main thread.
|
||||
*
|
||||
* <p>Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into
|
||||
* the compositor library.
|
||||
* The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library):
|
||||
* {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its
|
||||
* DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block.
|
||||
*/
|
||||
public final class LsfgNative {
|
||||
private static final String TAG = "LsfgNative";
|
||||
|
||||
final class LsfgNative {
|
||||
static { System.loadLibrary("bannerwayland"); }
|
||||
|
||||
private LsfgNative() {}
|
||||
|
||||
// Mirrors lsfg::DllStatus.
|
||||
public static final int STATUS_OK = 0;
|
||||
public static final int STATUS_NOT_INSTALLED = 1;
|
||||
public static final int STATUS_UNREADABLE_FILE = 2;
|
||||
public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
|
||||
public static final int STATUS_MISSING_SHADERS = 4;
|
||||
public static final int STATUS_TRANSLATION_FAILED = 5;
|
||||
public static final int STATUS_CACHE_UNUSABLE = 6;
|
||||
static final int STATUS_OK = 0;
|
||||
static final int STATUS_NOT_INSTALLED = 1;
|
||||
static final int STATUS_UNREADABLE_FILE = 2;
|
||||
static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
|
||||
static final int STATUS_MISSING_SHADERS = 4;
|
||||
static final int STATUS_TRANSLATION_FAILED = 5;
|
||||
static final int STATUS_CACHE_UNUSABLE = 6;
|
||||
|
||||
private static native int nativeValidateDll(String dllPath);
|
||||
private static native int nativeDllVariant(String dllPath, boolean preferFp16);
|
||||
private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
|
||||
private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath);
|
||||
private static native int nativeCacheVariant(String cachePath);
|
||||
private static native String nativeStatusName(int status);
|
||||
private static native String nativeVariantName(int variant);
|
||||
// Mirrors lsfg::Variant.
|
||||
static final int VARIANT_NONE = 0;
|
||||
|
||||
/** Where Lossless Scaling lands when installed from the client inside the runtime. */
|
||||
public static File losslessDll(Context context) {
|
||||
return new File(LinuxRuntime.rootDir(context),
|
||||
"root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll");
|
||||
}
|
||||
|
||||
/** Where the translated SPIR-V chain is cached. */
|
||||
public static File cacheFile(Context context) {
|
||||
return new File(context.getFilesDir(), "lsfg-native/shaders.cache");
|
||||
}
|
||||
|
||||
public static boolean isInstalled(Context context) {
|
||||
return losslessDll(context).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or
|
||||
* changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start.
|
||||
*/
|
||||
public static int ensureCache(Context context) {
|
||||
File dll = losslessDll(context);
|
||||
if (!dll.isFile()) return STATUS_NOT_INSTALLED;
|
||||
File cache = cacheFile(context);
|
||||
try {
|
||||
if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) {
|
||||
return STATUS_OK;
|
||||
}
|
||||
int status = nativeValidateDll(dll.getPath());
|
||||
if (status != STATUS_OK) {
|
||||
Log.w(TAG, "Lossless.dll rejected: " + statusName(status));
|
||||
return status;
|
||||
}
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
cache.getParentFile().mkdirs();
|
||||
status = nativeBuildCache(dll.getPath(), cache.getPath(), true);
|
||||
Log.i(TAG, "cache build: " + statusName(status)
|
||||
+ (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : ""));
|
||||
return status;
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "ensureCache", t);
|
||||
return STATUS_CACHE_UNUSABLE;
|
||||
}
|
||||
}
|
||||
|
||||
public static String statusName(int status) {
|
||||
try {
|
||||
return nativeStatusName(status);
|
||||
} catch (Throwable t) {
|
||||
return "status " + status;
|
||||
}
|
||||
}
|
||||
static native int nativeValidateDll(String dllPath);
|
||||
static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
|
||||
static native int nativeCacheVariant(String cachePath);
|
||||
static native String nativeStatusName(int status);
|
||||
static native String nativeVariantName(int variant);
|
||||
}
|
||||
@@ -179,10 +179,7 @@ public final class TurnipDriver {
|
||||
* {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installFromZip(Uri zipUri, String displayName) throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
try (InputStream is = context.getContentResolver().openInputStream(zipUri);
|
||||
@@ -198,26 +195,44 @@ public final class TurnipDriver {
|
||||
Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
String id = adopt(tmpDir, displayName);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver (meta.json and its library, flat) and move it in as a new driver.
|
||||
* Returns the id; the caller deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String displayName) throws IOException {
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
}
|
||||
|
||||
private static JSONObject readMetaFile(File metaFile) {
|
||||
try {
|
||||
return new JSONObject(FileUtils.readString(metaFile));
|
||||
|
||||
@@ -29,12 +29,20 @@ object TurnipReleases {
|
||||
|
||||
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
|
||||
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
|
||||
/**
|
||||
* [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux
|
||||
* build of the same pair are made to run together. "" for a list cached before pairs were kept.
|
||||
*/
|
||||
/** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */
|
||||
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 = "")
|
||||
val sha256: String = "", val pair: String = "", val bundle: Boolean = false)
|
||||
/** [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)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
|
||||
/** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */
|
||||
private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?)
|
||||
|
||||
private val SOURCES = listOf(
|
||||
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
|
||||
@@ -45,12 +53,14 @@ object TurnipReleases {
|
||||
if (variant.endsWith("-Wayland")) return@Source null
|
||||
val linux = variant.endsWith("-Linux")
|
||||
variant = variant.removeSuffix("-Linux")
|
||||
linux to when {
|
||||
val label = when {
|
||||
variant.isEmpty() -> "Adreno 6xx/7xx"
|
||||
variant == "-A8xx" -> "Adreno 8xx"
|
||||
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
|
||||
variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI"
|
||||
else -> variant.trimStart('-')
|
||||
}
|
||||
Kind(linux, label, "banner" + variant.lowercase())
|
||||
},
|
||||
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
|
||||
// b = Balanced, p = Performance, Axxx = every Adreno.
|
||||
@@ -58,7 +68,13 @@ object TurnipReleases {
|
||||
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
|
||||
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
|
||||
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
|
||||
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
|
||||
Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2])
|
||||
},
|
||||
// DD-Turnip-v<ver>.zip: DroidDeck's own all-in-one build, both halves in one zip, published
|
||||
// to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair.
|
||||
Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag ->
|
||||
if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null
|
||||
Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -91,14 +107,19 @@ object TurnipReleases {
|
||||
for (j in 0 until list.length()) {
|
||||
val a = list.getJSONObject(j)
|
||||
val name = a.getString("name")
|
||||
val (linux, label) = src.classify(name, tag) ?: continue
|
||||
val kind = src.classify(name, tag) ?: continue
|
||||
// A bundle is only offered from an official release, never a pre-release.
|
||||
if (kind.bundle && r.optBoolean("prerelease")) continue
|
||||
val linux = kind.linux
|
||||
val label = kind.label
|
||||
// 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
|
||||
if (!seen.add(if (kind.bundle) kind.pair else "$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("sha256", sha))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
|
||||
.put("bundle", kind.bundle))
|
||||
}
|
||||
}
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
@@ -136,7 +157,8 @@ 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.optString("sha256", "")))
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""),
|
||||
a.optBoolean("bundle", false)))
|
||||
}
|
||||
val latest = ArrayList<Pair<String, String>>()
|
||||
val l = json.optJSONArray("latest") ?: JSONArray()
|
||||
|
||||
@@ -66,6 +66,16 @@ public final class PadBridge {
|
||||
private volatile Runnable onPlayerInput;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
// What reached the pad over the last stats window, so a report of "the pad does nothing" or
|
||||
// "touch and the pad don't work together" shows in the app log whether input arrived, from
|
||||
// which device, and whether it got to the ring. Logged only for a window that had input.
|
||||
private static final long STATS_WINDOW_MS = 10_000;
|
||||
private int statButtons, statAxes, statOnScreen, statDropped;
|
||||
private String statDevice;
|
||||
private boolean statsScheduled;
|
||||
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
|
||||
private int lastDeviceId = Integer.MIN_VALUE;
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
@@ -134,6 +144,7 @@ public final class PadBridge {
|
||||
/** @return true when the event was a pad button and has been consumed. */
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
noteDevice(event.getDevice());
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
|
||||
@@ -161,6 +172,8 @@ public final class PadBridge {
|
||||
default: return false;
|
||||
}
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
@@ -169,6 +182,9 @@ public final class PadBridge {
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
noteDevice(event.getDevice());
|
||||
statAxes++;
|
||||
scheduleStats();
|
||||
state.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
|
||||
// pad reports as "down" is what the client is told.
|
||||
@@ -203,6 +219,8 @@ public final class PadBridge {
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
}
|
||||
|
||||
@@ -279,8 +297,39 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
if (seenDevices.add(device.getId())) {
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
|
||||
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
|
||||
open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleStats() {
|
||||
if (statsScheduled) return;
|
||||
statsScheduled = true;
|
||||
mainHandler.postDelayed(this::logStats, STATS_WINDOW_MS);
|
||||
}
|
||||
|
||||
private synchronized void logStats() {
|
||||
statsScheduled = false;
|
||||
Log.i(TAG, "[input] last 10 s: pad " + statButtons + " buttons, " + statAxes + " axis events"
|
||||
+ (statDevice != null ? " (\"" + statDevice + "\")" : "")
|
||||
+ ", on-screen " + statOnScreen
|
||||
+ (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "")
|
||||
+ "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad());
|
||||
statButtons = statAxes = statOnScreen = statDropped = 0;
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) return;
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
boolean guide = systemGuidePressed || qamChordActive;
|
||||
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
|
||||
if (!guide && !qam) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
|
||||
@@ -16,13 +17,15 @@ 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
|
||||
* says also goes to Download/DroidDeck/tools/<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 }
|
||||
try {
|
||||
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
|
||||
@@ -18,11 +18,15 @@ import kotlin.math.abs
|
||||
* from Android's own battery API - the same on every device - into a directory the session binds
|
||||
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
|
||||
*
|
||||
* Steam's Quick Access Menu takes its time estimates from neither: it reads the files in `/run/vpower` (as on
|
||||
* SteamOS), and with those absent showed "?h ?m" for projected battery life and for time to full.
|
||||
* The same estimates are written there too, into [vpowerDir] in the guest's own `/run`.
|
||||
*
|
||||
* Values: charge in µAh from the charge counter; energy in µWh from charge × voltage; power in µW
|
||||
* from |current| × voltage; time to empty from charge ÷ |average current| while discharging. Where
|
||||
* a device reports no current, the client still gets status and percentage.
|
||||
*/
|
||||
class BatteryComponent(val dir: File) : SessionPart() {
|
||||
class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
|
||||
@@ -42,6 +46,8 @@ class BatteryComponent(val dir: File) : SessionPart() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
// Stale estimates would outlive the session in the rootfs; Steam falls back to "?h ?m".
|
||||
for (name in VPOWER_FILES) File(vpowerDir, name).delete()
|
||||
}
|
||||
|
||||
private fun write() {
|
||||
@@ -115,6 +121,17 @@ class BatteryComponent(val dir: File) : SessionPart() {
|
||||
for ((k, v) in attrs) uevent.append("POWER_SUPPLY_").append(k.uppercase()).append('=').append(v).append('\n')
|
||||
put(File(bat, "uevent"), uevent.toString())
|
||||
}
|
||||
val plugged = (sticky?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0) != 0
|
||||
vpowerDir.mkdirs()
|
||||
val vpower = linkedMapOf(
|
||||
"ac_status" to if (plugged) "Connected" else "Disconnected",
|
||||
"battery_percent" to "%.2f".format(java.util.Locale.ROOT, pct.toDouble()),
|
||||
"battery_status" to statusText,
|
||||
// Steam reads a separate file for each direction; -1 = no estimate.
|
||||
"secs_until_battery_full" to (if (timeToFullS > 0) timeToFullS else -1L).toString(),
|
||||
"secs_until_shutdown_request" to (if (timeToEmptyS > 0) timeToEmptyS else -1L).toString(),
|
||||
)
|
||||
for ((k, v) in vpower) put(File(vpowerDir, k), v + "\n")
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "battery: could not write", t)
|
||||
}
|
||||
@@ -130,5 +147,9 @@ class BatteryComponent(val dir: File) : SessionPart() {
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
private const val PERIOD_MS = 5_000L
|
||||
private val VPOWER_FILES = listOf(
|
||||
"ac_status", "battery_percent", "battery_status",
|
||||
"secs_until_battery_full", "secs_until_shutdown_request",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The GPU held at its top clock for a session, without root: KGSL's power-control property turned
|
||||
* off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the
|
||||
* governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch;
|
||||
* Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat,
|
||||
* so it is opt-in, and the kernel's thermal limits still apply on top of it.
|
||||
*
|
||||
* The property belongs to the device, not to this process, and a killed app cannot unwind it: it
|
||||
* is cleared when a session stops, and at the next app start when a marker file says this app set
|
||||
* it and never cleared it - only then, so another app's own setting is not undone.
|
||||
* (Bannerlator, PerfGpuTurbo.)
|
||||
*/
|
||||
object GpuClockPin {
|
||||
private const val TAG = "GpuClockPin"
|
||||
private const val KGSL_NODE = "/dev/kgsl-3d0"
|
||||
|
||||
private const val MARKER = "gpu-clock-pinned"
|
||||
|
||||
@Volatile
|
||||
private var pinned = false
|
||||
|
||||
private val supported: Boolean by lazy { File(KGSL_NODE).exists() }
|
||||
|
||||
/** At a session's start: pins the clock when the user asked for it. The line is for the log. */
|
||||
@Synchronized
|
||||
fun start(context: Context): String {
|
||||
if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed"
|
||||
if (!supported) return "gpu clock: no $KGSL_NODE, left governed"
|
||||
return try {
|
||||
File(context.filesDir, MARKER).createNewFile()
|
||||
WaylandCompositor.nativeSetGpuTurbo(true)
|
||||
pinned = true
|
||||
"gpu clock: held at its top"
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "pin", t)
|
||||
"gpu clock: pin failed"
|
||||
}
|
||||
}
|
||||
|
||||
/** At a session's end. */
|
||||
@Synchronized
|
||||
fun stop(context: Context) {
|
||||
if (pinned) clear(context)
|
||||
}
|
||||
|
||||
/** At the app's start: a process killed with the clock pinned left only the marker behind. */
|
||||
@Synchronized
|
||||
fun clearLeftover(context: Context) {
|
||||
if (File(context.filesDir, MARKER).exists()) clear(context)
|
||||
}
|
||||
|
||||
private fun clear(context: Context) {
|
||||
if (supported) {
|
||||
try {
|
||||
WaylandCompositor.nativeSetGpuTurbo(false)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "clear", t)
|
||||
}
|
||||
}
|
||||
pinned = false
|
||||
File(context.filesDir, MARKER).delete()
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import android.os.SystemClock
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
@@ -86,20 +88,22 @@ class PerfHud(context: Context) {
|
||||
}
|
||||
|
||||
private fun line(base: Float): String {
|
||||
val engine = FrameGen.engine(context)
|
||||
if (engine == FrameGen.ENGINE_OFF) return fps(base)
|
||||
val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG"
|
||||
val multiplier = FrameGen.multiplier(context)
|
||||
val mode = FrameGen.mode(context)
|
||||
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
|
||||
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
|
||||
val name = if (lsfg) "LSFG" else "Win-FG"
|
||||
val label = FrameGen.label(context, mode)
|
||||
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
|
||||
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
|
||||
val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 }
|
||||
return when {
|
||||
problem == 1 -> "$name: unsupported"
|
||||
problem == 2 -> "$name: engine failed"
|
||||
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
|
||||
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
|
||||
stats != null && stats[3] > 1f -> {
|
||||
val source = if (stats[2] > 1f) stats[2] else base
|
||||
String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3])
|
||||
String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3])
|
||||
}
|
||||
else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base))
|
||||
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import android.util.Log
|
||||
* 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;
|
||||
* 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode
|
||||
* the cap divides evenly, so every capped frame is shown for the same number of refreshes;
|
||||
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
|
||||
* its boost on loading screens or menus keeps it up.
|
||||
*
|
||||
@@ -27,7 +28,7 @@ import android.util.Log
|
||||
object PerfMode {
|
||||
private const val TAG = "PerfMode"
|
||||
|
||||
fun apply(a: Activity): String {
|
||||
fun apply(a: Activity, fpsLimit: Int = 0): String {
|
||||
val parts = ArrayList<String>(2)
|
||||
|
||||
// The fastest display mode at the panel's current size.
|
||||
@@ -35,10 +36,12 @@ object PerfMode {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay
|
||||
if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else {
|
||||
val cur = display.mode
|
||||
val best = display.supportedModes
|
||||
val sameSize = display.supportedModes
|
||||
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
|
||||
.maxByOrNull { it.refreshRate }
|
||||
if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) {
|
||||
val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate }
|
||||
?: sameSize.maxByOrNull { it.refreshRate }
|
||||
if (best != null && best.modeId != cur.modeId &&
|
||||
(best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) {
|
||||
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
|
||||
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
|
||||
} else "display ${cur.refreshRate.toInt()} Hz"
|
||||
@@ -64,4 +67,11 @@ object PerfMode {
|
||||
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */
|
||||
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
|
||||
if (fpsLimit <= 0) return true
|
||||
val ratio = hz / fpsLimit
|
||||
return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ object SessionArtifacts {
|
||||
fun scrubOlder(context: Context) {
|
||||
val current = SessionPaths.current()
|
||||
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
|
||||
f.isDirectory && f.name.startsWith("session-") && f != current && !File(f, SCRUBBED_MARKER).exists()
|
||||
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
|
||||
} ?: return
|
||||
if (dirs.isEmpty()) return
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
@@ -124,8 +124,8 @@ object SessionArtifacts {
|
||||
fun finishAbandoned(context: Context) {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
val abandoned = parent.listFiles { f ->
|
||||
f.isDirectory && f.name.startsWith("session-") && !File(f, COMPLETE_MARKER).exists()
|
||||
}?.sortedBy { it.name } ?: return
|
||||
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
|
||||
}?.sortedWith(SessionPaths.chronological) ?: return
|
||||
if (abandoned.isEmpty()) return
|
||||
val current = SessionPaths.current()
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
@@ -157,5 +157,37 @@ object SessionArtifacts {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the newest [SessionPaths.KEEP_SESSIONS] session folders and deletes the rest: a few
|
||||
* days of testing left hundreds, and the one a report needed was lost among them. Only folders
|
||||
* that are finished; the session in progress is never touched. Runs at app start after
|
||||
* [finishAbandoned], on its worker thread.
|
||||
*/
|
||||
@Synchronized
|
||||
fun prune(context: Context) {
|
||||
val current = SessionPaths.current()
|
||||
val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() }
|
||||
val old = finished.dropLast(SessionPaths.KEEP_SESSIONS)
|
||||
old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every session folder but the one in progress, and the one-off command logs (tools/, or loose
|
||||
* beside the folders from before it): the Setup page's Clear logs. Returns how many session
|
||||
* folders went.
|
||||
*/
|
||||
@Synchronized
|
||||
fun clearAll(context: Context): Int {
|
||||
val current = SessionPaths.current()
|
||||
val gone = SessionPaths.sessionFolders(context).filter { it != current }
|
||||
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
|
||||
// Before tools/, those logs sat loose beside the session folders.
|
||||
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
|
||||
Log.i(TAG, "cleared ${gone.size} session folder(s)")
|
||||
return gone.size
|
||||
}
|
||||
|
||||
private fun now(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
|
||||
}
|
||||
@@ -11,11 +11,12 @@ object SessionEvents {
|
||||
private const val TAG = "SessionEvents"
|
||||
private val lock = Any()
|
||||
|
||||
fun begin(context: Context, mode: String) {
|
||||
/** [label] names the session's folder for what is being run (SessionPaths.label). */
|
||||
fun begin(context: Context, mode: String, label: String? = null) {
|
||||
// A runtime-install failure can leave the folder claimed before the service ever starts.
|
||||
// Keep that folder, but give the next attempt its own session directory.
|
||||
SessionPaths.take()
|
||||
val dir = SessionPaths.beginOrCurrent(context)
|
||||
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
|
||||
synchronized(SessionState) {
|
||||
SessionState.phase = SessionPhase.PREPARING
|
||||
SessionState.mode = mode
|
||||
|
||||
@@ -17,10 +17,7 @@ import java.util.zip.ZipOutputStream
|
||||
*/
|
||||
object SessionLogShare {
|
||||
/** The newest session folder in either place logs are written, or null if there is none. */
|
||||
fun latest(context: Context): File? =
|
||||
listOf(LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
|
||||
.flatMap { dir -> dir.listFiles { f -> f.isDirectory && f.name.startsWith("session-") }?.toList() ?: emptyList() }
|
||||
.maxByOrNull { it.name }
|
||||
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
|
||||
|
||||
/** Builds the zip (blocking). Returns null when there is no session to share. */
|
||||
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
|
||||
@@ -36,21 +33,40 @@ object SessionLogShare {
|
||||
// session pass yet, and the redactor changes nothing in a line that is already clean.
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f ->
|
||||
z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path))
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
|
||||
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's own logs as they stand, for a session shared while it runs. The session script
|
||||
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
|
||||
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
|
||||
* touch report needs most. Only for the running session: an older folder would get this
|
||||
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
|
||||
*/
|
||||
private fun liveSteamLogs(context: Context, folder: File): List<File> {
|
||||
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
|
||||
}
|
||||
|
||||
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
|
||||
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
|
||||
fun shareIntent(context: Context, zip: File): Intent {
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
|
||||
@@ -8,18 +8,24 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded:
|
||||
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
|
||||
* named for the day, that day's session number and what was run, so the one a report is about can
|
||||
* be picked out without opening any:
|
||||
*
|
||||
* ```
|
||||
* Download/DroidDeck/session-20260921-161256/
|
||||
* Download/DroidDeck/2026-09-30-03-steam/
|
||||
* device.txt what this device is, and every setting the session ran with
|
||||
* session.log the guest session: proot, gamescope, the client's stdout
|
||||
* wayland.log the app's compositor
|
||||
* steam.log the Steam client's own log, scrubbed (Steam mode)
|
||||
* steam/ the rest of the client's logs, scrubbed (Steam mode)
|
||||
* desktop.log labwc, the panel and the programs on it (desktop mode)
|
||||
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
|
||||
* ```
|
||||
*
|
||||
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
|
||||
* pruned with the rest.
|
||||
*
|
||||
* The folder is claimed by whoever starts first - the activity starts the compositor before the
|
||||
* service starts the session - so both write into the same one, and a recreated activity (a
|
||||
* foldable opening mid-session) joins the folder in progress instead of opening another.
|
||||
@@ -27,24 +33,44 @@ import java.util.Locale
|
||||
object SessionPaths {
|
||||
private const val TAG = "SessionPaths"
|
||||
|
||||
/** How many session folders are kept; older ones go at the next app start. */
|
||||
const val KEEP_SESSIONS = 30
|
||||
|
||||
/** Beside the session folders: the logs of one-off commands (GuestCommand's logName). */
|
||||
const val TOOLS_DIR = "tools"
|
||||
|
||||
/** `2026-09-30-03-steam`: day, that day's session number, what was run. */
|
||||
private val NAMED = Regex("""^(\d{4})-(\d{2})-(\d{2})-(\d{2,})-.+$""")
|
||||
/** `session-20260930-180642`, from before. */
|
||||
private val LEGACY = Regex("""^session-(\d{8})-(\d{6})(-\d+)?$""")
|
||||
|
||||
@Volatile
|
||||
private var dir: File? = null
|
||||
|
||||
@Volatile
|
||||
private var ended: File? = null
|
||||
|
||||
/**
|
||||
* The folder for the session now starting, or the one already in progress. With logs turned
|
||||
* off on the main screen the folder lives in the app's cache instead of Downloads - the
|
||||
* scripts and the compositor still need somewhere to write - and [release] deletes it.
|
||||
* [label] says what is being run (see [label]); without one it comes from the session's state.
|
||||
*/
|
||||
@Synchronized
|
||||
fun beginOrCurrent(context: Context): File {
|
||||
fun beginOrCurrent(context: Context, label: String? = null): File {
|
||||
dir?.let { if (it.isDirectory) return it }
|
||||
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context)
|
||||
else File(context.cacheDir, "session-logs")
|
||||
val baseName = "session-$stamp"
|
||||
var made = File(parent, baseName)
|
||||
var suffix = 2
|
||||
while (made.exists()) made = File(parent, "$baseName-${suffix++}")
|
||||
val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||
// The day's highest number plus one, not a count: pruning removes the oldest folders, and
|
||||
// a count would hand out a number already in use.
|
||||
val last = parent.listFiles()?.mapNotNull { f ->
|
||||
NAMED.matchEntire(f.name)?.takeIf { f.name.startsWith("$day-") }?.groupValues?.get(4)?.toIntOrNull()
|
||||
}?.maxOrNull() ?: 0
|
||||
val what = slug(label ?: label(SessionState.mode, SessionState.program))
|
||||
var number = last + 1
|
||||
var made = File(parent, "$day-${"%02d".format(Locale.US, number)}-$what")
|
||||
while (made.exists()) made = File(parent, "$day-${"%02d".format(Locale.US, ++number)}-$what")
|
||||
if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made")
|
||||
dir = made
|
||||
Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)")
|
||||
@@ -53,6 +79,9 @@ object SessionPaths {
|
||||
|
||||
fun current(): File? = dir
|
||||
|
||||
/** The folder of the session that ended last, while it is still there: what its Share logs sends. */
|
||||
fun lastEnded(): File? = ended?.takeIf { it.isDirectory }
|
||||
|
||||
/**
|
||||
* The ending session takes its folder with it: the next session (one that replaces this one
|
||||
* in place) then claims a folder of its own instead of sharing, and this one's collector
|
||||
@@ -60,9 +89,10 @@ object SessionPaths {
|
||||
*/
|
||||
@Synchronized
|
||||
fun take(): File? {
|
||||
val ended = dir
|
||||
val taken = dir
|
||||
dir = null
|
||||
return ended
|
||||
if (taken != null) ended = taken
|
||||
return taken
|
||||
}
|
||||
|
||||
fun file(name: String): File? = dir?.let { File(it, name) }
|
||||
@@ -78,4 +108,39 @@ object SessionPaths {
|
||||
com.droiddeck.launcher.core.FileUtils.delete(ended)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a session's folder is named for: Steam, the desktop, or the program started from the
|
||||
* rail by its own name (`2026-10-01-02-retroarch`).
|
||||
*/
|
||||
fun label(mode: String, program: String?): String = when (mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(program)
|
||||
?: program?.substringAfterLast('/')
|
||||
?: mode
|
||||
SessionService.MODE_DESKTOP -> "desktop"
|
||||
else -> mode
|
||||
}
|
||||
|
||||
/** Whether [f] is a session's folder, in either naming. */
|
||||
fun isSessionFolder(f: File): Boolean =
|
||||
f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name))
|
||||
|
||||
/**
|
||||
* Oldest first: by day, then by when in the day. A folder from before the new naming sorts
|
||||
* ahead of a new one from the same day, which it always predates.
|
||||
*/
|
||||
val chronological: Comparator<File> = compareBy { f ->
|
||||
NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" }
|
||||
?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" }
|
||||
?: f.name
|
||||
}
|
||||
|
||||
/** Every session folder in the places logs are written, oldest first. */
|
||||
fun sessionFolders(context: Context): List<File> =
|
||||
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
|
||||
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
|
||||
.sortedWith(chronological)
|
||||
|
||||
internal fun slug(text: String): String =
|
||||
text.lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24).trim('-').ifEmpty { "session" }
|
||||
}
|
||||
@@ -44,12 +44,23 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
|
||||
}
|
||||
|
||||
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
|
||||
/**
|
||||
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
|
||||
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
|
||||
* in one - a choice kept apart from every other session's, where it stays on by default.
|
||||
*/
|
||||
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
|
||||
|
||||
fun setHudEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("hud", on).apply()
|
||||
prefs(context).edit().putBoolean(hudKey(context), on).apply()
|
||||
}
|
||||
|
||||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||||
|
||||
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
|
||||
private fun mangoappSession(context: Context) =
|
||||
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
|
||||
|
||||
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
|
||||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||||
|
||||
@@ -101,15 +112,52 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The imported glibc Turnip a mode draws with inside the runtime, keyed by
|
||||
* SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the
|
||||
* driver built into the runtime. Resolved by LinuxVulkanDriver at session start.
|
||||
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
|
||||
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
|
||||
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
|
||||
* session's choice, the one nearly everyone set, carries over.
|
||||
*/
|
||||
fun linuxDriver(context: Context, mode: String): String =
|
||||
prefs(context).getString("linuxDriver.$mode", "") ?: ""
|
||||
fun linuxDriver(context: Context): String =
|
||||
prefs(context).getString("linuxDriver", null)
|
||||
?: prefs(context).getString("linuxDriver.steam", null)
|
||||
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
|
||||
|
||||
fun setLinuxDriver(context: Context, mode: String, id: String) {
|
||||
prefs(context).edit().putString("linuxDriver.$mode", id).apply()
|
||||
fun setLinuxDriver(context: Context, id: String) {
|
||||
prefs(context).edit().putString("linuxDriver", id).apply()
|
||||
}
|
||||
|
||||
const val GPU_DRIVERS_AUTO = "auto"
|
||||
const val GPU_DRIVERS_MANUAL = "manual"
|
||||
|
||||
/**
|
||||
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
|
||||
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
|
||||
* installs; see settleGpuDriverMode.
|
||||
*/
|
||||
fun gpuDriverMode(context: Context): String =
|
||||
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
|
||||
|
||||
fun setGpuDriverMode(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("gpuDriverMode", mode).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto arrived after people had picked drivers by hand: an install that chose either driver
|
||||
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
|
||||
*/
|
||||
fun settleGpuDriverMode(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("gpuDriverMode")) return
|
||||
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
|
||||
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
|
||||
}
|
||||
|
||||
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
|
||||
fun gpuAutoInstalled(context: Context): Set<String> =
|
||||
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
|
||||
|
||||
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
|
||||
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,6 +289,13 @@ object SessionPrefs {
|
||||
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
|
||||
* but correct. Max's advice for devices whose kernels "don't work well with it".
|
||||
*/
|
||||
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
|
||||
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
|
||||
|
||||
fun setGpuClockPin(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
|
||||
}
|
||||
|
||||
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
|
||||
|
||||
fun setProotNoSeccomp(context: Context, on: Boolean) {
|
||||
@@ -298,10 +353,30 @@ object SessionPrefs {
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On for new installs (settleDeckModeDefault). */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/**
|
||||
* Deck mode became the default for new installs; an install from before keeps what it ran with
|
||||
* (off), so an update never changes its interface or restarts the client on its own. Run once at
|
||||
* process start, before anything reads or writes these prefs: a new install has neither prefs
|
||||
* nor a runtime yet. The answer is written down, so it is decided once.
|
||||
*/
|
||||
fun settleDeckModeDefault(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("steamDeckMode")) return
|
||||
val existing = p.all.isNotEmpty() || java.io.File(context.filesDir, "linuxfs").exists()
|
||||
p.edit().putBoolean("steamDeckMode", !existing).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws
|
||||
* through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did).
|
||||
*/
|
||||
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
|
||||
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -494,6 +569,22 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
|
||||
* -r (what the client and its games see as the display's rate), the compositor's buffer release
|
||||
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
|
||||
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
|
||||
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
|
||||
* does. Applies next session.
|
||||
*/
|
||||
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
|
||||
|
||||
fun setFpsLimit(context: Context, mode: String, fps: Int) {
|
||||
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
|
||||
}
|
||||
|
||||
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
|
||||
|
||||
/**
|
||||
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
|
||||
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
|
||||
@@ -514,7 +605,7 @@ object SessionPrefs {
|
||||
// ── Game storage ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A second Steam library on this device: the folder bound at /mnt/bannerlator-sd and
|
||||
* A second Steam library on this device: the folder bound at /mnt/droiddeck-sd and
|
||||
* registered with the client, which then asks where to install every game and shows both
|
||||
* on its Storage page. "" = automatic: the SD card when one is in the phone (the default,
|
||||
* so the choice is made inside the client like anywhere else); GAME_STORAGE_OFF = internal
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -269,6 +270,8 @@ class SessionService : Service() {
|
||||
val sessionDir = openSessionFolder()
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
|
||||
Log.i(TAG, GpuClockPin.start(this))
|
||||
|
||||
val size = SessionState.outputSize
|
||||
val guest = ArrayList<String>()
|
||||
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
|
||||
@@ -287,7 +290,7 @@ class SessionService : Service() {
|
||||
guest.add("DXVK_HDR=1")
|
||||
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
|
||||
}
|
||||
guest.add("BL_FPS=0")
|
||||
guest.add("BL_FPS=" + SessionState.fpsLimit)
|
||||
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
|
||||
guest.add("BL_LOG=" + sessionLog.path)
|
||||
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
|
||||
@@ -296,7 +299,7 @@ class SessionService : Service() {
|
||||
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
|
||||
SessionState.deckPad = false
|
||||
deckBinds = emptyList()
|
||||
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
|
||||
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
|
||||
// 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
|
||||
@@ -314,6 +317,7 @@ class SessionService : Service() {
|
||||
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 (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=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).
|
||||
@@ -435,6 +439,7 @@ class SessionService : Service() {
|
||||
}
|
||||
sessionPid = pid
|
||||
if (pid > 1) {
|
||||
raiseTracer(pid, gen)
|
||||
SessionEvents.guestStarted(pid)
|
||||
} else {
|
||||
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
|
||||
@@ -498,15 +503,12 @@ class SessionService : Service() {
|
||||
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)
|
||||
// An imported glibc Turnip, when one is set (by the user, or by Auto): 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. One driver for every session: the driver's shader cache is keyed on
|
||||
// its build, and with one per mode every emulator compiled its shaders twice.
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this)
|
||||
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
|
||||
@@ -527,7 +529,14 @@ class SessionService : Service() {
|
||||
// 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")
|
||||
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
|
||||
// file cache is an open and every store an open and a rename, each a proot stop (a rename
|
||||
// two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is
|
||||
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
|
||||
// the old folder itself once it has gone a week untouched.
|
||||
guest.add("MESA_DISK_CACHE_DATABASE=1")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
|
||||
@@ -608,7 +617,7 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
|
||||
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
|
||||
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: 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.
|
||||
@@ -634,11 +643,15 @@ class SessionService : Service() {
|
||||
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")
|
||||
logControllersAtStart()
|
||||
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")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
|
||||
}
|
||||
} 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
|
||||
@@ -651,9 +664,11 @@ class SessionService : Service() {
|
||||
// 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")
|
||||
}
|
||||
// The guest side of the pads (libfakeinput: which pads were opened, the Deck's hidraw and why
|
||||
// it was refused) in pad.log beside the session's other logs, so it is in every shared zip.
|
||||
// Setup-time lines only, nothing per input event.
|
||||
guest.add("FAKE_EVDEV_LOG=1")
|
||||
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
|
||||
SessionState.fakeInputDir = fakeInputDir
|
||||
}
|
||||
|
||||
@@ -666,7 +681,11 @@ class SessionService : Service() {
|
||||
// /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
|
||||
// of our own, written from Android's battery API every few seconds, is bound over it.
|
||||
val battery = BatteryComponent(File(filesDir, "session/sys/power_supply"))
|
||||
// Steam's time estimates come from /run/vpower instead, written straight into the rootfs.
|
||||
val battery = BatteryComponent(
|
||||
File(filesDir, "session/sys/power_supply"),
|
||||
File(LinuxRuntime.rootDir(this), "run/vpower"),
|
||||
)
|
||||
battery.attach(this)
|
||||
components.add(battery)
|
||||
binds.add(battery.dir.path + ":/sys/class/power_supply")
|
||||
@@ -689,9 +708,12 @@ class SessionService : Service() {
|
||||
if (library != null) {
|
||||
val problem = GameStorage.prepare(library.path)
|
||||
if (problem == null) {
|
||||
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
|
||||
binds.add("${library.path}:/mnt/droiddeck-sd")
|
||||
// Links, prefixes and Steam entries made before the rename still name the old path.
|
||||
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
|
||||
binds.add("${library.path}:/mnt/bannerlator-sd")
|
||||
Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")")
|
||||
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")")
|
||||
} else {
|
||||
Log.w(TAG, "game storage: $problem; internal only this session")
|
||||
}
|
||||
@@ -967,6 +989,31 @@ class SessionService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* proot is one thread, and every syscall it traps anywhere in the session - a path lookup in
|
||||
* Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to
|
||||
* be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6,
|
||||
* under the compositor's -8. After a moment, not at once: the first guest process is forked by
|
||||
* proot itself and would inherit the value, and with it everything the session starts.
|
||||
*/
|
||||
private fun raiseTracer(pid: Int, gen: Int) {
|
||||
Thread({
|
||||
try {
|
||||
Thread.sleep(2000)
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (gen != sessionGen || sessionPid != pid) return@Thread
|
||||
val nice = try {
|
||||
WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "tracer priority", t)
|
||||
return@Thread
|
||||
}
|
||||
Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)")
|
||||
}, "tracer-priority").start()
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
@@ -975,6 +1022,7 @@ class SessionService : Service() {
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
GpuClockPin.stop(this)
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
suspendOperationPending = false
|
||||
launchWatcher?.stopWatching()
|
||||
@@ -1149,8 +1197,22 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
|
||||
private fun logControllersAtStart() {
|
||||
val pads = ArrayList<String>()
|
||||
for (id in android.view.InputDevice.getDeviceIds()) {
|
||||
val d = android.view.InputDevice.getDevice(id) ?: continue
|
||||
if (!com.droiddeck.launcher.input.PadBridge.isFromController(d)) continue
|
||||
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
|
||||
}
|
||||
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
|
||||
private const val TRACER_NICE = -6
|
||||
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
|
||||
@@ -1176,7 +1238,6 @@ class SessionService : Service() {
|
||||
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. */
|
||||
|
||||
@@ -95,6 +95,10 @@ object SessionState {
|
||||
@Volatile
|
||||
var refreshHz: Float = 60f
|
||||
|
||||
/** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */
|
||||
@Volatile
|
||||
var fpsLimit: Int = 0
|
||||
|
||||
/** The compositor has presented a frame, so the loading panel is behind us for this session. */
|
||||
@Volatile
|
||||
var firstFrameSeen = false
|
||||
|
||||
@@ -23,7 +23,11 @@ import java.io.File
|
||||
* - `/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.
|
||||
* which libudev's enumeration leaves a device node out;
|
||||
* - [listingDir] - stand-ins for the listings of `/sys`, `/sys/class` and `/sys/bus`. An app under
|
||||
* an enforcing SELinux policy (every retail phone) may not list those directories, and libudev
|
||||
* abandons its whole scan when it cannot, so the client never reached `hidraw`; libfakeinput
|
||||
* lists these to the client instead when the real listing is refused (FAKE_DECK_SYSFS_LISTING).
|
||||
*
|
||||
* Symlinks are absolute guest paths; proot resolves them inside the session.
|
||||
*/
|
||||
@@ -47,6 +51,9 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** 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")
|
||||
@@ -89,6 +96,11 @@ object SteamDeckPad {
|
||||
|
||||
hidrawClass.mkdirs()
|
||||
link(File(hidrawClass, NODE), HIDRAW)
|
||||
|
||||
// Only the names matter: libudev reads a listing for the subsystems to descend into and
|
||||
// then opens /sys/class/hidraw itself, which is the bind above.
|
||||
val listing = listingDir(sessionRoot)
|
||||
for (name in listOf("sys/bus", "sys/class", "sys/devices", "class/hidraw", "bus")) File(listing, name).mkdirs()
|
||||
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ fun ComponentsPage(
|
||||
onImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
requestInitialFocus: Boolean = true,
|
||||
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
|
||||
gpu: GpuDriversState = GpuDriversState(),
|
||||
gpuActions: GpuDriversActions = GpuDriversActions(),
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -112,23 +115,55 @@ fun ComponentsPage(
|
||||
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 }
|
||||
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
val gpuTab = comp == GPU_TAB
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
|
||||
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
|
||||
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
|
||||
canRestore = gpu.canRestoreBundled,
|
||||
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
|
||||
onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
val nightlies = if (gpuTab) gpu.releaseStatus else 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),
|
||||
maxLines = 1,
|
||||
)
|
||||
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)
|
||||
// The status gives way to the title, not the other way round.
|
||||
if (!narrow) Text(
|
||||
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||
) else Spacer(Modifier.weight(1f))
|
||||
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
|
||||
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
}
|
||||
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -138,7 +173,7 @@ fun ComponentsPage(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) {
|
||||
if (view != null) Box {
|
||||
if (view != null && !gpuTab) Box {
|
||||
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
|
||||
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
|
||||
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
|
||||
@@ -152,11 +187,12 @@ fun ComponentsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
|
||||
val tabs = listOf(GPU_TAB) + comps
|
||||
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
|
||||
}
|
||||
|
||||
// ---- what is in use -----------------------------------------------------------------------
|
||||
if (view != null) {
|
||||
if (view != null && !gpuTab) {
|
||||
val st = view.components.getValue(comp)
|
||||
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
|
||||
Row(
|
||||
@@ -179,6 +215,12 @@ fun ComponentsPage(
|
||||
|
||||
// ---- the lists ------------------------------------------------------------------------------
|
||||
when {
|
||||
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
|
||||
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
|
||||
Row(modifier = Modifier.padding(top = 14.dp)) {
|
||||
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
|
||||
}
|
||||
}
|
||||
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))
|
||||
@@ -304,7 +346,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa
|
||||
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))
|
||||
.glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick),
|
||||
) {
|
||||
@@ -327,7 +369,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean =
|
||||
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))
|
||||
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
@@ -369,7 +411,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
|
||||
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)
|
||||
.glideBorder(hot, ROW_SHAPE, pal.signal)
|
||||
.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),
|
||||
@@ -398,7 +440,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
|
||||
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)
|
||||
.glideBorder(delHot, ROW_SHAPE, colors.error)
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
|
||||
.controllerConfirm(onClick = onDelete),
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
|
||||
@@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
/** The Components page's tab for the GPU drivers, before the Proton components' own. */
|
||||
const val GPU_TAB = "gpu"
|
||||
@@ -92,7 +92,7 @@ fun DriverPage(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp))
|
||||
.glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line)
|
||||
.focusRequester(refreshFocus)
|
||||
.hoverable(refreshSrc)
|
||||
.clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh),
|
||||
@@ -141,6 +141,7 @@ fun DriverPage(
|
||||
text = {
|
||||
Text(
|
||||
if (row.tag == DriverRow.BUNDLED) "It is built into the app, so this removes its unpacked files and hides it. \"Restore built-in drivers\" brings it back, and Auto still uses it on a GPU that needs it."
|
||||
else if (row.tag == DriverRow.BUNDLE) "It is one half of an Android + Linux bundle, so its runtime and display drivers are both removed. A driver in use goes back to the default."
|
||||
else "Its files are removed from the app." + if (row.id == selected) " It is the driver in use, so the default takes its place." else "",
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
@@ -173,7 +174,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape),
|
||||
.glideBorder(hot, shape, pal.signal),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -209,7 +210,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp))
|
||||
.glideBorder(delHot, RoundedCornerShape(10.dp), colors.error)
|
||||
.focusRequester(delFocus)
|
||||
.focusProperties { left = mainFocus }
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete),
|
||||
@@ -229,7 +230,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier
|
||||
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color,
|
||||
modifier = modifier.clip(shape)
|
||||
.background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent)
|
||||
.border(2.dp, if (hot) color else Color.Transparent, shape)
|
||||
.glideBorder(hot, shape, color)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
// One focus ring for a whole screen that slides from control to control. When focus moves, the
|
||||
// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so
|
||||
// the ring stretches like a drop and snaps into the new control's shape.
|
||||
|
||||
/** A control that can show the ring: where it is and what its ring looks like there. */
|
||||
internal class GlideSource {
|
||||
var coords: LayoutCoordinates? = null
|
||||
var shape: Shape by mutableStateOf<Shape>(androidx.compose.ui.graphics.RectangleShape)
|
||||
var color by mutableStateOf(Color.Transparent)
|
||||
var width by mutableStateOf(2.dp)
|
||||
}
|
||||
|
||||
internal class FocusGlide(val view: View) {
|
||||
var host: LayoutCoordinates? = null
|
||||
/** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */
|
||||
val hot = mutableStateListOf<GlideSource>()
|
||||
/** Bumped when the current control moves (a scroll, a relayout). */
|
||||
var moves by mutableIntStateOf(0)
|
||||
|
||||
val current: GlideSource? get() = hot.lastOrNull()
|
||||
|
||||
fun claim(s: GlideSource) { hot.remove(s); hot.add(s) }
|
||||
fun release(s: GlideSource) { hot.remove(s) }
|
||||
fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ }
|
||||
|
||||
/** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */
|
||||
fun boundsOf(s: GlideSource): Rect? {
|
||||
val h = host ?: return null
|
||||
val c = s.coords ?: return null
|
||||
if (!h.isAttached || !c.isAttached) return null
|
||||
val b = h.localBoundingBoxOf(c, clipBounds = true)
|
||||
return if (b.width < 1f || b.height < 1f) null else b
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalFocusGlide = staticCompositionLocalOf<FocusGlide?> { null }
|
||||
|
||||
private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float =
|
||||
when (val o = shape.createOutline(size, dir, density)) {
|
||||
is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x
|
||||
is Outline.Rectangle -> 0f
|
||||
is Outline.Generic -> minOf(size.width, size.height) / 2f
|
||||
}
|
||||
|
||||
/**
|
||||
* A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot].
|
||||
* Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a
|
||||
* popup, a screen with no host) it is drawn here, as a plain border.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Modifier.glideBorder(
|
||||
hot: Boolean,
|
||||
shape: Shape,
|
||||
hotColor: Color,
|
||||
restColor: Color = Color.Transparent,
|
||||
hotWidth: Dp = 2.dp,
|
||||
restWidth: Dp = 1.dp,
|
||||
): Modifier {
|
||||
val glide = LocalFocusGlide.current
|
||||
if (glide == null || glide.view !== LocalView.current) {
|
||||
return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape)
|
||||
}
|
||||
val src = remember { GlideSource() }
|
||||
src.shape = shape
|
||||
src.color = hotColor
|
||||
src.width = hotWidth
|
||||
DisposableEffect(glide, hot) {
|
||||
if (hot) glide.claim(src)
|
||||
onDispose { glide.release(src) }
|
||||
}
|
||||
val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this
|
||||
return base.onGloballyPositioned { src.coords = it; glide.moved(src) }
|
||||
}
|
||||
|
||||
/** Leading edge on a focus move: quick and a little loose. */
|
||||
private fun lead(): AnimationSpec<Float> = Motion.sp(0.62f, 700f)
|
||||
/** Trailing edge: slower, settles without a bounce to speak of. */
|
||||
private fun trail(): AnimationSpec<Float> = Motion.sp(0.78f, 360f)
|
||||
/** Following a control that moves under the ring (a scroll): tight, no wobble. */
|
||||
private fun follow(): AnimationSpec<Float> = Motion.sp(1f, 2400f)
|
||||
|
||||
/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */
|
||||
private const val SETTLE_MS = 450L
|
||||
|
||||
/**
|
||||
* Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the
|
||||
* host's space, so put the host inside anything that moves as a whole (a sheet that slides in).
|
||||
*/
|
||||
@Composable
|
||||
internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
val dir = LocalLayoutDirection.current
|
||||
val glide = remember(view) { FocusGlide(view) }
|
||||
val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom
|
||||
val corner = remember { Animatable(0f) }
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val jobs = remember { arrayOfNulls<Job>(5) }
|
||||
val cur = glide.current
|
||||
var lastColor by remember { mutableStateOf(Color.Transparent) }
|
||||
if (cur != null) lastColor = cur.color
|
||||
val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor")
|
||||
var width by remember { mutableStateOf(2.dp) }
|
||||
if (cur != null) width = cur.width
|
||||
|
||||
LaunchedEffect(glide) {
|
||||
var shown: GlideSource? = null
|
||||
var movedAt = 0L
|
||||
var dirSpecs: List<AnimationSpec<Float>> = List(4) { follow() }
|
||||
var delays = LongArray(4)
|
||||
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, wait: Long) {
|
||||
jobs[i]?.cancel()
|
||||
jobs[i] = scope.launch {
|
||||
if (wait > 0) delay(Motion.ms(wait.toInt()).toLong())
|
||||
edges[i].animateTo(v, spec)
|
||||
}
|
||||
}
|
||||
fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) {
|
||||
val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size
|
||||
val r = cornerOf(s.shape, size, dir, density)
|
||||
jobs[4]?.cancel()
|
||||
jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) }
|
||||
}
|
||||
snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) ->
|
||||
if (src == null) {
|
||||
// Focus passing between two controls can let go of one a moment before the next
|
||||
// takes it: wait a beat, so that reads as a move and not a fade out and back in.
|
||||
delay(60)
|
||||
scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
|
||||
shown = null
|
||||
return@collectLatest
|
||||
}
|
||||
var first = src !== shown
|
||||
val start = System.nanoTime()
|
||||
var last: Rect? = null
|
||||
while (true) {
|
||||
val b = glide.boundsOf(src)
|
||||
if (b == null) {
|
||||
if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) }
|
||||
} else if (first) {
|
||||
val prev = shown
|
||||
val wasShowing = prev != null && alpha.targetValue > 0f
|
||||
shown = src
|
||||
first = false
|
||||
if (!wasShowing) {
|
||||
// Appearing: a hair bigger, pulled in onto the control as it fades up.
|
||||
val out = with(density) { 3.dp.toPx() }
|
||||
val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out)
|
||||
jobs.forEach { it?.cancel() }
|
||||
scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join()
|
||||
dirSpecs = List(4) { Motion.sp(0.6f, 700f) }
|
||||
delays = LongArray(4)
|
||||
cornerTo(src, b, snap = true)
|
||||
scope.launch { alpha.animateTo(1f, Motion.tw(140)) }
|
||||
} else {
|
||||
// Moving: the edges on the side it is heading lead.
|
||||
val dx = b.center.x - (edges[0].value + edges[2].value) / 2f
|
||||
val dy = b.center.y - (edges[1].value + edges[3].value) / 2f
|
||||
val horizontal = abs(dx) >= abs(dy)
|
||||
val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1)
|
||||
val trailIdx = (leadIdx + 2) % 4
|
||||
dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } }
|
||||
delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L }
|
||||
cornerTo(src, b, snap = false)
|
||||
scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
|
||||
}
|
||||
movedAt = System.nanoTime()
|
||||
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) }
|
||||
last = b
|
||||
} else if (b != last) {
|
||||
// It moved under the ring: keep the stretch while the move is fresh, else follow.
|
||||
val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS
|
||||
listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) }
|
||||
if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) }
|
||||
last = b
|
||||
}
|
||||
// Layer animations (a tile growing, a row sliding) move it without a relayout, so
|
||||
// look again each frame for a moment after anything changes.
|
||||
if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break
|
||||
withFrameNanos { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.onGloballyPositioned { glide.host = it }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
val a = alpha.value
|
||||
if (a <= 0.01f) return@drawWithContent
|
||||
val w = width.toPx()
|
||||
val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value
|
||||
val sw = (r - l - w).coerceAtLeast(0f)
|
||||
val sh = (b - t - w).coerceAtLeast(0f)
|
||||
val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f)
|
||||
drawRoundRect(
|
||||
color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh),
|
||||
cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a,
|
||||
)
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(LocalFocusGlide provides glide) { content() }
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,48 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* a game's launch settings and the session drawer, so they say the same thing. Importing a
|
||||
* Lossless.dll is offered once the account is known to own Lossless Scaling.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FrameGenMenu(
|
||||
host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean,
|
||||
onPick: (engine: String, multiplier: Int) -> Unit,
|
||||
host: MenuHost, mode: FrameGen.Mode, lossless: Lossless.State,
|
||||
onPick: (FrameGen.Mode) -> Unit,
|
||||
onImportLossless: () -> 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) }
|
||||
val context = LocalContext.current
|
||||
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = stringResource(R.string.frame_gen_title)) { firstItemFocus ->
|
||||
val need = when {
|
||||
lossless.ready -> null
|
||||
lossless.owned -> stringResource(R.string.lsfg_needs_install)
|
||||
else -> stringResource(R.string.lsfg_needs_steam)
|
||||
}
|
||||
@Composable
|
||||
fun item(m: FrameGen.Mode, enabled: Boolean = true, detail: String? = null, focus: FocusRequester? = null) =
|
||||
MenuItem(FrameGen.label(context, m), checked = mode == m, enabled = enabled, detail = detail, focusRequester = focus) {
|
||||
onPick(m); host.open = null
|
||||
}
|
||||
item(FrameGen.Mode.OFF, focus = firstItemFocus)
|
||||
for (m in 2..4) item(FrameGen.Mode(FrameGen.ENGINE_WINFG, m))
|
||||
val lsfg = (2..4).map { FrameGen.Mode(FrameGen.ENGINE_LSFG, it) } +
|
||||
FrameGen.ADAPTIVE_TARGETS.map { FrameGen.Mode(FrameGen.ENGINE_LSFG, target = it) }
|
||||
lsfg.forEachIndexed { i, m -> item(m, enabled = lossless.ready, detail = need.takeIf { i == 0 }) }
|
||||
if (lossless.owned) {
|
||||
val using = when (lossless.source) {
|
||||
Lossless.Source.STEAM -> stringResource(R.string.lsfg_using_steam)
|
||||
Lossless.Source.IMPORT -> stringResource(R.string.lsfg_using_import)
|
||||
Lossless.Source.NONE -> null
|
||||
}
|
||||
MenuItem(stringResource(R.string.lsfg_import), checked = false, detail = using) { host.open = null; onImportLossless() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
|
||||
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)
|
||||
.glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp)
|
||||
.alpha(if (t.dim && !hot) 0.55f else 1f)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
|
||||
) {
|
||||
|
||||
@@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole
|
||||
.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)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
|
||||
@@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
|
||||
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
|
||||
.clip(Shape12)
|
||||
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
|
||||
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
|
||||
.glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
|
||||
.controllerConfirm(onClick = onLaunch)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
|
||||
@@ -201,7 +201,6 @@ private fun RailItem(
|
||||
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,
|
||||
@@ -211,7 +210,7 @@ private fun RailItem(
|
||||
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
|
||||
.clip(Shape14)
|
||||
.background(fill)
|
||||
.border(2.dp, ring, Shape14)
|
||||
.glideBorder(focused, Shape14, pal.signal)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
|
||||
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
|
||||
@@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on
|
||||
.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)
|
||||
.glideBorder(focused, Shape14, pal.signal)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
|
||||
.semantics { contentDescription = "Resume $name" }
|
||||
|
||||
@@ -68,6 +68,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
@@ -91,9 +92,8 @@ class FrontEndState(
|
||||
val steamGames: List<Library.SteamGame>,
|
||||
val emulators: List<Library.Emulator>,
|
||||
val running: String?,
|
||||
val frameGenEngine: String = FrameGen.ENGINE_OFF,
|
||||
val frameGenMultiplier: Int = 2,
|
||||
val lsfgReady: Boolean = false,
|
||||
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
|
||||
val lossless: Lossless.State = Lossless.State.NONE,
|
||||
val pageKey: String? = null,
|
||||
val theme: String = Themes.GRAPHITE,
|
||||
val isHomeApp: Boolean = false,
|
||||
@@ -139,7 +139,8 @@ class FrontEndActions(
|
||||
val onInstallPackage: (String) -> Unit,
|
||||
val onRemovePackage: (String) -> Unit,
|
||||
val onRuntime: () -> Unit,
|
||||
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
|
||||
val onFrameGenPick: (FrameGen.Mode) -> Unit,
|
||||
val onImportLossless: () -> Unit,
|
||||
val onProtons: () -> Unit,
|
||||
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
|
||||
val onComponents: (focusContent: Boolean) -> Unit,
|
||||
@@ -152,6 +153,7 @@ class FrontEndActions(
|
||||
val onBrowseFiles: (File) -> Unit = {},
|
||||
val onLogs: () -> Unit,
|
||||
val onShareLogs: () -> Unit = {},
|
||||
val onClearLogs: () -> Unit = {},
|
||||
val onOffline: () -> Unit,
|
||||
val onPageBack: () -> Unit = {},
|
||||
val onTheme: (String) -> Unit = {},
|
||||
|
||||
@@ -59,6 +59,7 @@ 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.res.stringResource
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
@@ -73,6 +74,7 @@ import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
|
||||
|
||||
@@ -151,7 +153,9 @@ internal fun SetupPanel(
|
||||
}
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val gpuOk = remember { DeviceSupport.adreno() }
|
||||
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
|
||||
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
|
||||
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
|
||||
val gpuName = remember { DeviceSupport.gpuName() }
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
@@ -193,8 +197,16 @@ internal fun SetupPanel(
|
||||
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",
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "Device supported"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "Untested GPU"
|
||||
else -> "GPU not supported"
|
||||
},
|
||||
when (gpu.support) {
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED -> "${gpu.name} · $gpuName"
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> "${gpu.name}: ${gpu.supportText.replaceFirstChar { it.lowercase() }}"
|
||||
else -> "Steam draws with an Adreno driver; $gpuName may show a black screen"
|
||||
},
|
||||
)
|
||||
CheckRow(
|
||||
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
|
||||
@@ -269,7 +281,7 @@ internal fun SetupPanel(
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
SettingsRow("Frame generation", "Select the frame generation mode") {
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
|
||||
Box {
|
||||
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
|
||||
FrameGenMenu(s, a, host)
|
||||
@@ -277,6 +289,7 @@ internal fun SetupPanel(
|
||||
}
|
||||
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
|
||||
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
|
||||
ActionRow("Saved session logs", "The newest ${com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS} are kept", "Clear logs", a.onClearLogs)
|
||||
ToggleRow(
|
||||
host, "offline", "Offline mode",
|
||||
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
|
||||
@@ -370,7 +383,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.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),
|
||||
@@ -383,7 +396,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
|
||||
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,7 +412,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
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()) {
|
||||
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
host.open = if (host.open == "fg") null else "fg"
|
||||
}
|
||||
FrameGenMenu(s, a, host)
|
||||
@@ -425,14 +438,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi
|
||||
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)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
|
||||
@@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un
|
||||
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp)
|
||||
.clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(start = 8.dp, end = 16.dp),
|
||||
@@ -180,7 +180,7 @@ internal fun PrimaryButton(
|
||||
.shine(hot, 0.45f)
|
||||
// The grow and shine alone barely show on the light fill: outline it when a
|
||||
// controller is on it, as the other controls are.
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
|
||||
.glideBorder(hot, Shape12, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
|
||||
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
|
||||
onClick()
|
||||
@@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale")
|
||||
val pal = LocalPalette.current
|
||||
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge")
|
||||
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
|
||||
modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
@@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit)
|
||||
val hot = rememberHot(src)
|
||||
val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog")
|
||||
val pal = LocalPalette.current
|
||||
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge")
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12)
|
||||
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
|
||||
) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
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.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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
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
|
||||
|
||||
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
|
||||
class PairRow(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val version: String,
|
||||
val detail: String,
|
||||
val recommended: Boolean,
|
||||
/** Built for this GPU's family; the others only show when asked. */
|
||||
val suits: Boolean,
|
||||
val complete: Boolean,
|
||||
val installed: Boolean,
|
||||
val active: Boolean,
|
||||
)
|
||||
|
||||
class GpuDriversState(
|
||||
val gpuName: String = "",
|
||||
val gpuFamily: String = "",
|
||||
val soc: String = "",
|
||||
val supportText: String = "",
|
||||
val supported: Boolean = true,
|
||||
val unsupported: Boolean = false,
|
||||
val auto: Boolean = true,
|
||||
val pairs: List<PairRow> = emptyList(),
|
||||
/** The pair key being installed, and how far along (-1 = unknown). */
|
||||
val busy: String? = null,
|
||||
val percent: Int = -1,
|
||||
val autoStatus: String = "",
|
||||
val releaseStatus: String = "",
|
||||
val checking: Boolean = false,
|
||||
// Advanced: each driver on its own.
|
||||
val linuxRows: List<DriverRow> = emptyList(),
|
||||
val linuxSelected: String = "",
|
||||
val androidRows: List<DriverRow> = emptyList(),
|
||||
val androidSelected: String = "",
|
||||
val linuxDownloads: List<DownloadRow> = emptyList(),
|
||||
val androidDownloads: List<DownloadRow> = emptyList(),
|
||||
val canRestoreBundled: Boolean = false,
|
||||
/** The Android + Linux bundle both drivers are set to ("DD-Turnip 0.1.0"), or null. */
|
||||
val activeBundle: String? = null,
|
||||
) {
|
||||
/** The pair both drivers are set to, if they make one. */
|
||||
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
|
||||
}
|
||||
|
||||
class GpuDriversActions(
|
||||
val onAuto: (Boolean) -> Unit = {},
|
||||
val onPair: (String) -> Unit = {},
|
||||
val onRefresh: () -> Unit = {},
|
||||
val onSelectLinux: (String) -> Unit = {},
|
||||
val onSelectAndroid: (String) -> Unit = {},
|
||||
val onRemoveLinux: (String) -> Unit = {},
|
||||
val onRemoveAndroid: (String) -> Unit = {},
|
||||
val onDownloadDriver: (String) -> Unit = {},
|
||||
val onImportLinux: () -> Unit = {},
|
||||
val onImportAndroid: () -> Unit = {},
|
||||
/** The tab's own import: a bundle, or a single driver of either kind. */
|
||||
val onImportZip: () -> Unit = {},
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
)
|
||||
|
||||
/**
|
||||
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
|
||||
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
|
||||
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
|
||||
* two lists each on their own, and imports, are under Advanced.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
DeviceCard(s)
|
||||
if (!s.unsupported) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ModeCard(Icons.Outlined.AutoAwesome, "Auto", "The best pair for this GPU, kept up to date", s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, "Manual", "Pick a pair yourself", !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
}
|
||||
if (s.auto) AutoCard(s)
|
||||
else {
|
||||
val shown = s.pairs.filter { it.suits || showAll }
|
||||
if (shown.isEmpty()) Text(
|
||||
if (s.checking) "Checking for drivers…" else "No drivers listed yet. Tap refresh to look for them.",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
|
||||
if (s.pairs.any { !it.suits }) FocusText(
|
||||
if (showAll) "Hide drivers for other GPUs" else "Show drivers for other GPUs", pal.signal,
|
||||
) { showAll = !showAll }
|
||||
}
|
||||
}
|
||||
SettingsGroup("Advanced") {
|
||||
val together = s.activeBundle?.let { "Set together with the %s driver: $it carries both." }
|
||||
SettingsRow("Runtime driver", together?.format("display") ?: "Games and the desktop draw with it.") {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, "Runtime default", a.onSelectLinux) { onAdvanced("rt") }
|
||||
}
|
||||
SettingsRow("Display driver", together?.format("runtime") ?: "Puts frames on the screen. Changes apply after DroidDeck restarts.") {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, "Auto - picked by GPU", a.onSelectAndroid) { onAdvanced("panel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
|
||||
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
|
||||
*/
|
||||
@Composable
|
||||
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
|
||||
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
for (row in rows) DropdownMenuItem(
|
||||
leadingIcon = { Radio(row.id == selected) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(row.tag, fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
|
||||
)
|
||||
HorizontalDivider(color = pal.line)
|
||||
DropdownMenuItem(text = { Text("Download, import or delete…", fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val tint = when {
|
||||
s.unsupported -> pal.error
|
||||
s.supported -> pal.good
|
||||
else -> AttentionAmber
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What Auto has set and what it is doing. */
|
||||
@Composable
|
||||
private fun AutoCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val active = s.activePair
|
||||
val recommended = s.pairs.firstOrNull { it.recommended }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text("IN USE", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: "The drivers built into DroidDeck",
|
||||
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
if (active != null || s.activeBundle != null) "Runtime and display drivers set together as a matched pair."
|
||||
else recommended?.let { "Recommended for this GPU: ${it.name}." } ?: "Tap refresh to look for drivers for this GPU.",
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (s.busy != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (s.percent.coerceAtLeast(0)) / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
|
||||
.clip(Shape14).background(colors.surface).background(fill)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape14)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Radio(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val usable = enabled && p.complete && !p.active
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (p.active) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
|
||||
.clip(Shape12).background(colors.surface).background(fill)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || p.active) pal.signal else pal.line, Shape12)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(enabled = usable, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Radio(p.active)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (p.recommended) Text(
|
||||
"RECOMMENDED", fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
when {
|
||||
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
|
||||
}
|
||||
p.active -> Text("In use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text("Unavailable", fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text("Use", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text("Download", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Radio(selected: Boolean) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
|
||||
const val BUNDLED = "BUNDLED"
|
||||
const val DOWNLOADED = "DOWNLOADED"
|
||||
const val IMPORTED = "IMPORTED"
|
||||
/** One half of an Android + Linux bundle: picked and deleted together with the other. */
|
||||
const val BUNDLE = "ANDROID + LINUX"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +49,10 @@ class ModeSettings(
|
||||
val shapeMode: String,
|
||||
val hdr: Boolean,
|
||||
val hdrReason: String?,
|
||||
val linuxRows: List<DriverRow>,
|
||||
val linuxSelected: String,
|
||||
val androidRows: List<DriverRow>,
|
||||
val androidSelected: String,
|
||||
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
|
||||
val gpuDrivers: String = "Auto",
|
||||
/** Frames per second the session is capped at; 0 = none. */
|
||||
val fpsLimit: Int = 0,
|
||||
val touchMode: String,
|
||||
val suspendPolicy: String,
|
||||
/** Steam only. */
|
||||
@@ -71,6 +73,8 @@ class ModeSettings(
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: enable the SteamOS client interface and its performance controls. */
|
||||
val steamDeckMode: Boolean = false,
|
||||
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
|
||||
val mangoapp: Boolean = true,
|
||||
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
|
||||
val steamController: String? = null,
|
||||
/** Steam only: start a Steam session when DroidDeck opens. */
|
||||
@@ -80,12 +84,6 @@ class ModeSettings(
|
||||
val addedGames: List<AddedGameRow> = emptyList(),
|
||||
val addedGamesArt: Boolean = true,
|
||||
/** Latest Banners-Turnip release: what each driver menu offers to download, and the refresh line. */
|
||||
val linuxDownloads: List<DownloadRow> = emptyList(),
|
||||
val androidDownloads: List<DownloadRow> = emptyList(),
|
||||
val releaseStatus: String = "Not checked yet - tap refresh to look for new drivers",
|
||||
val releaseChecking: Boolean = false,
|
||||
/** A bundled display driver was deleted: the page offers to restore it. */
|
||||
val canRestoreBundled: Boolean = false,
|
||||
/** Steam only: Decky Loader is managed from the Steam session settings. */
|
||||
val deckyInstalled: String? = null,
|
||||
val deckyLatestRelease: DeckyManager.Release? = null,
|
||||
@@ -105,16 +103,9 @@ class ModeSettingsActions(
|
||||
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
|
||||
val onShape: (String) -> Unit,
|
||||
val onHdr: (Boolean) -> Unit,
|
||||
val onSelectLinux: (String) -> Unit,
|
||||
val onImportLinux: () -> Unit,
|
||||
val onRemoveLinux: (String) -> Unit,
|
||||
val onRefreshReleases: () -> Unit = {},
|
||||
/** Asset name of the release driver to download. */
|
||||
val onDownloadDriver: (String) -> Unit = {},
|
||||
val onRestoreBundled: () -> Unit = {},
|
||||
val onSelectAndroid: (String) -> Unit,
|
||||
val onImportAndroid: () -> Unit,
|
||||
val onRemoveAndroid: (String) -> Unit,
|
||||
/** Opens the GPU drivers on the Components page: they are shared by every session. */
|
||||
val onGpuDrivers: () -> Unit = {},
|
||||
val onFpsLimit: (Int) -> Unit = {},
|
||||
val onTouch: (String) -> Unit,
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onOsc: (String) -> Unit,
|
||||
@@ -130,6 +121,7 @@ class ModeSettingsActions(
|
||||
val onStretch16x9: (Boolean) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onSteamDeckMode: (Boolean) -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
val onRunSteamAtStartup: (Boolean) -> Unit = {},
|
||||
val onPickAddedGamesDir: () -> Unit = {},
|
||||
@@ -149,51 +141,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
// The two driver lists open as full pages over this one ("rt" = runtime, "panel" = display).
|
||||
// Coming back restores this page as it was left: the same scroll position, and controller focus
|
||||
// on the driver box that opened the page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
var returnTo by remember { mutableStateOf<String?>(null) }
|
||||
val pageScroll = androidx.compose.foundation.rememberScrollState()
|
||||
val runtimeChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val displayChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
fun openDriverPage(key: String) { returnTo = key; driverPage = key }
|
||||
androidx.compose.runtime.LaunchedEffect(driverPage) {
|
||||
if (driverPage == null) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once; back
|
||||
// from a driver page, it returns to the driver box that opened it.
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val target = when (returnTo) { "rt" -> runtimeChip; "panel" -> displayChip; else -> firstChip }
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
}
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver",
|
||||
hint = (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.",
|
||||
rows = s.linuxRows, selected = s.linuxSelected, downloads = s.linuxDownloads,
|
||||
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
onSelect = a.onSelectLinux, onDelete = a.onRemoveLinux, onRefresh = a.onRefreshReleases,
|
||||
onDownload = a.onDownloadDriver, onImport = a.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver",
|
||||
hint = "Used by the compositor in both modes. Restart the app to apply.",
|
||||
rows = s.androidRows, selected = s.androidSelected, downloads = s.androidDownloads,
|
||||
status = s.releaseStatus, checking = s.releaseChecking, importLabel = "Import an AdrenoTools zip…",
|
||||
canRestore = s.canRestoreBundled,
|
||||
onSelect = a.onSelectAndroid, onDelete = a.onRemoveAndroid, onRefresh = a.onRefreshReleases,
|
||||
onDownload = a.onDownloadDriver, onImport = a.onImportAndroid, onRestore = a.onRestoreBundled,
|
||||
onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { firstChip.requestFocus() }
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
@@ -219,6 +173,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.",
|
||||
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "fps", "Frame limit", "Applies next session.",
|
||||
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
|
||||
note = "Caps the whole session. The screen switches to a rate the cap divides evenly.",
|
||||
onPick = a.onFpsLimit,
|
||||
)
|
||||
if (editCustom) CustomResolutionDialog(
|
||||
initial = custom,
|
||||
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
|
||||
@@ -234,17 +194,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
SettingsGroup("Drivers") {
|
||||
SettingsRow("Runtime driver", (if (steam) "Used by Steam and games." else "Used by desktop apps.") + " Applies next session.") {
|
||||
ValueChip(
|
||||
s.linuxRows.firstOrNull { it.id == s.linuxSelected }?.name ?: "Runtime default", open = false,
|
||||
modifier = androidx.compose.ui.Modifier.focusRequester(runtimeChip),
|
||||
) { openDriverPage("rt") }
|
||||
}
|
||||
SettingsRow("Display driver", "Used by the compositor in both modes. Restart the app to apply.") {
|
||||
ValueChip(
|
||||
s.androidRows.firstOrNull { it.id == s.androidSelected }?.name ?: "Auto - picked by GPU", open = false,
|
||||
modifier = androidx.compose.ui.Modifier.focusRequester(displayChip),
|
||||
) { openDriverPage("panel") }
|
||||
SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
SettingsGroup(if (steam) "Touch & controls" else "Touch") {
|
||||
@@ -361,6 +312,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
"Enables Steam's Deck interface and Quick Access performance overlay controls. Applies next session.",
|
||||
s.steamDeckMode, onChange = a.onSteamDeckMode,
|
||||
)
|
||||
if (s.steamDeckMode) ToggleRow(
|
||||
host, "mangoapp", "Performance overlay",
|
||||
"The Quick Access Menu's frame-rate and usage overlay. Turn off if games crash or go black with Deck mode on. Applies next session.",
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
|
||||
if (s.steamDeckMode) SettingsRow("Client branch", "Steam Deck public beta: Deck mode needs it, so Steam doesn't keep offering an update") {}
|
||||
else ChoiceRow(
|
||||
|
||||
@@ -202,12 +202,11 @@ private fun RowScope.KeyCap(
|
||||
else -> Color.White.copy(alpha = 0.07f)
|
||||
}, label = "keyFill",
|
||||
)
|
||||
val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.weight(weight).fillMaxHeight()
|
||||
.background(fill, RoundedCornerShape(7.dp))
|
||||
.border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp))
|
||||
.glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f))
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown().consume()
|
||||
@@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) {
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp))
|
||||
.glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f))
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) }
|
||||
|
||||
@@ -38,6 +38,7 @@ fun PerformancePage(
|
||||
noGlError: Boolean,
|
||||
noXalia: Boolean,
|
||||
gamescopeRealtime: Boolean,
|
||||
gpuClockPin: Boolean,
|
||||
prootNoSeccomp: Boolean,
|
||||
guestHostname: String,
|
||||
phantomWarning: String?,
|
||||
@@ -48,6 +49,7 @@ fun PerformancePage(
|
||||
onNoGlError: (Boolean) -> Unit,
|
||||
onNoXalia: (Boolean) -> Unit,
|
||||
onGamescopeRealtime: (Boolean) -> Unit,
|
||||
onGpuClockPin: (Boolean) -> Unit,
|
||||
onProotNoSeccomp: (Boolean) -> Unit,
|
||||
onGuestHostname: (String) -> Unit,
|
||||
onClientCore: (Int, Boolean) -> Unit,
|
||||
@@ -102,6 +104,13 @@ fun PerformancePage(
|
||||
gamescopeRealtime, onChange = onGamescopeRealtime,
|
||||
)
|
||||
}
|
||||
SettingsGroup("GPU") {
|
||||
ToggleRow(
|
||||
host, "gpuclock", "Hold the GPU at its top clock",
|
||||
"Adreno only. Fewer hitches from the clock ramping up, at the cost of battery and heat. Released when the session ends.",
|
||||
gpuClockPin, onChange = onGpuClockPin,
|
||||
)
|
||||
}
|
||||
SettingsGroup("Session fixes") {
|
||||
ToggleRow(
|
||||
host, "sysmem", "Turnip: sysmem rendering",
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.offset
|
||||
@@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -93,9 +113,11 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
@@ -137,9 +159,8 @@ class DrawerActions(
|
||||
val isHomeApp: Boolean,
|
||||
val androidApps: List<HomeApp.LaunchableApp>,
|
||||
val hudOn: Boolean,
|
||||
val frameGenEngine: String,
|
||||
val frameGenMultiplier: Int,
|
||||
val lsfgReady: Boolean,
|
||||
val frameGen: FrameGen.Mode,
|
||||
val lossless: Lossless.State,
|
||||
val oscMode: String,
|
||||
val suspendPolicy: String,
|
||||
val backActionsInverted: Boolean,
|
||||
@@ -153,7 +174,8 @@ class DrawerActions(
|
||||
val secondScreenDisplays: List<SecondScreenDisplay>,
|
||||
val selectedSecondScreenDisplay: Int,
|
||||
val onHud: (Boolean) -> Unit,
|
||||
val onFrameGenPick: (engine: String, multiplier: Int) -> Unit,
|
||||
val onFrameGenPick: (FrameGen.Mode) -> Unit,
|
||||
val onImportLossless: () -> Unit,
|
||||
/** The Android keyboard (text, turned into key presses). */
|
||||
val onKeyboard: () -> Unit,
|
||||
/** The on-screen PC keyboard: real keys, Esc, F1-F12, Ctrl, Alt... */
|
||||
@@ -195,6 +217,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
|
||||
var confirmStop by remember { mutableStateOf(false) }
|
||||
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
|
||||
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
|
||||
val focus = remember { DrawerFocus() }
|
||||
@@ -240,10 +264,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)),
|
||||
exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)),
|
||||
) {
|
||||
FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(sheetWidth)
|
||||
.fillMaxSize()
|
||||
.background(pal.background.copy(alpha = 0.97f))
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
|
||||
.focusGroup()
|
||||
@@ -255,7 +279,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) {
|
||||
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold,
|
||||
color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true }
|
||||
StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true }
|
||||
}
|
||||
if (a.onSteamMenu != null && a.onQam != null) {
|
||||
val qamInteraction = remember { MutableInteractionSource() }
|
||||
@@ -323,11 +347,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
|
||||
)
|
||||
val fgOpen = host.open == "fg"
|
||||
val fgLabel = FrameGen.label(a.frameGenEngine, a.frameGenMultiplier)
|
||||
SettingsRow("Frame generation", null, highlighted = fgOpen) {
|
||||
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
|
||||
Box {
|
||||
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
|
||||
FrameGenMenu(host, a.frameGenEngine, a.frameGenMultiplier, a.lsfgReady, a.onFrameGenPick)
|
||||
FrameGenMenu(host, a.frameGen, a.lossless, a.onFrameGenPick, a.onImportLossless)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +461,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
} }
|
||||
}
|
||||
StopConfirm(
|
||||
open = confirmStop,
|
||||
pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } },
|
||||
controllerActive = controllerActive,
|
||||
onCancel = { confirmStop = false },
|
||||
onStop = { onScreen ->
|
||||
confirmStop = false
|
||||
val from = a.onStopFrom
|
||||
if (from != null) from(onScreen) else a.onStop()
|
||||
},
|
||||
)
|
||||
} }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,44 +493,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
onDismiss = { appToChooseDisplay = null },
|
||||
)
|
||||
}
|
||||
if (confirmStop) {
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
val cancel = { confirmStop = false }
|
||||
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
|
||||
// against the screen and the session's overlay converts them back.
|
||||
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
val stop = {
|
||||
confirmStop = false
|
||||
val from = a.onStopFrom
|
||||
if (from != null) from(stopOnScreen) else a.onStop()
|
||||
}
|
||||
LaunchedEffect(controllerActive) {
|
||||
if (controllerActive) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { cancelFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = cancel,
|
||||
modifier = Modifier.controllerBack(onBack = cancel),
|
||||
title = { Text("Stop session?") },
|
||||
confirmButton = {
|
||||
val dialogView = LocalView.current
|
||||
TextButton(
|
||||
onClick = stop,
|
||||
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
|
||||
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
|
||||
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
|
||||
) { Text("Stop") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus)
|
||||
.controllerConfirm(onClick = cancel)) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -524,7 +522,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" }
|
||||
.clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape)
|
||||
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(start = 12.dp, end = 14.dp),
|
||||
@@ -549,7 +547,7 @@ private fun QuickAction(
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(interactionSource)
|
||||
.clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onConfirm ?: onClick)
|
||||
@@ -580,7 +578,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.size(44.dp).clip(ring)
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, ring)
|
||||
.glideBorder(hot, ring, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = description },
|
||||
@@ -639,7 +637,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
|
||||
),
|
||||
)
|
||||
.border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp))
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = "${drawerPageTitles[index]} page" }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
@@ -788,3 +786,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
|
||||
|
||||
/** Room between the Stop pill and the box that steps out under it: the pull. */
|
||||
private val StepPull = 6.dp
|
||||
private val StepBoxCorner = 16.dp
|
||||
private val StepFillet = 10.dp
|
||||
private val StepBoxMaxWidth = 264.dp
|
||||
|
||||
/**
|
||||
* The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge
|
||||
* and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and
|
||||
* the inside corner where the pull meets the box is rounded the other way. While the box is still
|
||||
* no wider than the pill it is just the pill, stretched down.
|
||||
*/
|
||||
private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) {
|
||||
path.reset()
|
||||
val r = p.right; val tl = p.left; val tt = p.top
|
||||
val pb = maxOf(bottom, p.bottom)
|
||||
val l = minOf(left, tl)
|
||||
val pt = minOf(pullTop, pb)
|
||||
val sw = tl - l
|
||||
val h = (pb - pt).coerceAtLeast(0f)
|
||||
val rt = minOf(p.height / 2f, p.width / 2f)
|
||||
val rbr = minOf(box, (pb - tt) / 2f)
|
||||
val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f)
|
||||
val rpl = minOf(box, sw / 2f, h / 2f)
|
||||
val f = minOf(fillet, sw / 2f)
|
||||
val yl = minOf(pt + rpl, pb - rbl)
|
||||
fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) {
|
||||
if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false)
|
||||
}
|
||||
path.moveTo(tl + rt, tt)
|
||||
path.lineTo(r - rt, tt)
|
||||
corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt)
|
||||
path.lineTo(r, pb - rbr)
|
||||
corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb)
|
||||
path.lineTo(l + rbl, pb)
|
||||
corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl)
|
||||
path.lineTo(l, yl)
|
||||
corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt)
|
||||
path.lineTo(tl - f, pt)
|
||||
corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f)
|
||||
path.lineTo(tl, tt + rt)
|
||||
corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt)
|
||||
path.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width
|
||||
* drops out of it and a wider box steps out to the left under it for the choices, right edges
|
||||
* flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill,
|
||||
* width first. [onStop] gets Stop's bounds on screen for the flood.
|
||||
*/
|
||||
@Composable
|
||||
private fun StopConfirm(
|
||||
open: Boolean,
|
||||
pill: Rect?,
|
||||
controllerActive: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
onStop: (Rect?) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
val left = remember { Animatable(0f) }
|
||||
val bottom = remember { Animatable(0f) }
|
||||
val tint = remember { Animatable(0f) }
|
||||
val dim = remember { Animatable(0f) }
|
||||
val items = remember { List(3) { Animatable(0f) } }
|
||||
// Composed from the first open until it has folded back into the pill.
|
||||
var shown by remember { mutableStateOf(false) }
|
||||
var grown by remember { mutableStateOf(false) }
|
||||
var content by remember { mutableStateOf<IntSize?>(null) }
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
var stopFocused by remember { mutableStateOf(false) }
|
||||
var cancelFocused by remember { mutableStateOf(false) }
|
||||
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
val view = LocalView.current
|
||||
if (!open && !shown) return
|
||||
val pull = with(density) { StepPull.toPx() }
|
||||
|
||||
LaunchedEffect(open, pill, content) {
|
||||
val p = pill ?: return@LaunchedEffect
|
||||
val c = content ?: return@LaunchedEffect
|
||||
val boxLeft = (p.right - c.width).coerceAtLeast(0f)
|
||||
val boxBottom = p.bottom + pull + c.height
|
||||
if (open) {
|
||||
shown = true
|
||||
if (!grown) {
|
||||
left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f)
|
||||
items.forEach { it.snapTo(0f) }
|
||||
grown = true
|
||||
}
|
||||
coroutineScope {
|
||||
launch { dim.animateTo(1f, Motion.tw(260)) }
|
||||
launch { tint.animateTo(1f, Motion.tw(200)) }
|
||||
launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) }
|
||||
launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) }
|
||||
items.forEachIndexed { i, a ->
|
||||
launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) }
|
||||
}
|
||||
if (controllerActive) launch {
|
||||
delay(Motion.ms(300).toLong())
|
||||
withFrameNanos { }
|
||||
runCatching { cancelFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
} else if (grown) {
|
||||
coroutineScope {
|
||||
items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
|
||||
launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) }
|
||||
launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) }
|
||||
launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) }
|
||||
launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) }
|
||||
}
|
||||
grown = false
|
||||
shown = false
|
||||
content = null
|
||||
}
|
||||
}
|
||||
val p = pill ?: return
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The rest of the sheet dims behind it, and a tap there cancels.
|
||||
Box(
|
||||
Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000))
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel),
|
||||
)
|
||||
val path = remember { Path() }
|
||||
val boxCorner = with(density) { StepBoxCorner.toPx() }
|
||||
val fillet = with(density) { StepFillet.toPx() }
|
||||
val rim = with(density) { 1.4.dp.toPx() }
|
||||
val restFill = colors.surface
|
||||
val hotFill = lerp(colors.surface, colors.error, 0.22f)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
if (!grown) return@Canvas
|
||||
stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet)
|
||||
drawPath(path, lerp(hotFill, restFill, tint.value))
|
||||
drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim))
|
||||
}
|
||||
// The pill's label rides on top, as the pull's handle.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) }
|
||||
.size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() })
|
||||
.padding(start = 12.dp, end = 14.dp),
|
||||
) {
|
||||
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp))
|
||||
Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
|
||||
}
|
||||
val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) }
|
||||
.width(with(density) { maxW.toDp() })
|
||||
.onSizeChanged { if (content != it) content = it }
|
||||
.controllerBack(onCancel)
|
||||
.onPreviewKeyEvent { e ->
|
||||
// Keep a controller in here, as the dialog's own window used to.
|
||||
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (e.key) {
|
||||
Key.DirectionUp, Key.DirectionDown -> true
|
||||
Key.DirectionLeft -> cancelFocused
|
||||
Key.DirectionRight -> stopFocused
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() },
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||
) {
|
||||
ConfirmChoice(
|
||||
"Cancel", danger = false, enabled = open,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
|
||||
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
|
||||
onClick = onCancel,
|
||||
)
|
||||
ConfirmChoice(
|
||||
"Stop", danger = true, enabled = open,
|
||||
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
|
||||
.onFocusChanged { stopFocused = it.isFocused }
|
||||
.onGloballyPositioned { stopCoords = it },
|
||||
) {
|
||||
val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) }
|
||||
onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */
|
||||
@Composable
|
||||
private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val accent = if (danger) colors.error else pal.signal
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 40.dp).clip(shape)
|
||||
.background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent)
|
||||
.glideBorder(hot, shape, accent)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ internal fun TabStrip(
|
||||
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
|
||||
.clip(tabShape)
|
||||
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape)
|
||||
.glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
@@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val pal = LocalPalette.current
|
||||
val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge")
|
||||
val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
|
||||
.heightIn(min = 44.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(colors.surfaceVariant)
|
||||
.border(1.dp, edge, RoundedCornerShape(10.dp))
|
||||
.glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
@@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack")
|
||||
val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge")
|
||||
val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob")
|
||||
val flip = { onChange(!checked) }
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
@@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape),
|
||||
.glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2),
|
||||
) {
|
||||
Box(
|
||||
Modifier.padding(4.dp).size(22.dp)
|
||||
|
||||
@@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo
|
||||
.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)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
@@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier.paneItem("pill:$label").clip(shape)
|
||||
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
@@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) {
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}")
|
||||
.clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
@@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) {
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14)
|
||||
.glideBorder(hot, Shape14, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
|
||||
@@ -112,10 +112,15 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
if (LocalNarrowPane.current) {
|
||||
StatusPanel(s, u, ua, me)
|
||||
Box(Modifier.height(18.dp))
|
||||
ChannelLabel()
|
||||
ChannelPicker(u, ua)
|
||||
} else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
} else {
|
||||
// The label sits over both columns, so the status card lines up with the first channel.
|
||||
ChannelLabel()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
|
||||
Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,20 +145,21 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
val pal = LocalPalette.current
|
||||
val catalog = u.catalog
|
||||
val release = catalog?.let { AppUpdates.release(it, u.follow) }
|
||||
val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
|
||||
val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
|
||||
val name = channelName(u.follow)
|
||||
val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH)
|
||||
val installBlock = release?.let { AppUpdates.installBlock(it, me) }
|
||||
class Look(val tint: Color, val status: String, val headline: String, val detail: String?)
|
||||
val look = when {
|
||||
!me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer",
|
||||
"It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.")
|
||||
!me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place",
|
||||
"This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.")
|
||||
catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null)
|
||||
offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null)
|
||||
offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null)
|
||||
offer == Offer.AHEAD -> Look(pal.signal, "Ahead of Stable", "You're ahead of Stable",
|
||||
"This build is newer than the last Stable release. You'll move onto Stable with its next one.")
|
||||
offer == Offer.GONE -> Look(AttentionAmber, "Test ended", "This test has ended",
|
||||
"Its fix was merged or dropped. Follow Nightly to keep getting the newest fixes.")
|
||||
"Its fix was merged or dropped. Follow Preview to keep getting the newest fixes.")
|
||||
else -> Look(pal.good, "Up to date", "You have the latest $name", null)
|
||||
}
|
||||
Column(
|
||||
@@ -169,8 +175,11 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
ReleaseNotes(changeTitle(release!!.title.ifBlank { release.tag }), release.summary, release.tag)
|
||||
}
|
||||
if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
|
||||
if (installBlock != null && (offered || offer == Offer.AHEAD)) {
|
||||
Text(installBlock, fontSize = 13.sp, color = AttentionAmber, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
|
||||
val installable = release?.apk != null && !s.sessionRunning
|
||||
val installable = release?.apk != null && installBlock == null && !s.sessionRunning
|
||||
val button = release?.apk?.size?.takeIf { it > 0 }?.let { " · ${megabytes(it)}" }.orEmpty()
|
||||
Box(Modifier.padding(top = 14.dp)) {
|
||||
when {
|
||||
@@ -181,8 +190,9 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m
|
||||
}
|
||||
offer == Offer.UPDATE -> PrimaryButton("Update$button", enabled = installable, main = true) { ua.onInstall(release!!) }
|
||||
offer == Offer.SWITCH -> PrimaryButton("Install$button", enabled = installable, main = true) { ua.onInstall(release!!) }
|
||||
offer == Offer.AHEAD -> SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) }
|
||||
offer == Offer.GONE -> PrimaryButton("Follow Nightly", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
|
||||
offer == Offer.AHEAD && installBlock == null ->
|
||||
SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) }
|
||||
offer == Offer.GONE -> PrimaryButton("Follow Preview", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
|
||||
}
|
||||
}
|
||||
if (s.sessionRunning && (offered || offer == Offer.AHEAD)) {
|
||||
@@ -213,7 +223,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
|
||||
Text(
|
||||
if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more")
|
||||
.clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
|
||||
.clip(Shape12).glideBorder(hot, Shape12, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
@@ -221,10 +231,10 @@ private fun ReleaseNotes(title: String, notes: String, key: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The headline for a build on offer: "DroidDeck 0.3.0", "New Nightly build", "PR #93 test build". */
|
||||
/** The headline for a build on offer: "DroidDeck 0.3.0", "New Preview build", "PR #93 test build". */
|
||||
private fun newBuild(f: Follow, r: Release) = when (f.channel) {
|
||||
Channel.STABLE -> "DroidDeck ${r.version ?: r.tag}"
|
||||
Channel.NIGHTLY -> "New Nightly build"
|
||||
Channel.NIGHTLY -> "New Preview build"
|
||||
Channel.TEST -> "PR #${r.pr} test build"
|
||||
}
|
||||
|
||||
@@ -235,20 +245,27 @@ private fun changeTitle(t: String): String =
|
||||
private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB"
|
||||
|
||||
/** The three channels as cards to pick from; Test builds opens its list of PRs under it. */
|
||||
@Composable
|
||||
private fun ChannelLabel() {
|
||||
Text(
|
||||
"UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val catalog = u.catalog
|
||||
val tests = catalog?.tests.orEmpty()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
|
||||
ChannelCard(
|
||||
Icons.Outlined.Verified, "Stable", "Tested releases, for most people",
|
||||
catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE,
|
||||
) { ua.onFollow(Follow(Channel.STABLE)) }
|
||||
ChannelCard(
|
||||
Icons.Outlined.Bolt, "Nightly", "Newest fixes, the odd new bug",
|
||||
catalog?.nightly?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
|
||||
Icons.Outlined.Bolt, "Preview", "Newest main-branch fixes, before Stable",
|
||||
catalog?.preview?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
|
||||
) { ua.onFollow(Follow(Channel.NIGHTLY)) }
|
||||
ChannelCard(
|
||||
Icons.Outlined.Science, "Test builds", "Try a fix before it's released",
|
||||
@@ -273,13 +290,12 @@ private fun ChannelCard(
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
// Focus shows at once, as on the Components page: no ripple, no fade.
|
||||
val edge = if (hot || selected) pal.signal else pal.line
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("channel:$title")
|
||||
.clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14)
|
||||
.clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line)
|
||||
.alpha(if (enabled) 1f else 0.55f)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick)
|
||||
@@ -316,7 +332,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
|
||||
.heightIn(min = 52.dp)
|
||||
.clip(Shape12)
|
||||
.background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12)
|
||||
.glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line)
|
||||
.hoverable(src)
|
||||
.clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
@@ -332,7 +348,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
|
||||
|
||||
private fun channelName(f: Follow) = when (f.channel) {
|
||||
Channel.STABLE -> "Stable"
|
||||
Channel.NIGHTLY -> "Nightly"
|
||||
Channel.NIGHTLY -> "Preview"
|
||||
Channel.TEST -> "the PR #${f.pr} test"
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ internal fun AdbTextField(
|
||||
.fillMaxWidth()
|
||||
.height(inputHeight)
|
||||
.background(colors.surfaceVariant, shape)
|
||||
.border(1.dp, if (focused) palette.signal else palette.line2, shape)
|
||||
.glideBorder(focused, shape, palette.signal, palette.line2)
|
||||
.padding(horizontal = 10.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
|
||||
@@ -1,73 +1,121 @@
|
||||
package com.droiddeck.launcher.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.Instant
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* DroidDeck's own builds, on three channels: Stable (the org repo's latest release), Nightly (the
|
||||
* newest `main-<sha>` release on DroidDeck-CI, one per merge to main) and Test (a `pr-<n>` release
|
||||
* on DroidDeck-CI, a signed build of an open PR). All three are signed with the release key, so any
|
||||
* of them installs over any other and keeps the app's data.
|
||||
* DroidDeck's published builds. CI turns GitHub Releases into one static catalog in DroidDeck-CI;
|
||||
* the app consumes that contract instead of inferring channels from tags and release-note prose.
|
||||
*
|
||||
* Which build is newer is decided without a diff of the repo (GitHub's compare answers in
|
||||
* megabytes): main is linear, so the newest main build is the newest; Stable is ordered by its
|
||||
* version; a test build is newer when its PR's head moved. The last check is remembered, so the
|
||||
* page shows it without going online.
|
||||
* [Channel.NIGHTLY] is intentionally kept as the internal enum/pref value for upgrades from older
|
||||
* DroidDeck versions. The channel is presented to users as Preview.
|
||||
*/
|
||||
object AppUpdates {
|
||||
private const val REPO = "Droid-Deck/DroidDeck"
|
||||
private const val CI_REPO = "Droid-Deck/DroidDeck-CI"
|
||||
private const val CATALOG_URL = "https://raw.githubusercontent.com/$CI_REPO/main/catalog.json"
|
||||
private const val PREFS = "app_updates"
|
||||
private const val KEY_CATALOG = "catalog"
|
||||
private const val KEY_CHANNEL = "channel"
|
||||
private const val CATALOG_SCHEMA = 1
|
||||
|
||||
enum class Channel { STABLE, NIGHTLY, TEST }
|
||||
|
||||
/** The channel the user follows; [pr] is the followed test build's PR, for [Channel.TEST]. */
|
||||
data class Follow(val channel: Channel, val pr: Int = 0)
|
||||
|
||||
class Apk(val name: String, val url: String, val size: Long, val sha256: String)
|
||||
|
||||
/**
|
||||
* One published build. [commit] is a full or short SHA of the source it was built from; [pr] is
|
||||
* 0 for a main or stable build. [apk] is the file for the package this app runs as, or null when
|
||||
* the build has none (a variant a release predates).
|
||||
*/
|
||||
class Release(
|
||||
val tag: String, val title: String, val summary: String, val commit: String, val pr: Int,
|
||||
val version: String?, val publishedAt: Long, val url: String, val apk: Apk?,
|
||||
class Apk(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val size: Long,
|
||||
val sha256: String,
|
||||
val packageName: String,
|
||||
val versionCode: Int,
|
||||
val signerSha256: String,
|
||||
)
|
||||
|
||||
class Catalog(val stable: Release?, val nightly: Release?, val tests: List<Release>, val checkedAt: Long)
|
||||
class Release(
|
||||
val tag: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val commit: String,
|
||||
val pr: Int,
|
||||
val version: String?,
|
||||
val versionCode: Int,
|
||||
val publishedAt: Long,
|
||||
val url: String,
|
||||
val apk: Apk?,
|
||||
)
|
||||
|
||||
/** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */
|
||||
class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L)
|
||||
class Catalog(
|
||||
val stable: Release?,
|
||||
val preview: Release?,
|
||||
val tests: List<Release>,
|
||||
val checkedAt: Long,
|
||||
)
|
||||
|
||||
class Installed(
|
||||
val commit: String,
|
||||
val pr: Int,
|
||||
val version: String,
|
||||
val versionCode: Int,
|
||||
val updatable: Boolean,
|
||||
val committedAt: Long = 0L,
|
||||
)
|
||||
|
||||
enum class Offer {
|
||||
/** This is the build the channel has. */
|
||||
CURRENT,
|
||||
/** The channel has a newer build of what is running. */
|
||||
UPDATE,
|
||||
/** The channel's build is a different line (a test build from Nightly, or back); installing moves over. */
|
||||
SWITCH,
|
||||
/** What is running is newer than the channel's build (a Nightly past the last Stable). */
|
||||
AHEAD,
|
||||
/** The followed test build is gone: its PR was merged or closed. */
|
||||
GONE,
|
||||
}
|
||||
|
||||
fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME)
|
||||
@Volatile private var releaseSigned = BuildConfig.CI_BUILD
|
||||
|
||||
fun init(context: Context) {
|
||||
releaseSigned = runCatching {
|
||||
signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }
|
||||
}.getOrDefault(BuildConfig.CI_BUILD)
|
||||
}
|
||||
|
||||
private fun signers(context: Context): List<String> {
|
||||
val pm = context.packageManager
|
||||
@Suppress("DEPRECATION")
|
||||
val certs = if (Build.VERSION.SDK_INT >= 28) {
|
||||
val info = pm.getPackageInfo(
|
||||
context.packageName,
|
||||
PackageManager.GET_SIGNING_CERTIFICATES,
|
||||
).signingInfo ?: return emptyList()
|
||||
if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory
|
||||
} else {
|
||||
pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures
|
||||
} ?: return emptyList()
|
||||
return certs.map { cert ->
|
||||
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun installed(): Installed = Installed(
|
||||
BuildConfig.SOURCE_COMMIT,
|
||||
BuildConfig.PR_NUMBER,
|
||||
BuildConfig.VERSION_NAME,
|
||||
BuildConfig.VERSION_CODE,
|
||||
releaseSigned,
|
||||
BuildConfig.COMMIT_TIME,
|
||||
)
|
||||
|
||||
fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) {
|
||||
Channel.STABLE -> catalog.stable
|
||||
Channel.NIGHTLY -> catalog.nightly
|
||||
Channel.NIGHTLY -> catalog.preview
|
||||
Channel.TEST -> catalog.tests.firstOrNull { it.pr == follow.pr }
|
||||
}
|
||||
|
||||
@@ -85,19 +133,27 @@ object AppUpdates {
|
||||
}
|
||||
}
|
||||
|
||||
/** The dot on the rail: the followed channel has a newer build of what is running. */
|
||||
fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean =
|
||||
catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE
|
||||
fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean {
|
||||
if (catalog == null) return false
|
||||
val me = installed()
|
||||
val r = release(catalog, follow) ?: return false
|
||||
return me.updatable && offer(catalog, follow, me) == Offer.UPDATE && installBlock(r, me) == null
|
||||
}
|
||||
|
||||
fun isRunning(r: Release, me: Installed = installed()): Boolean {
|
||||
if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false
|
||||
return me.commit.startsWith(r.commit, ignoreCase = true) || r.commit.startsWith(me.commit, ignoreCase = true)
|
||||
return me.commit.startsWith(r.commit, ignoreCase = true) ||
|
||||
r.commit.startsWith(me.commit, ignoreCase = true)
|
||||
}
|
||||
|
||||
/** Why Android cannot install this published build over [me], before downloading anything. */
|
||||
fun installBlock(r: Release, me: Installed = installed()): String? = when {
|
||||
r.apk == null -> "This build has no download for this copy of DroidDeck."
|
||||
r.apk.versionCode < me.versionCode ->
|
||||
"Android won't install it over this build because its versionCode ${r.apk.versionCode} is below the installed ${me.versionCode}."
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel followed. Until the user picks one it is worked out once from what is running and
|
||||
* kept: a test build follows its PR, the Stable release follows Stable, and a main build Nightly.
|
||||
*/
|
||||
fun follow(context: Context, catalog: Catalog?): Follow {
|
||||
stored(context)?.let { return it }
|
||||
val me = installed()
|
||||
@@ -105,7 +161,7 @@ object AppUpdates {
|
||||
me.pr != 0 -> Follow(Channel.TEST, me.pr)
|
||||
catalog == null -> return Follow(Channel.STABLE)
|
||||
catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE)
|
||||
me.ci -> Follow(Channel.NIGHTLY)
|
||||
me.updatable -> Follow(Channel.NIGHTLY)
|
||||
else -> Follow(Channel.STABLE)
|
||||
}
|
||||
setFollow(context, inferred)
|
||||
@@ -113,7 +169,9 @@ object AppUpdates {
|
||||
}
|
||||
|
||||
fun setFollow(context: Context, follow: Follow) {
|
||||
val value = if (follow.channel == Channel.TEST) "test:${follow.pr}" else follow.channel.name.lowercase()
|
||||
// Keep "nightly" on disk so an older DroidDeck still understands the preference after rollback.
|
||||
val value = if (follow.channel == Channel.TEST) "test:${follow.pr}"
|
||||
else follow.channel.name.lowercase()
|
||||
prefs(context).edit().putString(KEY_CHANNEL, value).apply()
|
||||
}
|
||||
|
||||
@@ -121,129 +179,115 @@ object AppUpdates {
|
||||
val v = prefs(context).getString(KEY_CHANNEL, null) ?: return null
|
||||
return when {
|
||||
v == "stable" -> Follow(Channel.STABLE)
|
||||
v == "nightly" -> Follow(Channel.NIGHTLY)
|
||||
v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let { Follow(Channel.TEST, it) }
|
||||
v == "nightly" || v == "preview" -> Follow(Channel.NIGHTLY)
|
||||
v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let {
|
||||
Follow(Channel.TEST, it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The last check, or null before the first. */
|
||||
fun cached(context: Context): Catalog? {
|
||||
val raw = prefs(context).getString(KEY_CATALOG, null) ?: return null
|
||||
return runCatching { readCatalog(JSONObject(raw)) }.getOrNull()
|
||||
return runCatching { readCachedCatalog(JSONObject(raw)) }.getOrNull()
|
||||
}
|
||||
|
||||
/** Read every channel now and remember the result: three small requests, four for an annotated tag. */
|
||||
/** One static request, rather than several unauthenticated GitHub API requests. */
|
||||
fun refresh(context: Context): Catalog {
|
||||
val suffix = variantSuffix(context.packageName)
|
||||
val stable = runCatching { stable(suffix) }.getOrElse { if (it is NoRelease) null else throw it }
|
||||
val ci = JSONArray(get("https://api.github.com/repos/$CI_REPO/releases?per_page=50"))
|
||||
var nightly: Release? = null
|
||||
val tests = ArrayList<Release>()
|
||||
for (i in 0 until ci.length()) {
|
||||
val r = ci.getJSONObject(i)
|
||||
if (r.optBoolean("draft")) continue
|
||||
val tag = r.getString("tag_name")
|
||||
when {
|
||||
tag.startsWith("main-") -> {
|
||||
val candidate = ciRelease(r, suffix, tag.removePrefix("main-"), 0)
|
||||
if (nightly == null || candidate.publishedAt > nightly.publishedAt) nightly = candidate
|
||||
}
|
||||
tag.startsWith("pr-") -> {
|
||||
val pr = tag.removePrefix("pr-").toIntOrNull() ?: continue
|
||||
val body = r.optString("body")
|
||||
// Only builds of this repo's PRs: a test build from before the org move names another.
|
||||
if (!body.contains("github.com/$REPO/pull/$pr")) continue
|
||||
val commit = Regex("""Commit: `?([0-9a-f]{7,40})""").find(body)?.groupValues?.get(1) ?: continue
|
||||
tests.add(ciRelease(r, suffix, commit, pr))
|
||||
}
|
||||
}
|
||||
}
|
||||
tests.sortByDescending { it.publishedAt }
|
||||
val catalog = Catalog(stable, nightly, tests, System.currentTimeMillis())
|
||||
val checkedAt = System.currentTimeMillis()
|
||||
val catalog = readPublishedCatalog(
|
||||
JSONObject(get("$CATALOG_URL?checked=$checkedAt")),
|
||||
context.packageName,
|
||||
checkedAt,
|
||||
)
|
||||
prefs(context).edit().putString(KEY_CATALOG, writeCatalog(catalog).toString()).apply()
|
||||
return catalog
|
||||
}
|
||||
|
||||
private class NoRelease : IOException("no stable release")
|
||||
|
||||
private fun stable(suffix: String): Release {
|
||||
val r = try {
|
||||
JSONObject(get("https://api.github.com/repos/$REPO/releases/latest"))
|
||||
} catch (e: IOException) {
|
||||
if (e.message?.contains("HTTP 404") == true) throw NoRelease() else throw e
|
||||
internal fun readPublishedCatalog(
|
||||
root: JSONObject,
|
||||
packageName: String,
|
||||
checkedAt: Long,
|
||||
): Catalog {
|
||||
val schema = root.optInt("schema")
|
||||
val sourceRepo = root.optString("sourceRepo")
|
||||
val ciRepo = root.optString("ciRepo")
|
||||
if (schema != CATALOG_SCHEMA || sourceRepo != REPO || ciRepo != CI_REPO) {
|
||||
throw IOException(
|
||||
"The update catalog has an unsupported format (schema $schema, source $sourceRepo, CI $ciRepo)",
|
||||
)
|
||||
}
|
||||
val tag = r.getString("tag_name")
|
||||
// The tag's commit; an annotated tag points at a tag object first.
|
||||
var obj = JSONObject(get("https://api.github.com/repos/$REPO/git/ref/tags/$tag")).getJSONObject("object")
|
||||
if (obj.getString("type") == "tag") obj = JSONObject(get(obj.getString("url"))).getJSONObject("object")
|
||||
val (title, summary) = describeStable(r.optString("name").ifBlank { tag }, r.optString("body"))
|
||||
return Release(tag, title, summary, obj.getString("sha"), 0, tag.removePrefix("v"), published(r),
|
||||
r.optString("html_url"), apk(r, suffix))
|
||||
val tests = root.optJSONArray("tests") ?: JSONArray()
|
||||
return Catalog(
|
||||
root.optJSONObject("stable")?.let { readPublishedRelease(it, packageName) },
|
||||
root.optJSONObject("preview")?.let { readPublishedRelease(it, packageName) },
|
||||
(0 until tests.length())
|
||||
.map { readPublishedRelease(tests.getJSONObject(it), packageName) }
|
||||
.filter { it.pr > 0 }
|
||||
.sortedByDescending { it.publishedAt },
|
||||
checkedAt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ciRelease(r: JSONObject, suffix: String, commit: String, pr: Int): Release {
|
||||
val (title, summary) = describeCi(r.optString("name"), r.optString("body"))
|
||||
return Release(r.getString("tag_name"), title, summary, commit, pr, null, published(r), r.optString("html_url"), apk(r, suffix))
|
||||
}
|
||||
|
||||
private fun published(r: JSONObject): Long =
|
||||
runCatching { Instant.parse(r.optString("published_at")).toEpochMilli() }.getOrDefault(0L)
|
||||
|
||||
/** The apk for this package: `<name><suffix>.apk`, and for the standard app the one with no other variant's suffix. */
|
||||
private fun apk(r: JSONObject, suffix: String): Apk? {
|
||||
val others = variantSuffixes().values.filter { it.isNotEmpty() && it != suffix }
|
||||
val assets = r.optJSONArray("assets") ?: return null
|
||||
for (i in 0 until assets.length()) {
|
||||
val a = assets.getJSONObject(i)
|
||||
val name = a.getString("name")
|
||||
if (!name.endsWith("$suffix.apk") || others.any { name.endsWith("$it.apk") }) continue
|
||||
// Only a file GitHub has a sha256 for: the download is checked against it.
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
return Apk(name, a.getString("browser_download_url"), a.optLong("size"), sha)
|
||||
private fun readPublishedRelease(o: JSONObject, packageName: String): Release {
|
||||
val commit = o.getString("commit")
|
||||
if (!Regex("^[0-9a-fA-F]{7,40}$").matches(commit)) {
|
||||
throw IOException("The update catalog contains an invalid commit")
|
||||
}
|
||||
return null
|
||||
val versionCode = o.getInt("versionCode")
|
||||
if (versionCode <= 0) throw IOException("The update catalog contains an invalid versionCode")
|
||||
val apk = o.optJSONObject("apks")?.optJSONObject(packageName)?.let {
|
||||
readPublishedApk(it, packageName)
|
||||
}
|
||||
if (apk != null && apk.versionCode != versionCode) {
|
||||
throw IOException("The update catalog disagrees about the APK versionCode")
|
||||
}
|
||||
return Release(
|
||||
o.getString("tag"),
|
||||
o.optString("title"),
|
||||
o.optString("summary"),
|
||||
commit,
|
||||
o.optInt("pr"),
|
||||
if (o.isNull("version")) null else o.optString("version").ifEmpty { null },
|
||||
versionCode,
|
||||
o.optLong("publishedAt"),
|
||||
o.optString("url"),
|
||||
apk,
|
||||
)
|
||||
}
|
||||
|
||||
private fun variantSuffixes(): Map<String, String> = BuildConfig.VARIANT_SUFFIXES.split(';')
|
||||
.mapNotNull { e -> e.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }.toMap()
|
||||
|
||||
private fun variantSuffix(packageName: String): String = variantSuffixes()[packageName] ?: ""
|
||||
|
||||
/**
|
||||
* A CI release's notes open with what it is: "**Merged to main** - [PR #n](…): title (merged …)"
|
||||
* or "**Not merged** - a test build of open [PR #n](…): title", then a paragraph from the PR.
|
||||
*/
|
||||
internal fun describeCi(name: String, body: String): Pair<String, String> {
|
||||
val paragraphs = paragraphs(body)
|
||||
val head = paragraphs.firstOrNull().orEmpty()
|
||||
val title = head.substringAfter("): ", "").substringBeforeLast(" (merged").ifBlank { plain(head) }.ifBlank { name }
|
||||
val summary = paragraphs.getOrNull(1)?.takeUnless { it.startsWith("Signed build") || it.startsWith("Source:") }.orEmpty()
|
||||
return plain(title) to lines(summary)
|
||||
private fun readPublishedApk(o: JSONObject, packageName: String): Apk {
|
||||
val actualPackage = o.getString("packageName")
|
||||
val versionCode = o.getInt("versionCode")
|
||||
val signer = o.getString("signerSha256").lowercase()
|
||||
val sha = o.getString("sha256").lowercase()
|
||||
val url = o.getString("url")
|
||||
if (actualPackage != packageName) throw IOException("The update catalog names the wrong Android package")
|
||||
if (versionCode <= 0) throw IOException("The update catalog has an invalid APK versionCode")
|
||||
if (!Regex("^[0-9a-f]{64}$").matches(sha)) {
|
||||
throw IOException("The update catalog has an invalid checksum")
|
||||
}
|
||||
if (!signer.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) {
|
||||
throw IOException("The update catalog is not signed for DroidDeck's release key")
|
||||
}
|
||||
val allowed = listOf(
|
||||
"https://github.com/$REPO/releases/download/",
|
||||
"https://github.com/$CI_REPO/releases/download/",
|
||||
)
|
||||
if (allowed.none { url.startsWith(it) }) {
|
||||
throw IOException("The update catalog has an unexpected download URL")
|
||||
}
|
||||
return Apk(
|
||||
o.getString("name"),
|
||||
url,
|
||||
o.optLong("size"),
|
||||
sha,
|
||||
actualPackage,
|
||||
versionCode,
|
||||
signer,
|
||||
)
|
||||
}
|
||||
|
||||
/** A stable release: its name, and the first paragraph of its notes that is prose rather than a heading or picture. */
|
||||
private fun describeStable(name: String, body: String): Pair<String, String> =
|
||||
name to lines(paragraphs(body).firstOrNull { !it.startsWith("#") && !it.startsWith("![") && !it.startsWith("<") }.orEmpty())
|
||||
|
||||
private fun paragraphs(body: String): List<String> =
|
||||
body.replace("\r", "").split(Regex("\n\\s*\n")).map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
/** A paragraph's lines as text, a list item's marker as a bullet: the page shows them collapsed or in full. */
|
||||
private fun lines(md: String): String = md.lines().map { line ->
|
||||
val item = Regex("""^\s*[-*]\s+""").find(line)
|
||||
if (item != null) "• " + plain(line.substring(item.range.last + 1)) else plain(line)
|
||||
}.filter { it.isNotEmpty() }.joinToString("\n")
|
||||
|
||||
/** Markdown down to text: links to their words, emphasis and code marks gone, on one line. */
|
||||
private fun plain(md: String): String = md
|
||||
.replace(Regex("""!\[[^]]*]\([^)]*\)"""), "")
|
||||
.replace(Regex("""\[([^]]*)]\([^)]*\)"""), "$1")
|
||||
.replace(Regex("""[*_`]{1,3}"""), "")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
|
||||
/** Dotted versions, number by number: "0.10.0" is past "0.9.2"; a missing part counts as 0. */
|
||||
fun compareVersions(a: String, b: String): Int {
|
||||
val x = a.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 }
|
||||
val y = b.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 }
|
||||
@@ -258,40 +302,79 @@ object AppUpdates {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000
|
||||
c.readTimeout = 20_000
|
||||
c.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
c.setRequestProperty("Accept", "application/json")
|
||||
c.setRequestProperty("User-Agent", "DroidDeck-app")
|
||||
c.setRequestProperty("Cache-Control", "no-cache")
|
||||
try {
|
||||
val code = c.responseCode
|
||||
if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
|
||||
if (code != 200) throw IOException("GitHub answered HTTP $code")
|
||||
if (code != 200) throw IOException("The update catalog answered HTTP $code")
|
||||
return c.inputStream.bufferedReader().use { it.readText() }
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeRelease(r: Release): JSONObject = JSONObject()
|
||||
.put("tag", r.tag).put("title", r.title).put("summary", r.summary).put("commit", r.commit).put("pr", r.pr)
|
||||
.put("version", r.version).put("publishedAt", r.publishedAt).put("url", r.url)
|
||||
.put("apk", r.apk?.let { JSONObject().put("name", it.name).put("url", it.url).put("size", it.size).put("sha256", it.sha256) })
|
||||
private fun writeApk(a: Apk): JSONObject = JSONObject()
|
||||
.put("name", a.name)
|
||||
.put("url", a.url)
|
||||
.put("size", a.size)
|
||||
.put("sha256", a.sha256)
|
||||
.put("packageName", a.packageName)
|
||||
.put("versionCode", a.versionCode)
|
||||
.put("signerSha256", a.signerSha256)
|
||||
|
||||
private fun readRelease(o: JSONObject): Release = Release(
|
||||
o.getString("tag"), o.optString("title"), o.optString("summary"), o.getString("commit"), o.optInt("pr"),
|
||||
o.optString("version").ifEmpty { null }, o.optLong("publishedAt"), o.optString("url"),
|
||||
o.optJSONObject("apk")?.let { Apk(it.getString("name"), it.getString("url"), it.optLong("size"), it.getString("sha256")) },
|
||||
private fun writeRelease(r: Release): JSONObject = JSONObject()
|
||||
.put("tag", r.tag)
|
||||
.put("title", r.title)
|
||||
.put("summary", r.summary)
|
||||
.put("commit", r.commit)
|
||||
.put("pr", r.pr)
|
||||
.put("version", r.version)
|
||||
.put("versionCode", r.versionCode)
|
||||
.put("publishedAt", r.publishedAt)
|
||||
.put("url", r.url)
|
||||
.put("apk", r.apk?.let(::writeApk))
|
||||
|
||||
private fun readCachedRelease(o: JSONObject): Release = Release(
|
||||
o.getString("tag"),
|
||||
o.optString("title"),
|
||||
o.optString("summary"),
|
||||
o.getString("commit"),
|
||||
o.optInt("pr"),
|
||||
if (o.isNull("version")) null else o.optString("version").ifEmpty { null },
|
||||
o.getInt("versionCode"),
|
||||
o.optLong("publishedAt"),
|
||||
o.optString("url"),
|
||||
o.optJSONObject("apk")?.let {
|
||||
Apk(
|
||||
it.getString("name"),
|
||||
it.getString("url"),
|
||||
it.optLong("size"),
|
||||
it.getString("sha256"),
|
||||
it.getString("packageName"),
|
||||
it.getInt("versionCode"),
|
||||
it.getString("signerSha256"),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun writeCatalog(c: Catalog): JSONObject = JSONObject()
|
||||
.put("stable", c.stable?.let(::writeRelease)).put("nightly", c.nightly?.let(::writeRelease))
|
||||
.put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } }).put("checkedAt", c.checkedAt)
|
||||
.put("stable", c.stable?.let(::writeRelease))
|
||||
.put("preview", c.preview?.let(::writeRelease))
|
||||
.put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } })
|
||||
.put("checkedAt", c.checkedAt)
|
||||
|
||||
private fun readCatalog(o: JSONObject): Catalog {
|
||||
private fun readCachedCatalog(o: JSONObject): Catalog {
|
||||
val tests = o.optJSONArray("tests") ?: JSONArray()
|
||||
val preview = o.optJSONObject("preview") ?: o.optJSONObject("nightly")
|
||||
return Catalog(
|
||||
o.optJSONObject("stable")?.let(::readRelease), o.optJSONObject("nightly")?.let(::readRelease),
|
||||
(0 until tests.length()).map { readRelease(tests.getJSONObject(it)) }, o.optLong("checkedAt"),
|
||||
o.optJSONObject("stable")?.let(::readCachedRelease),
|
||||
preview?.let(::readCachedRelease),
|
||||
(0 until tests.length()).map { readCachedRelease(tests.getJSONObject(it)) },
|
||||
o.optLong("checkedAt"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private fun prefs(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import java.io.File
|
||||
@@ -14,6 +16,7 @@ import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Installs one of DroidDeck's own builds over itself: download into the cache, check the file
|
||||
@@ -60,9 +63,13 @@ object SelfInstaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (apk.size > 0 && target.length() != apk.size) {
|
||||
throw IOException("The download size didn't match the published APK and was discarded - try again")
|
||||
}
|
||||
if (!apk.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
|
||||
throw IOException("The download didn't match its checksum and was discarded - try again")
|
||||
}
|
||||
validateDownloaded(context, target, apk)
|
||||
return target
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(target)
|
||||
@@ -72,6 +79,48 @@ object SelfInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the actual downloaded APK before giving it to Android's installer. */
|
||||
private fun validateDownloaded(context: Context, file: File, apk: AppUpdates.Apk) {
|
||||
if (apk.packageName != context.packageName) {
|
||||
throw IOException("The update is for Android package ${apk.packageName}, not ${context.packageName}")
|
||||
}
|
||||
if (!apk.signerSha256.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true)) {
|
||||
throw IOException("The update metadata doesn't name DroidDeck's release signing key")
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val flags = if (Build.VERSION.SDK_INT >= 28) {
|
||||
PackageManager.GET_SIGNING_CERTIFICATES
|
||||
} else {
|
||||
PackageManager.GET_SIGNATURES
|
||||
}
|
||||
val info = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
|
||||
?: throw IOException("Android couldn't read the downloaded APK")
|
||||
if (info.packageName != context.packageName) {
|
||||
throw IOException("The downloaded APK is for ${info.packageName}, not ${context.packageName}")
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else info.versionCode.toLong()
|
||||
if (code != apk.versionCode.toLong()) {
|
||||
throw IOException("The downloaded APK says versionCode $code, not the published ${apk.versionCode}")
|
||||
}
|
||||
if (code < BuildConfig.VERSION_CODE.toLong()) {
|
||||
throw IOException("Android won't install versionCode $code over the installed ${BuildConfig.VERSION_CODE}")
|
||||
}
|
||||
|
||||
// Before Android 9 this lineage intentionally presents the public legacy test key. Android
|
||||
// 9+ understands the v3 hand-over and must see DroidDeck's private release key as current.
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
val signing = info.signingInfo ?: throw IOException("The downloaded APK has no signing information")
|
||||
val digests = signing.apkContentsSigners.orEmpty().map { cert ->
|
||||
MessageDigest.getInstance("SHA-256").digest(cert.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
if (digests.none { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) }) {
|
||||
throw IOException("The downloaded APK isn't signed with DroidDeck's release key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes [apk] into an install session and commits it; the answer comes as an [ACTION_STATUS] broadcast. */
|
||||
fun install(context: Context, apk: File) {
|
||||
val installer = context.packageManager.packageInstaller
|
||||
|
||||
@@ -32,7 +32,9 @@ object CompositorHost {
|
||||
outputWidth: Int,
|
||||
outputHeight: Int,
|
||||
refreshHz: Float,
|
||||
fpsLimit: Int,
|
||||
): Boolean {
|
||||
pace(refreshHz, fpsLimit)
|
||||
if (started) {
|
||||
attached = surface
|
||||
WaylandCompositor.nativeSetSurface(surface)
|
||||
@@ -41,10 +43,6 @@ object CompositorHost {
|
||||
}
|
||||
WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
|
||||
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
|
||||
// The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate
|
||||
// already being achieved, and on a phone whose vendor picks clocks from that a session
|
||||
// that has been slowed reads as one that wants to be. (WinNative, WaylandSession.)
|
||||
WaylandCompositor.nativeSetLayerFrameRate(refreshHz)
|
||||
WaylandCompositor.nativeStartWithSurface(
|
||||
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
|
||||
)
|
||||
@@ -54,6 +52,19 @@ object CompositorHost {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's frame cap, on every attach: the compositor outlives sessions, and the next one
|
||||
* may have a different cap. The release pacer hands a game one buffer back per capped frame
|
||||
* (per refresh without a cap), and the zero-copy layer votes for the same cadence. Left at 0
|
||||
* the vote is inferred from the rate already being achieved, and on a phone whose vendor picks
|
||||
* clocks from that a session that has been slowed reads as one that wants to be. (WinNative,
|
||||
* WaylandSession.)
|
||||
*/
|
||||
private fun pace(refreshHz: Float, fpsLimit: Int) {
|
||||
WaylandCompositor.nativeSetFpsLimit(fpsLimit)
|
||||
WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Surface changed size under the compositor (a foldable opening or closing). Android does
|
||||
* not always recreate the Surface for that, and a swapchain built for the old size keeps
|
||||
@@ -69,7 +80,7 @@ object CompositorHost {
|
||||
// came back stretched with an engine on and right with it off. Disarmed, the window is
|
||||
// rebound and the swapchain rebuilt; the caller re-arms once that has settled, and every
|
||||
// engine-side object is made again for the new size.
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
WaylandCompositor.nativeLog("screen", "surface resized: rebinding the window, frame generation re-armed after")
|
||||
WaylandCompositor.nativeSetSurface(null)
|
||||
attached = surface
|
||||
@@ -85,7 +96,7 @@ object CompositorHost {
|
||||
*/
|
||||
fun rearmFrameGen(rearm: () -> Unit) {
|
||||
if (!started) return
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
WaylandCompositor.nativeLog("screen", "presenting window changed: frame generation re-armed")
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(rearm, 600)
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ public final class WaylandCompositor {
|
||||
*/
|
||||
public static native int nativeCursorSnapshot(int[] out);
|
||||
|
||||
/** The serial {@link #nativeCursorSnapshot} would report, without copying the image. */
|
||||
public static native int nativeCursorSerial();
|
||||
|
||||
/** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */
|
||||
public static final int CURSOR_BUF_INTS = 6 + 256 * 256;
|
||||
|
||||
@@ -434,6 +437,14 @@ public final class WaylandCompositor {
|
||||
* back to the game, like the X11 IdleNotify pacer, so the game itself slows to the cap. */
|
||||
public static native void nativeSetFpsLimit(int fps);
|
||||
|
||||
/** KGSL power control off (true: the GPU held at its top clock) or back on. Device-wide and
|
||||
* outlives the process; see {@code GpuClockPin}. A no-op without /dev/kgsl-3d0. */
|
||||
public static native void nativeSetGpuTurbo(boolean on);
|
||||
|
||||
/** setpriority() on one of our own processes or threads, only ever raising it (no cgroup move,
|
||||
* unlike android.os.Process.setThreadPriority). Returns the nice it is left at; 100 = unknown. */
|
||||
public static native int nativeRaisePriority(int tid, int nice);
|
||||
|
||||
/** Inject the app's X-server input in scene (virtual desktop) pixels. type 2 = move to a,b;
|
||||
* 3 = evdev button a (BTN_LEFT=0x110…) pressed (b=1) or released (b=0); 4 = a wheel steps,
|
||||
* negative = up. */
|
||||
@@ -450,10 +461,12 @@ public final class WaylandCompositor {
|
||||
public static native void nativeSetFrameGenEngine(int kind);
|
||||
|
||||
/** Arm (multiplier 2..4: one real frame plus multiplier-1 interpolated ones per game frame) or
|
||||
* disarm. Generated frames are presented ahead of the real frame on consecutive vblanks. */
|
||||
public static native void nativeSetFrameGenArmed(boolean armed, int multiplier);
|
||||
* disarm. A targetFps above 0 makes LSFG adaptive instead: it generates 0..3 frames per game
|
||||
* frame, whatever reaches that rate. Generated frames are presented ahead of the real frame
|
||||
* on consecutive vblanks. */
|
||||
public static native void nativeSetFrameGenArmed(boolean armed, int multiplier, int targetFps);
|
||||
|
||||
/** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code LsfgNative.cacheFile}). */
|
||||
/** LSFG Native: the SPIR-V cache built from the user's Lossless.dll ({@code Lossless.cacheFile}). */
|
||||
public static native void nativeSetLsfgCachePath(String path);
|
||||
|
||||
/** Flow scale (0.25-1.0) and the panel's real refresh rate (the pacer never generates above it). */
|
||||
|
||||
@@ -19,6 +19,28 @@
|
||||
<string name="resume_session">Resume</string>
|
||||
<string name="stop_session">Stop session</string>
|
||||
<string name="frame_gen_title">Frame generation</string>
|
||||
<string name="frame_gen_hint">Select the frame generation mode</string>
|
||||
<string name="frame_gen_off">Off</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
|
||||
<string name="lsfg_needs_steam">Needs Lossless Scaling on Steam</string>
|
||||
<string name="lsfg_needs_install">Install Lossless Scaling in Steam or import Lossless.dll</string>
|
||||
<string name="lsfg_import">Import Lossless.dll…</string>
|
||||
<string name="lsfg_using_steam">Using the copy from Steam</string>
|
||||
<string name="lsfg_using_import">Using an imported copy</string>
|
||||
<string name="lsfg_pick_title">Choose Lossless.dll</string>
|
||||
<string name="lsfg_imported">Lossless.dll imported</string>
|
||||
<string name="lsfg_import_unchanged">This Lossless.dll is already in use</string>
|
||||
<string name="lsfg_import_not_owned">Lossless Scaling is not owned on this Steam account</string>
|
||||
<string name="lsfg_import_invalid">This file is not Lossless Scaling\'s Lossless.dll</string>
|
||||
<string name="lsfg_import_failed">Could not prepare LSFG from this Lossless.dll</string>
|
||||
<string name="lsfg_problem_missing">LSFG needs Lossless Scaling from Steam or an imported Lossless.dll</string>
|
||||
<string name="lsfg_problem_failed">LSFG could not prepare its shaders from Lossless.dll</string>
|
||||
<string name="hud_fg_unsupported">%1$s: unsupported</string>
|
||||
<string name="hud_fg_failed">%1$s: engine failed</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · starting</string>
|
||||
<string name="added_games_import_hint">Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games.</string>
|
||||
<string name="second_library_import_hint">Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts.</string>
|
||||
<string name="added_games_forget_hint">Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk.</string>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class DriverBundleTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
File(context.filesDir, "graphics_driver").deleteRecursively()
|
||||
File(context.filesDir, LinuxVulkanDriverManager.DIR_NAME).deleteRecursively()
|
||||
context.getSharedPreferences("driver_bundles", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
SessionPrefs.setAndroidDriver(context, "")
|
||||
SessionPrefs.setLinuxDriver(context, "")
|
||||
}
|
||||
|
||||
/** Enough of an AArch64 ELF for the importers: the header they read, then the libc they look for. */
|
||||
private fun library(libc: String) = ByteArray(64).also {
|
||||
byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte(), 2, 1).copyInto(it)
|
||||
it[18] = 0xB7.toByte()
|
||||
} + libc.toByteArray() + ByteArray(1)
|
||||
|
||||
private val manifest = JSONObject()
|
||||
.put("package", "DD-Turnip").put("version", "0.1.0").put("tag", "DD-Turnip-v0.1.0")
|
||||
.put("drivers", JSONObject()
|
||||
.put("android", JSONObject().put("path", "android"))
|
||||
.put("linux", JSONObject().put("path", "linux")))
|
||||
|
||||
private fun zip(entries: Map<String, ByteArray>): Uri {
|
||||
val file = File.createTempFile("bundle", ".zip", context.cacheDir)
|
||||
ZipOutputStream(file.outputStream()).use { z ->
|
||||
for ((name, data) in entries) { z.putNextEntry(ZipEntry(name)); z.write(data); z.closeEntry() }
|
||||
}
|
||||
return Uri.fromFile(file)
|
||||
}
|
||||
|
||||
private fun bundle(linuxLibc: String = "libc.so.6", extra: Map<String, ByteArray> = emptyMap()) = zip(mapOf(
|
||||
"android/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("libraryName", "libvulkan_freedreno.so").toString().toByteArray(),
|
||||
"android/libvulkan_freedreno.so" to library("libc.so"),
|
||||
"linux/meta.json" to JSONObject().put("name", "DD-Turnip v0.1.0").put("driverVersion", "DD-Turnip-v0.1.0").toString().toByteArray(),
|
||||
"linux/libvulkan_freedreno.so" to library(linuxLibc),
|
||||
"manifest.json" to manifest.toString().toByteArray(),
|
||||
) + extra)
|
||||
|
||||
@Test fun manifestNeedsBothHalvesInSeparateSafeDirectories() {
|
||||
val m = DriverBundle.parseManifest(manifest)!!
|
||||
assertEquals("android", m.androidPath)
|
||||
assertEquals("linux", m.linuxPath)
|
||||
assertNull(DriverBundle.parseManifest(JSONObject().put("drivers", JSONObject().put("android", JSONObject().put("path", "a")))))
|
||||
val same = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "android/") }
|
||||
assertNull(DriverBundle.parseManifest(same))
|
||||
val escape = JSONObject(manifest.toString()).apply { getJSONObject("drivers").getJSONObject("linux").put("path", "../x") }
|
||||
assertNull(DriverBundle.parseManifest(escape))
|
||||
}
|
||||
|
||||
@Test fun singleDriverZipsAreNotBundles() {
|
||||
val legacy = zip(mapOf("meta.json" to "{}".toByteArray(), "libvulkan_freedreno.so" to library("libc.so")))
|
||||
assertFalse(DriverBundle.isBundle(context, legacy))
|
||||
assertTrue(DriverBundle.isBundle(context, bundle()))
|
||||
}
|
||||
|
||||
@Test fun installsBothHalvesAndPicksAndDeletesThemTogether() {
|
||||
val b = DriverBundle.install(context, bundle())
|
||||
assertEquals("DD-Turnip-v0.1.0", b.id)
|
||||
assertEquals("DD-Turnip 0.1.0", b.label)
|
||||
assertTrue(TurnipDriver(context).isInstalled(b.androidId))
|
||||
assertTrue(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
|
||||
assertEquals(b.id, DriverBundle.containing(context, b.linuxId, linux = true)?.id)
|
||||
assertEquals(b.id, DriverBundle.containing(context, b.androidId, linux = false)?.id)
|
||||
|
||||
assertNull(DriverBundle.active(context))
|
||||
DriverBundle.select(context, b)
|
||||
assertEquals(b.androidId, SessionPrefs.androidDriver(context))
|
||||
assertEquals(b.linuxId, SessionPrefs.linuxDriver(context))
|
||||
assertNotNull(DriverBundle.active(context))
|
||||
|
||||
DriverBundle.remove(context, b)
|
||||
assertFalse(TurnipDriver(context).isInstalled(b.androidId))
|
||||
assertFalse(LinuxVulkanDriverManager(context).isInstalled(b.linuxId))
|
||||
assertEquals("", SessionPrefs.androidDriver(context))
|
||||
assertEquals("", SessionPrefs.linuxDriver(context))
|
||||
assertTrue(DriverBundle.all(context).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aRefusedHalfLeavesNothingInstalled() {
|
||||
try {
|
||||
DriverBundle.install(context, bundle(linuxLibc = "libc.so"))
|
||||
fail("a bionic library in linux/ must be refused")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
assertTrue(e.message!!.contains("glibc"))
|
||||
}
|
||||
assertTrue(TurnipDriver(context).enumerateImported().isEmpty())
|
||||
assertTrue(LinuxVulkanDriverManager(context).enumerateInstalledDrivers().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun entriesOutsideTheBundleAreRefused() {
|
||||
try {
|
||||
DriverBundle.install(context, bundle(extra = mapOf("../escape.so" to ByteArray(4))))
|
||||
fail("a zip-slip entry must be refused")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
assertTrue(e.message!!.contains("outside"))
|
||||
}
|
||||
assertFalse(File(context.cacheDir.parentFile, "escape.so").exists())
|
||||
}
|
||||
|
||||
@Test fun twoInstallsOfOneReleaseGetTheirOwnIds() {
|
||||
val first = DriverBundle.install(context, bundle())
|
||||
val second = DriverBundle.install(context, bundle())
|
||||
assertEquals("DD-Turnip-v0.1.0-2", second.id)
|
||||
assertEquals(2, DriverBundle.all(context).size)
|
||||
assertFalse(first.androidId == second.androidId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo.Family
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DriverPairsTest {
|
||||
private fun gpu(model: Int, adreno: Boolean = true, oneUi: Boolean = false) =
|
||||
GpuInfo("Adreno $model", model, GpuInfo.familyOf(adreno, model), "", oneUi)
|
||||
|
||||
private fun asset(name: String, tag: String, linux: Boolean, pair: String) =
|
||||
TurnipReleases.Asset("src", tag, name, "https://example/$name", 3_000_000, linux, "label", "0".repeat(64), pair)
|
||||
|
||||
@Test
|
||||
fun familiesFollowTheAdrenoModel() {
|
||||
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 825))
|
||||
assertEquals(Family.A8XX, GpuInfo.familyOf(true, 810))
|
||||
assertEquals(Family.A7XX_LOW, GpuInfo.familyOf(true, 722))
|
||||
assertEquals(Family.A7XX, GpuInfo.familyOf(true, 740))
|
||||
assertEquals(Family.A6XX, GpuInfo.familyOf(true, 610))
|
||||
assertEquals(Family.ADRENO_UNKNOWN, GpuInfo.familyOf(true, 0))
|
||||
assertEquals(Family.NOT_ADRENO, GpuInfo.familyOf(false, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTestedHardwareCountsAsSupported() {
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(740).support)
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(825).support)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support)
|
||||
assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoPicksWinNativeBalancedOn8xxAndBannersElsewhere() {
|
||||
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), emptyList()))
|
||||
assertEquals("840 with no DD-Turnip release listed", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(840), emptyList()))
|
||||
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER_ONEUI, DriverPairs.recommendedKey(gpu(740, oneUi = true), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER_710, DriverPairs.recommendedKey(gpu(720), emptyList()))
|
||||
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(650), emptyList()))
|
||||
assertNull(DriverPairs.recommendedKey(gpu(0, adreno = false), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairsMatchHalvesAndNeverMixBannersReleases() {
|
||||
val check = TurnipReleases.Check(
|
||||
listOf(
|
||||
asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER),
|
||||
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER),
|
||||
// The A8xx set's newest display build is r4, but its Linux half is only in r3.
|
||||
asset("Turnip-r4-A8xx.zip", "r4", false, DriverPairs.BANNER_A8XX),
|
||||
asset("Turnip-r3-A8xx-Linux.zip", "r3", true, DriverPairs.BANNER_A8XX),
|
||||
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
|
||||
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED),
|
||||
),
|
||||
emptyList(), emptyList(), 0L,
|
||||
)
|
||||
val pairs = DriverPairs.from(check).associateBy { it.key }
|
||||
assertTrue(pairs.getValue(DriverPairs.BANNER).complete)
|
||||
assertFalse("halves from two releases are not a pair", pairs.getValue(DriverPairs.BANNER_A8XX).complete)
|
||||
val wn = pairs.getValue(DriverPairs.WN_BALANCED)
|
||||
assertTrue("WinNative's two release lines pair up", wn.complete)
|
||||
assertEquals("v1.19 + v0.1.2", wn.version)
|
||||
assertTrue(wn.suits(gpu(830)))
|
||||
assertFalse(pairs.getValue(DriverPairs.BANNER).suits(gpu(830)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyBundleReleaseIsACompletePairOnItsOwnNewestFirst() {
|
||||
fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000,
|
||||
false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true)
|
||||
val newer = bundle("DD-Turnip-v0.2.0")
|
||||
val older = bundle("DD-Turnip-v0.1.0")
|
||||
val pairs = DriverPairs.from(TurnipReleases.Check(
|
||||
listOf(asset("Turnip-r4.zip", "r4", false, DriverPairs.BANNER), newer, older,
|
||||
asset("Turnip-r4-Linux.zip", "r4", true, DriverPairs.BANNER)),
|
||||
emptyList(), emptyList(), 0L,
|
||||
))
|
||||
assertEquals(listOf(DriverPairs.ddTurnip("DD-Turnip-v0.2.0"), DriverPairs.ddTurnip("DD-Turnip-v0.1.0"), DriverPairs.BANNER), pairs.map { it.key })
|
||||
val dd = pairs.first()
|
||||
assertTrue(dd.complete)
|
||||
assertEquals(listOf(newer), dd.assets)
|
||||
assertEquals("v0.2.0", dd.version)
|
||||
assertEquals("DroidDeck · DD-Turnip", dd.name)
|
||||
assertTrue(dd.suits(gpu(840)))
|
||||
assertTrue(dd.suits(gpu(740)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adreno840GetsTheNewestOfficialDdTurnip() {
|
||||
fun bundle(tag: String) = TurnipReleases.Asset("DroidDeck", tag, "$tag.zip", "https://example/$tag", 6_000_000,
|
||||
false, "Android + Linux", "0".repeat(64), DriverPairs.ddTurnip(tag), bundle = true)
|
||||
val pairs = DriverPairs.from(TurnipReleases.Check(
|
||||
listOf(bundle("DD-Turnip-v0.9.0"), bundle("DD-Turnip-v1.0.0"), bundle("DD-Turnip-v0.10.1"),
|
||||
asset("WN-Turnip-1.19-b_Axxx.zip", "v1.19", false, DriverPairs.WN_BALANCED),
|
||||
asset("WN-Linux-Turnip-0.1.2-b_Axxx.zip", "linux-v0.1.2", true, DriverPairs.WN_BALANCED)),
|
||||
emptyList(), emptyList(), 0L,
|
||||
))
|
||||
assertEquals(DriverPairs.ddTurnip("DD-Turnip-v1.0.0"), DriverPairs.recommendedKey(gpu(840), pairs))
|
||||
assertEquals("other 8xx keep WinNative", DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(830), pairs))
|
||||
assertEquals(DriverPairs.WN_BALANCED, DriverPairs.recommendedKey(gpu(825), pairs))
|
||||
assertEquals(DriverPairs.BANNER, DriverPairs.recommendedKey(gpu(740), pairs))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user