Compare commits

...
Author SHA1 Message Date
maxjivi05 11a820402c fix: native Linux games under FEX reach FEXServer, and a refused compat app no longer loops the sync
FEX launched through a script (Valve's _v2-entry-point) was judged 32-bit, so the
fast path stayed off in the FEX client while FEXServer bound its socket through it.
The fast path now follows #! lines before judging the program.

The live compat sync drops apps the client refuses GetCompatTools for instead of
reattaching forever.
2026-10-20 14:11:25 -04:00
MaxsTechReview 0f1b82aa26 Work around Android's denial of executable stacks (#1208) 2026-10-20 12:44:49 -04:00
Kurt Himebauch 8b279001a5 fix: prefetch Comet and Xbox packs for existing users (#1204) 2026-10-20 16:29:08 +00:00
Kurt Himebauch 61254786e6 feat: ship proot, DirectAudio, Xbox, Comet and hand tracking as CI-built add-on packs (#1198)
* perf: download hand tracking models and native libs on first use

* feat: build and publish add-on packs in CI, add Xbox and Comet packs

* feat: ship proot and DirectAudio drivers as CI-built packs

* test: hand tracking readiness requires the tracking pack

* ci: attach PR test-build packs to the PR release instead of Components
2026-10-20 11:18:44 -04:00
Kurt Himebauch ece1df0da8 Fix Game Pass library sync when subscription plan rows are omitted (#1193) 2026-10-20 11:14:06 -04:00
kingokksa 49d1311808 feat: arrange and hide/show store tabs from stores settings (#1121) 2026-10-20 11:12:27 -04:00
MaxsTechReview a38514afa0 fix: RetroArch fills the screen from the emulator rail (#1194) 2026-10-20 10:38:52 -04:00
MaxsTechReview 31a6c3d033 fix: open Flatpak apps in renamed app variants (#1192)
Flatpak starts bwrap with an empty environment, so the bwrap shim lost DROIDDECK_BWRAP_SOCKET and dialled the default package's socket. The session now writes the socket name to /etc/droiddeck/bwrap-socket and the shim reads it when the variable is missing.

Fixes #1092
2026-10-20 09:38:14 -04:00
FX1922 99ab6dd64e feat: Better boot movies system; Added steamdeckrepo support (#1036) 2026-10-20 09:36:25 -04:00
MaxsTechReview 9b5df0d224 perf: tile rendering for games, faster session start (#1188)
Games launched from Steam get Turnip's own per-render-pass choice between
tile memory and system memory again; TU_DEBUG=sysmem now stays with the
Steam client, whose interface it was set for. A per-game TU_DEBUG, the
debug override file and the Adreno 710/720 driver still win. On the
REDMAGIC tablet (Adreno 840) PEAK runs 46.8 -> 52.3 fps over 7 alternating
pairs and Valheim's 3D menu needs 12.6 % less GPU time per frame; light
games are unchanged.

droiddeck-seed-redists remembers the identity (inode, size, mtime) of each
prefix registry it has verified and skips re-reading it while it is
unchanged. Steam started after it on every session, ~1.8 s later.

SessionFiles keeps a ledger of the files it staged for the installed APK
and copies only the ones that changed, ~180 ms less per session start.

Session start to Steam ready: 5.57 -> 3.57 s.
2026-10-20 09:19:46 -04:00
MaxsTechReview aeb0fa57e3 fix: RPCS3 opens from the emulator rail again (#1191)
RPCS3's AppImage could not start: its own runtime unpacks the image with
a dwarfsextract it runs from a memfd, which Android refuses (EACCES), and
it has no other way to run it. Emulator AppImages for arm64 now start
through the shipped uruntime, which falls back to a file in /tmp.

RPCS3 then stopped with "Null object" in utils::shm (errno 13): it reads
/proc/sys/vm/overcommit_memory to pick memfd for guest memory, and apps
cannot read it. The guest now gets the kernel's default policy (0) there
when the real file is unreadable, like file-max.
2026-10-20 08:52:31 -04:00
MaxsTechReview ba582dea65 fix: add a picked exe the session cannot see by binding its folder (#1173)
Adding a game from a single exe outside internal storage and the game
library, such as a microSD card, only showed "This location is not
available in the session" and added nothing. #1153 made "Add all games in
this folder" bind such a folder under /root/Games, but a picked exe still
had no guest path.

addExe now binds the folder the same way when the session cannot see the
exe (the game it belongs to, or else the exe's own folder), then adds the
game. The bind is mounted from the next session start.
A folder the session already sees is not bound again, and a folder the app
cannot read is still refused.
2026-10-20 00:45:56 -04:00
MaxsTechReview 8074ec44e8 fix: route ALSA to PulseAudio for x86 Linux games under FEX (no game audio) (#1166)
fix: route ALSA to PulseAudio for x86 Linux games under FEX
2026-10-20 00:44:56 -04:00
MaxsTechReview fdab3cea82 fix: GOG games miss the registry entries their install script sets (#1159)
A GOG download only places the files, so games that look up their own registry keys (Fallout: New Vegas reads HKLM\Software\Bethesda Softworks\FalloutNV "Installed Path") said they were not installed. Every GOG launch now reads the setRegistry actions of the game's goggame-<id>.script and imports them into the prefix's 32-bit view through Proton's reg.exe, once per change of the script, so games already downloaded are fixed on their next launch.
2026-10-20 00:38:08 -04:00
MaxsTechReview b26b43fbd0 fix: find and save Steam touch layouts of non-Steam shortcuts (#1171)
The touch layout editor opened "App -<n> Layout" for a non-Steam shortcut
(an added game, the Epic Games Launcher) with LB, LT, RT, RB, both sticks
and LS/RS greyed out, so they could not be placed. Steam keys a shortcut's
controller config by the shortcut's lowercase name and logs its app id
unsigned, but the editor looked both up by the signed app id, found
neither, and fell back to a blank layout with nothing bound.

SteamTouchConfig now reads the shortcut's name from shortcuts.vdf, loads
and saves its layout under that name the way Steam does (autosave folder
and configset entry), and matches Steam's log line by the unsigned id. The
editor is titled with the game's name, and the app id in the panel is
shown unsigned. A name that is not a plain folder name (a
path separator, "." or "..", a quote, a backslash or a control character)
keeps the numeric app id. Existing configset entries are matched
without regard to case, as Steam reads them.
2026-10-20 00:34:01 -04:00
MaxsTechReview 4ba3e442a8 fix: let Wine write hooks into code FEX has translated (Ubisoft "Error at hooking API") (#1163)
fix: let Wine write hooks into code FEX has translated

Wine 11's wineserver writes another process's memory with process_vm_writev,
which the kernel refuses on a page that is not writable. FEX's WoW64 layer
keeps the pages it has translated read-only to catch self-modifying code and
re-protects them right after a program unprotects them, so WriteProcessMemory
and NtWriteVirtualMemory into ntdll failed with a partial copy and Ubisoft
games stopped at "Error at hooking API NtProtectVirtualMemory". Wine only
falls back to /proc/<pid>/mem on ENOSYS or EPERM.

libblsession now completes a single-buffer write that wineserver was refused
with EFAULT through /proc/<pid>/mem, the way Wine wrote before Wine 11. Other
programs and every other error keep the kernel's answer. The iovecs are read
with process_vm_readv, so a bad pointer still returns EFAULT, and offsets are
64-bit in the i386 build.
2026-10-20 00:31:03 -04:00
MaxsTechReview 57799bef5a fix: add games picked inside a folder game as their own games (#1155)
Picking an exe anywhere under an added folder treated it as that game, so a
folder added with "Add all games in this folder" (or a removed one) swallowed
every game below it: picking another game's exe switched the folder game's
target, or brought the removed folder game back under its old name and art.

Only treat a pick as the same game when it sits in the folder root, in the
same top-level subfolder as the game's current exe, or is another build of
that exe, and prefer the deepest matching folder.
2026-10-20 00:29:21 -04:00
MaxsTechReview cee84fd12a fix: show the app's cursor in sessions started from the front end (#1154)
gamescope makes its cursor surfaces by attaching an shm buffer, committing,
destroying the buffer and only then calling wl_pointer.set_cursor. The
compositor kept that buffer only as a reference, so set_cursor found nothing
to publish: apps run from the front end (store apps, Flatpaks, emulators)
showed DroidDeck's fallback arrow, or no cursor at all after a desktop
session had hidden its own.

Copy a role-less surface's pixels when its held buffer is destroyed and
publish that copy when the surface becomes the cursor, for as long as the
surface lives.
2026-10-20 00:28:14 -04:00
Resul Övür 48daa064c9 fix: confirm before bulk-removing unused components in Storage (#1140) 2026-10-19 23:51:11 -04:00
Resul Övür a42ad82bcb fix: keep held Steam Touch controls pressed across action-set and layer changes (#1143) 2026-10-19 23:50:29 -04:00
Kurt Himebauch 06e819f864 fix: an interrupted package install can no longer break the Linux runtime (#1078) 2026-10-19 23:40:30 -04:00
Grimarius 4c81c98f12 feat: add configurable touchscreen gestures (#940) 2026-10-20 03:22:41 +00:00
MaxsTechReview 3a8d6aaabb fix: Steam no longer sleeps again right after a paused session resumes (#1162) 2026-10-19 23:06:48 -04:00
Resul Övürandovurrsl 64b966af49 fix: keep ModeHeader search from stealing focus when recomposed (#1141)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:22 -04:00
Resul Övürandovurrsl 583c6805fd fix: keep touchpad two-finger scroll from jumping the cursor or starting a drag (#1142)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:06 -04:00
Resul Övür e8ee11612f fix: uppercase section headings in the app language (#1148)
* fix: uppercase section headings in the app language

* fix: localize store section headings
2026-10-19 21:27:50 -04:00
MaxsTechReview 7855bec6a1 fix: give x86 programs under FEX the runtime's OpenSSL 3 (libcrypto.so.3 not found) (#1167)
fix: give x86 programs under FEX the runtime's OpenSSL 3
2026-10-19 21:27:25 -04:00
MaxsTechReview 2a423a729a fix: let x86 apps under FEX use the device's fonts (Epic launcher CJK text shows boxes) (#1168)
fix: let x86 apps under FEX use the device's fonts for CJK text
2026-10-19 21:27:18 -04:00
MaxsTechReview 01f41e0553 fix: user apps open at the Desktop's resolution (#1172)
A user app (script, AppImage or Flatpak) started from its shortcut runs in
a Run session, which took the Steam resolution (720p by default). It
ignored the resolution chosen under Open Desktop and was letterboxed at
1280x720 on screens that are not 16:9.

User apps now use the Desktop's resolution once one has been chosen. Until
then, and for every other Run program, nothing changes.
2026-10-19 21:27:06 -04:00
Kurt Himebauch 3dd0045874 Restore Xbox launches from Steam (#1170) 2026-10-20 00:46:35 +00:00
Kurt Himebauch 6bbcf3ad8b Restrict Xbox credential IPC and address launch review findings (#1164)
* Restrict Xbox credentials to host backends and lease game launches

* Verify mapped PRoot backend images and retry lease heartbeat timeouts

* Bound Xbox backend shutdown and isolate service socket lifetimes

* Keep unique Xbox service sockets within Unix address limits

* Own licensed launch directories and recover abandoned views safely

* Serialize Xbox account revocation with sign-in commits and launch authorization
2026-10-19 20:10:54 -04:00
Kurt Himebauch f5bba17f53 Add experimental Xbox and PC Game Pass store support (#1156) 2026-10-19 18:44:10 -04:00
The412Banner af57286c98 fix: adding a games folder on an SD card found nothing (#1153)
Adding a folder on an SD card outside the Steam library added nothing and said
"Nothing new in this folder": every game's exe had no guest path, so each one
was skipped. Since #542 nothing created the game-folder binds that let older
builds use such folders; only migrated ones remained.

addFolder now binds the picked folder under /root/Games (named as
GamesFolderMigration names them, with a suffix when the name is taken) when the
session cannot see it, then adds its games. The bind is mounted from the next
session start.
2026-10-19 19:43:03 +00:00
Kurt Himebauch 446452a97f Fix intermittent CI package updates and escaped test worker (#1151)
Fix escaped cloud-save test worker and CI package updates
2026-10-19 14:52:30 -04:00
Kurt Himebauch 868f54e425 fix: skip DirectAudio for Steam install scripts (#1046) 2026-10-19 14:19:10 -04:00
Kurt Himebauch c3bc3e6fbd fix: run x86 Proton under FEX in the Steam runtime it requires (#1034) 2026-10-19 14:18:52 -04:00
Kurt Himebauch 1197be90d9 Fix direct-touch right-click and Steam Touch mode restoration (#677) 2026-10-19 14:18:26 -04:00
Kurt Himebauch 6f29018864 fix: register SteamVR only during a DroidDeck VR session (#1137) 2026-10-19 12:31:23 -04:00
Kurt Himebauch ac25283f04 fix: DD-FG 3x/4x flicker and rebrand Win-FG to DD-FG (#1132) 2026-10-19 11:21:32 -04:00
Kurt Himebauch 2ecfe18f76 fix: keep the TP UBWC hint on for Samsung One UI 8 Gen 2 (#1128) 2026-10-19 09:58:24 -04:00
kingokksa 7515bb9898 Ship the desktop's translations as per-language packs (#942) 2026-10-19 09:54:19 -04:00
Kurt Himebauch 4555fa266d feat: capsule view with DVD-case store art across all stores (#1117) 2026-10-19 08:45:58 -04:00
Kurt Himebauch 60f40913a2 feat: play button on installed store game pages (#1125) 2026-10-19 12:23:02 +00:00
Kurt Himebauch facaa18a3c feat: library and store tabs per store with installed games listed first (#1116) 2026-10-19 02:06:38 -04:00
kingokksaandxXJSONDeruloXx 8838cf28ed feat: itch.io store, library and installs for DRM-free Windows builds (#1064)
Co-authored-by: xXJSONDeruloXx <[email protected]>
2026-10-19 05:25:00 +00:00
Kurt Himebauch 65f44e2b80 feat: add light launcher icon option (#1104) 2026-10-19 02:54:50 +00:00
MaxsTechReview e321f5025d Fill missing plural forms and translate English left in the app strings (#1102)
Nine storage plurals lacked the forms Arabic, French, Spanish, Italian
and Portuguese use (zero/two/few/many), and 71 strings across 16
languages were still the English text.
2026-10-18 21:28:36 -04:00
MaxsTechReview a105e4e49c Steam x86-64 client: menus come up, sign-in stays put, games listed once (#1096)
* Steam x86-64 client: menus come up, sign-in stays put, games listed once

Switching the Steam client from arm64 to x86-64 left the client on a blank
screen behind "Downloading the Steam client update 100%", and each switch
signed one of the clients out.

- The x86-64 client is Valve's steamrt64 platform, but it was installed and
  synced from the ubuntu12 manifest, which lags it (1792108788 against
  1792192922). The client was therefore never the arm64 client's build, the
  arm64 web helper was never used, and the client then updated itself in
  place after the helper had been chosen. Install, sync and the build check
  now read the steamrt64 manifest.
- The x86 web helper's GPU process loads libpci, which exits when
  /sys/bus/pci/devices lists a device whose files the app may not read (a
  RedMagic's PCIe root ports). Every GPU process exited with status 1, even
  in software, until Chromium gave up and the menus stayed blank. An empty
  folder is bound over /sys/bus/pci/devices on such devices.
- Steam refuses one client's login token from the other ("Invalid
  Password" for a token the arm64 client signed in with minutes before), so
  copying the newer token at every start signed the other client out. Each
  client now keeps its own token; a client with no accounts only gets the
  other's account list.
- Games could be listed twice: an added game whose folder a Steam import
  already covers is now shown as that Steam game only, a client switch
  drops the old client's imported entries, an import stays one when its
  ownership cannot be confirmed instead of gaining a shortcut beside it,
  and only the client's own library folders count as an install.

* Steam x86-64 client: share the arm64 sign-in again

The x86-64 client refused the arm64 client's login token because it did
not know the account's SteamID: it takes it from Accounts in
config/config.vdf, which this script creates without one. It logged on as
[U:1:0] with a valid token, Steam answered "Invalid Password", and the
client dropped the token and AutoLoginUser, so every switch asked for the
password.

The newer sign-in is copied between the clients again, each start adds
the accounts either client knows to the other's config.vdf, and an
AutoLoginUser or RememberPassword one client lacks is filled in. Device:
the x86 client signs in with the arm64 token on a fresh install and on an
install left signed out, and the arm64 client stays signed in.
2026-10-18 20:38:48 -04:00
MaxsTechReview 2016a363f2 Boot Linux games under the FEX compatibility tool again (#1100)
Valve's FEX opens rootfs paths with openat2(RESOLVE_IN_ROOT), which the
preload emulates under proot. droiddeck-fex's rootfs links to the Steam
runtimes with absolute paths outside the rootfs, so every lookup missed
and FEX fell back to the arm64 host files: x86 bash, and with it Stardew
Valley's launcher, failed on libdl.so.2. An absolute link whose target
is missing in the root but present on the host now finishes there.
2026-10-18 20:38:08 -04:00
MaxsTechReview 1873a9aee7 fix: Steam client responds sooner and its sounds play on time (#1094)
- Pace the session at the rate the panel actually runs. When the system
  keeps a lower rate than the mode DroidDeck asked for (a vendor game
  assistant holding 120 Hz while 185 Hz was requested), gamescope,
  Xwayland and the compositor's presentation feedback were told 185 Hz,
  and Steam's interface drew every 10.8 ms out of step with the panel.
- Build the DirectAudio PulseAudio sink from source (LGPL-2.1-or-later,
  The412Banner/directaudio v1.1.0) with its max request kept to its ring
  target. Advertising 100 ms made PulseAudio grow every client's buffer
  to about 112 ms, so Steam's interface sounds played that late.
2026-10-18 19:26:26 -04:00
MaxsTechReview d5628f7b70 fix(ci): give unit tests a 2 GB heap (#1091)
Robolectric keeps one sandbox per SDK level the tests ask for, and the
suite now asks for seven (26, 28, 29, 31, 33, 34 and the default). They
no longer fit in Gradle's default 512 MB test heap, so testReleaseUnitTest
runs out of memory part way through (SteamHomeHeadsetTest,
GlassesMenuCaptureTest) on main and on every open PR.
2026-10-18 19:24:09 -04:00
Kurt Himebauch e10fc86a76 fix: keep hidden and private Steam games off the Steam home art wall (#1097) 2026-10-18 19:11:30 -04:00
337 changed files with 24529 additions and 1314 deletions

No files matched your search

+5 -1
View File
@@ -64,6 +64,7 @@ jobs:
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
cp out/kwin/desktop-kde.kwin.txt out/
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
- uses: actions/upload-artifact@v4
with:
name: desktop-kde
@@ -74,6 +75,8 @@ jobs:
out/desktop-kde.packages.txt
out/desktop-kde.kwin.txt
out/desktop-kde.sha256
out/desktop-kde.i18n.txt
out/desktop-kde-i18n-*.tar.zst
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
@@ -88,5 +91,6 @@ jobs:
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
gh release edit "$TAG" --latest=false --prerelease=false
+106 -12
View File
@@ -29,7 +29,64 @@ env:
PRELOAD_CACHE_VERSION: v6
jobs:
xbox:
name: Xbox ARM64 backend
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "[email protected]"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: xbox-cache
with:
path: |
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
- name: Build Xbox backend
if: steps.xbox-cache.outputs.cache-hit != 'true'
run: |
rustup toolchain install 1.99.0 --profile minimal
sudo apt-get update -qq
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
bash tools/xbox/build.sh
- uses: actions/upload-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
retention-days: 14
- uses: actions/upload-artifact@v4
with:
name: xbox-corresponding-source
path: |
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
if-no-files-found: error
retention-days: 14
build:
needs: xbox
name: Build APK
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
@@ -41,6 +98,7 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Check co-authors
if: github.event_name == 'pull_request'
@@ -86,14 +144,17 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "[email protected]"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -140,6 +201,11 @@ jobs:
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -184,15 +250,14 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
@@ -225,7 +290,7 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
@@ -328,18 +393,17 @@ jobs:
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
- name: Bundle the droiddeck-esync packs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -f tools/droiddeck-esync/release.env ]; then
. tools/droiddeck-esync/release.env
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
mkdir -p app/src/main/assets/droiddeck-esync
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
rm sync-bundle.tzst
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
for id in $revoked; do
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
@@ -463,6 +527,11 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- uses: actions/download-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
- name: Build GOG Galaxy companion
if: steps.preloads.outputs.cache-hit != 'true'
run: |
@@ -470,6 +539,9 @@ jobs:
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
- name: Build
id: build
env:
@@ -508,6 +580,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
@@ -568,6 +645,14 @@ jobs:
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
@@ -670,8 +755,17 @@ jobs:
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
packs:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
secrets: inherit
announce:
needs: [build, sign]
needs: [build, sign, packs]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
+22
View File
@@ -176,6 +176,27 @@ jobs:
pattern: droiddeck-pr-signed-*
merge-multiple: true
path: signed
- name: The add-on packs main has not published
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ needs.authorize.outputs.run_id }}
run: |
set -euo pipefail
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
mkdir -p pr-packs
for file in packs/*.tar.zst; do
name=$(basename "$file" .tar.zst)
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty')
if [ "$digest" = "sha256:$sha" ]; then
echo "$name: served from DroidDeck-Components $tag"
else
cp "$file" "pr-packs/$tag.tar.zst"
echo "$name: attached to the PR release as $tag.tar.zst"
fi
done
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
@@ -233,6 +254,7 @@ jobs:
first=true
fi
echo "first=$first" >> "$GITHUB_OUTPUT"
cp pr-packs/* signed/ 2>/dev/null || true
gh release upload "$tag" signed/* -R "$repo" --clobber
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
keep=$(cd signed && ls | jq -R . | jq -sc .)
+83
View File
@@ -0,0 +1,83 @@
name: Publish add-on packs
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
# Only main and release builds publish here; a PR test build attaches the packs main has not published
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
on:
workflow_call:
inputs:
run_id:
description: The Build APK run whose packs to publish; empty for the calling run's own
type: string
default: ''
permissions:
contents: read
actions: read
jobs:
publish:
name: publish add-on packs
runs-on: ubuntu-24.04
timeout-minutes: 15
concurrency:
group: publish-packs
cancel-in-progress: false
steps:
- name: The packs (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-packs
path: packs
- name: The packs (another Build APK run)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
- uses: actions/create-github-app-token@v2
id: components-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
REPO: Droid-Deck/DroidDeck-Components
BUILD_RUN: ${{ inputs.run_id || github.run_id }}
run: |
set -euo pipefail
published_digest() {
{ gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$(published_digest "$tag" "$name.tar.zst")
if [ -z "$digest" ]; then
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|| echo "::warning::creating $tag failed; checking whether another run published it"
digest=$(published_digest "$tag" "$name.tar.zst")
fi
if [ "$digest" != "sha256:$sha" ]; then
echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha"
exit 1
fi
echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
done
+11 -1
View File
@@ -63,9 +63,19 @@ jobs:
artifact: droiddeck-release
secrets: inherit
packs:
needs: find
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
with:
run_id: ${{ needs.find.outputs.run_id }}
secrets: inherit
publish:
name: publish the release
needs: [find, sign]
needs: [find, sign, packs]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
+3 -1
View File
@@ -47,10 +47,12 @@ 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). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. 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. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). 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). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. 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. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment.
+33 -16
View File
@@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
return [(name): [sha256: '', size: 0L]]
}
def digest = java.security.MessageDigest.getInstance('SHA-256')
pack.withInputStream { input ->
byte[] buffer = new byte[1 << 20]
int n
while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n)
}
[(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]]
}
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
@@ -119,6 +136,10 @@ android {
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
addonPacks.each { name, pack ->
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
}
ndk {
abiFilters 'arm64-v8a'
@@ -172,6 +193,11 @@ android {
disable 'ExpiredTargetSdkVersion'
}
androidResources {
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
}
packagingOptions {
resources {
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
@@ -179,6 +205,8 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -192,6 +220,8 @@ android {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
@@ -282,22 +312,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
def verifyHandModel = tasks.register('verifyHandModel') {
def models = ['hand_landmarker.task': 'fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1',
'hand_landmarker_lite.task': '173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8']
.collectEntries { name, hash -> [(file("src/main/assets/models/$name")): hash] }
inputs.files(models.keySet())
doLast {
models.each { model, expected ->
def actual = java.security.MessageDigest.getInstance('SHA-256').digest(model.bytes).encodeHex().toString()
if (actual != expected) throw new GradleException("Hand Landmarker model checksum mismatch: ${model.name}")
}
}
}
tasks.named('preBuild').configure { dependsOn(verifyHandModel) }
dependencies {
implementation 'org.opencv:opencv:4.13.0'
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
@@ -316,7 +332,8 @@ dependencies {
implementation 'com.termux.termux-app:terminal-view:0.118.0'
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
implementation 'androidx.webkit:webkit:1.12.1'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
+29 -5
View File
@@ -65,11 +65,6 @@
android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -88,6 +83,31 @@
</intent-filter>
</activity>
<activity-alias
android:name=".LauncherDark"
android:targetActivity=".MainActivity"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherLight"
android:targetActivity=".MainActivity"
android:icon="@mipmap/ic_launcher_light"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".HomeActivity"
android:enabled="false"
@@ -127,6 +147,10 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxLoginActivity" android:exported="false"
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
Binary file not shown.
+14 -14
View File
@@ -11,23 +11,23 @@ add_subdirectory(adrenotools)
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
add_subdirectory(thirdparty/dxbc)
set(WINFG_UPSTREAM "v0.3.0")
set(WINFG_CHAIN_SRC
framegen/winfg/framegen.cpp
framegen/winfg/framegen.hpp
framegen/winfg/record_impl.inc
framegen/winfg/config.hpp
framegen/winfg/embedded_shaders.hpp
framegen/winfg/winfg_engine.cpp
set(DDFG_UPSTREAM "v0.3.0")
set(DDFG_CHAIN_SRC
framegen/ddfg/framegen.cpp
framegen/ddfg/framegen.hpp
framegen/ddfg/record_impl.inc
framegen/ddfg/config.hpp
framegen/ddfg/embedded_shaders.hpp
framegen/ddfg/ddfg_engine.cpp
)
set(WINFG_HASH_INPUT "")
foreach(f ${WINFG_CHAIN_SRC})
set(DDFG_HASH_INPUT "")
foreach(f ${DDFG_CHAIN_SRC})
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h)
string(APPEND WINFG_HASH_INPUT "${_h}")
string(APPEND DDFG_HASH_INPUT "${_h}")
endforeach()
string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}")
string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}")
string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH)
message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})")
add_subdirectory(waylandcomp)
add_subdirectory(questxr)
@@ -131,7 +131,7 @@ struct VkTable {
#include <string>
namespace lsfg { class Engine; }
namespace winfg { class Engine; }
namespace ddfg { class Engine; }
static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
@@ -280,7 +280,7 @@ public:
static constexpr int32_t kFgCaptureGame = -1;
void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0);
void setFrameGenEngine(int kind);
void setWinFgTuning(int model, int perfPreset);
void setDdFgTuning(int model, int perfPreset);
private:
struct WinTex {
@@ -468,12 +468,12 @@ private:
uint64_t fgSourceFrames_ = 0;
std::string lsfgCachePath_;
bool lsfgEngineTried_ = false;
std::unique_ptr<winfg::Engine> winfgEngine_;
bool winfgEngineTried_ = false;
std::unique_ptr<ddfg::Engine> ddfgEngine_;
bool ddfgEngineTried_ = false;
std::atomic<int> fgEngineKind_{0};
std::atomic<int> fgModel_{4};
std::atomic<int> fgPerfPreset_{1};
bool ensureWinFgEngine();
bool ensureDdFgEngine();
bool fgCapsOk() const;
uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; }
@@ -6,7 +6,7 @@
#include <fstream>
#include <sstream>
namespace winfg {
namespace ddfg {
struct Config {
bool enabled = false;
@@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) {
}
static inline std::string conf_path() {
if (const char* p = std::getenv("WIN_FG_CONF")) return p;
if (const char* p = std::getenv("DD_FG_CONF")) return p;
const char* home = std::getenv("HOME");
return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string();
return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string();
}
static inline std::string capture_root(const Config& c) {
if (!c.capDir.empty()) return c.capDir;
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture";
return std::string("/tmp/winfg-capture");
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture";
return std::string("/tmp/ddfg-capture");
}
static inline Config load_config() {
Config c;
c.enabled = envi("WIN_FG_ENABLE", 0) != 0;
if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
c.model = envi("WIN_FG_MODEL", c.model);
c.multiplier = envi("WIN_FG_MULT", c.multiplier);
c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages);
c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset);
if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
c.frIters = envi("WIN_FG_FR_ITERS", c.frIters);
c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda);
c.frDt = envf("WIN_FG_FR_DT", c.frDt);
c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge);
c.frEps = envf("WIN_FG_FR_EPS", c.frEps);
c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale);
c.beta = envf("WIN_FG_BETA", c.beta);
c.lambda = envf("WIN_FG_LAMBDA", c.lambda);
c.epsilon = envf("WIN_FG_EPSILON", c.epsilon);
c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale);
if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW);
c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH);
if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches);
c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize);
c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion);
c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB);
if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd;
if (const char* h = std::getenv("WIN_FG_HUD_RECT")) {
c.enabled = envi("DD_FG_ENABLE", 0) != 0;
if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
c.model = envi("DD_FG_MODEL", c.model);
c.multiplier = envi("DD_FG_MULT", c.multiplier);
c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages);
c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset);
if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
c.frIters = envi("DD_FG_FR_ITERS", c.frIters);
c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda);
c.frDt = envf("DD_FG_FR_DT", c.frDt);
c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge);
c.frEps = envf("DD_FG_FR_EPS", c.frEps);
c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale);
c.beta = envf("DD_FG_BETA", c.beta);
c.lambda = envf("DD_FG_LAMBDA", c.lambda);
c.epsilon = envf("DD_FG_EPSILON", c.epsilon);
c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale);
if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
c.captureW = envi("DD_FG_CAPTURE_W", c.captureW);
c.captureH = envi("DD_FG_CAPTURE_H", c.captureH);
if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches);
c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize);
c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion);
c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB);
if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd;
if (const char* h = std::getenv("DD_FG_HUD_RECT")) {
float r[4] = {0,0,0,0}; int n = 0;
std::string s = h; size_t start = 0;
for (; n < 4; ++n) {
@@ -266,8 +266,8 @@ static inline Config load_config() {
c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3];
}
const char* home = std::getenv("HOME");
if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml");
if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p);
if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml");
if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p);
c.sanitize();
return c;
}
@@ -1,10 +1,10 @@
#include "winfg_engine.h"
#include "winfg_vkd.h"
#include "ddfg_engine.h"
#include "ddfg_vkd.h"
#include "log.hpp"
#include <algorithm>
#include <cstring>
namespace winfg {
namespace ddfg {
namespace {
float alphaFor(uint32_t k, uint32_t n) {
@@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); }
bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev,
uint32_t queueFamily, VkQueue queue) {
if (!winfgVkdInit(table, dd_, id_)) {
WFG_LOGE("native: dispatch incomplete; frame generation unavailable");
if (!ddfgVkdInit(table, dd_, id_)) {
DDFG_LOGE("native: dispatch incomplete; frame generation unavailable");
return false;
}
phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue;
id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_);
if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) {
WFG_LOGE("native: FrameGen::init failed");
DDFG_LOGE("native: FrameGen::init failed");
unavailable_ = true;
return false;
}
WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
return true;
}
@@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) {
WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
unavailable_ = true;
return false;
}
@@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
}
builtW_ = w; builtH_ = h; builtFmt_ = fmt;
history_ = 0; curr_ = 0; frames_ = 0;
WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale);
return true;
}
static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot");
uint32_t Engine::plan(uint32_t capacity) const {
if (!valid() || history_ < 2) return 0;
const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0);
@@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou
dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite);
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_);
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_,
generation, generation > 0);
if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots;
(void)w; (void)h;
@@ -6,7 +6,7 @@
struct VkTable;
namespace winfg {
namespace ddfg {
constexpr uint32_t kMaxGenerations = 3;
@@ -1,14 +1,14 @@
#include "winfg_vkd.h"
#include "ddfg_vkd.h"
#include "../VulkanRendererContext.h"
#include <android/log.h>
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) {
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) {
bool ok = true;
#define COPY(dst, fn) \
do { \
dst.fn = table.fn; \
if (!dst.fn) { \
__android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \
__android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \
"entry point vk" #fn " did not resolve"); \
ok = false; \
} \
@@ -0,0 +1,4 @@
#pragma once
#include "vk_dispatch.hpp"
struct VkTable;
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id);
@@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
#include <cstddef>
namespace winfg { namespace embedded {
namespace ddfg { namespace embedded {
static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x0Line truncated
static const size_t OF3_LUMA_LEN = 481;
static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x0005006Line truncated
@@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b
static const size_t OF3_GM_PREWARP_LEN = 706;
static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,Line truncated
static const size_t OF3_FLOWREG_LEN = 1936;
static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0xLine truncated
static const size_t WFG_SYNTH_LEN = 3082;
static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0Line truncated
static const size_t DDFG_SYNTH_LEN = 3082;
struct Blob { const uint32_t* code; size_t words; };
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT };
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT };
static inline Blob blob(Shader s) {
switch (s) {
case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN };
@@ -35,7 +35,7 @@ static inline Blob blob(Shader s) {
case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN };
case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN };
case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN };
case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN };
case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN };
default: return { nullptr, 0 };
}
}
@@ -4,9 +4,9 @@
#include <cmath>
#include <cstdio>
namespace winfg {
namespace ddfg {
#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0)
#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0)
uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const {
for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i)
@@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id,
if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false;
if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false;
if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false;
if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false;
if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false;
if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters);
return true;
}
@@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) {
if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
ready_ = true;
WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset);
return true;
}
@@ -3,7 +3,7 @@
#include "config.hpp"
#include <vector>
namespace winfg {
namespace ddfg {
class FrameGen {
public:
@@ -12,7 +12,8 @@ public:
void configure(const Config& c);
bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false);
void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
VkImageView outView, float alpha, uint32_t gmSlot = 0);
VkImageView outView, float alpha, uint32_t gmSlot = 0,
uint32_t synthSlot = 0, bool reuseFlow = false);
void destroy();
bool valid() const { return device_ != VK_NULL_HANDLE && ready_; }
const Config& config() const { return cfg_; }
@@ -25,6 +26,7 @@ public:
static constexpr int kGmSlots = 3;
static constexpr int kSynthSlots = 3;
static constexpr int kGmLevel = 3;
static constexpr int kGmThreads = 512;
static constexpr int kGmStride = 32;
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#if defined(__ANDROID__)
#include <android/log.h>
#define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__)
#define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__)
#else
#include <cstdio>
#define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#endif
#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0)
@@ -1,31 +1,34 @@
namespace {
struct ScratchState {
VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr;
VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{};
VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{};
void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{};
std::vector<VkBuffer> flowUbo; std::vector<VkDeviceMemory> flowUboMem; std::vector<void*> flowUboPtr;
VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr;
VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr;
VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{};
VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{};
void* gmUboPtr[winfg::FrameGen::kGmSlots]{};
VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{};
VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{};
void* gmUboPtr[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE;
std::vector<VkDescriptorSet> sDownA, sDownB, sFlow;
VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{};
VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE;
VkDescriptorSet sSynth = VK_NULL_HANDLE;
VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{};
std::vector<VkDescriptorSet> sGmPrewarp[winfg::FrameGen::kGmSlots];
VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{};
VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{};
std::vector<VkDescriptorSet> sGmPrewarp[ddfg::FrameGen::kGmSlots];
VkExtent2D builtFor{0,0};
bool built = false;
bool flowInited = false;
bool flowReady = false;
};
ScratchState g_scratch;
}
static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp,
VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) {
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
@@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS;
}
static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev,
static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev,
VkDescriptorPool pool, VkDescriptorSetLayout layout) {
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout;
VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s;
}
static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) {
VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = &ii;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet
w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3])
out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0;
}
namespace winfg {
namespace ddfg {
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool,
@@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP
b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr;
};
auto& S = g_scratch;
freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr);
for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr);
freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]);
@@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) {
int want = flowFinestForPreset(cfg_.perfPreset);
const int maxFinest = kLevels - 2;
if (want > maxFinest) {
WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
"(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest);
want = maxFinest;
}
@@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) {
const int prevFinest = flowFinest_;
flowFinest_ = want;
const bool live = (flowFinest_ != prevFinest) && ready_;
WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0);
if (!live) return;
@@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) {
dd_->DeviceWaitIdle(device_);
destroyScratch(dd_, device_, descPool_);
if (!onResize(extent_, VK_FORMAT_UNDEFINED, true))
WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
extent_.width, extent_.height);
for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0;
for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; }
gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f;
WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
}
bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
@@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
}
void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
VkImageView outView, float alpha, uint32_t gmSlot) {
VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) {
if (!valid()) return;
if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0;
if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0;
const bool m4 = (cfg_.model == 4);
const Pipe& flowP = m4 ? pFlowM4_ : pFlow_;
const Pipe& expandP = m4 ? pExpandM4_ : pExpand_;
@@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
}
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
if (g_scratch.gmUboPtr[gmSlot]) {
GMUBO g{};
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
if (gmEngaged) {
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
// Every generation of one real frame is recorded into the same command buffer, so the flow
// between prev and curr is computed once and each generation gets its own synth UBO and set.
if (reuseFlow && !g_scratch.flowReady) return;
if (!reuseFlow) {
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
if (g_scratch.gmUboPtr[gmSlot]) {
GMUBO g{};
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
if (gmEngaged) {
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
}
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
{
static int lastEng = -1;
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
lastEng = gmEngaged ? 1 : 0;
{
static int lastEng = -1;
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
lastEng = gmEngaged ? 1 : 0;
}
}
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
}
SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0,
cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1};
if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su));
if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su));
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot];
wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline);
@@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
};
auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); };
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
{
VkClearColorValue zero{};
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
if (!reuseFlow) {
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
{
VkClearColorValue zero{};
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
for (int l = flowFinest_; l < kLevels; ++l) {
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.image = flowLvl_[l].image; b.subresourceRange = rng;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
}
}
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
bar(pyrA_[0]); bar(pyrB_[0]);
for (int l = 1; l < kLevels; ++l) {
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
bar(pyrA_[l]); bar(pyrB_[l]);
}
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
for (int l = flowFinest_; l < kLevels; ++l) {
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.image = flowLvl_[l].image; b.subresourceRange = rng;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
bar(pyrAs_[l]);
}
for (int l = kLevels - 1; l >= flowFinest_; --l) {
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
bar(flowLvl_[l]);
}
}
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
bar(pyrA_[0]); bar(pyrB_[0]);
for (int l = 1; l < kLevels; ++l) {
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
bar(pyrA_[l]); bar(pyrB_[l]);
}
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
for (int l = flowFinest_; l < kLevels; ++l) {
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
bar(pyrAs_[l]);
}
for (int l = kLevels - 1; l >= flowFinest_; --l) {
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
bar(flowLvl_[l]);
}
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
if (frEngaged) {
const int F = flowFinest_;
const VkExtent2D fe = levelExtent(extent_, F);
if (g_scratch.flowRegUboPtr) {
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
}
FrameGen::Img* last = nullptr;
for (int it = 0; it < cfg_.frIters; ++it) {
VkDescriptorSet set; FrameGen::Img* outImg;
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
bar(*outImg);
last = outImg;
}
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
VkImageCopy region{};
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.extent = {fe.width, fe.height, 1};
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, &region);
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb2, 0, nullptr, 0, nullptr);
}
{
static int lastFr = -1;
int fe = frEngaged ? 1 : 0;
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
lastFr = fe;
}
}
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
if (frEngaged) {
const int F = flowFinest_;
const VkExtent2D fe = levelExtent(extent_, F);
if (g_scratch.flowRegUboPtr) {
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
}
FrameGen::Img* last = nullptr;
for (int it = 0; it < cfg_.frIters; ++it) {
VkDescriptorSet set; FrameGen::Img* outImg;
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
bar(*outImg);
last = outImg;
}
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
VkImageCopy region{};
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.extent = {fe.width, fe.height, 1};
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, &region);
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb2, 0, nullptr, 0, nullptr);
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
bar(flowExpA_); bar(flowExpB_);
g_scratch.flowReady = true;
}
{
static int lastFr = -1;
int fe = frEngaged ? 1 : 0;
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
lastFr = fe;
}
}
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
bar(flowExpA_); bar(flowExpB_);
disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0));
disp(pSynth_, synthSet, levelExtent(extent_, 0));
}
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
@@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
S = ScratchState{};
const auto GEN = VK_IMAGE_LAYOUT_GENERAL;
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr);
for (int s = 0; s < FrameGen::kSynthSlots; ++s)
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr);
if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); }
makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
@@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN);
}
S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout);
wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO));
wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
for (int s = 0; s < FrameGen::kSynthSlots; ++s) {
S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout);
wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO));
wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
}
S.builtFor = ext; S.built = true;
return true;
@@ -3,7 +3,7 @@
#include <vulkan/vk_layer.h>
#include <cstring>
namespace winfg {
namespace ddfg {
static inline void* dispatch_key(void* handle) { return *reinterpret_cast<void**>(handle); }
-12
View File
@@ -1,12 +0,0 @@
#pragma once
#if defined(__ANDROID__)
#include <android/log.h>
#define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__)
#define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__)
#else
#include <cstdio>
#define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#endif
#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0)
@@ -1,4 +0,0 @@
#pragma once
#include "vk_dispatch.hpp"
struct VkTable;
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id);
+8 -8
View File
@@ -43,10 +43,10 @@ target_include_directories(droiddeckwayland PRIVATE
)
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg)
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp)
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)")
target_sources(droiddeckwayland PRIVATE
src/framegen_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
@@ -66,9 +66,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp
)
target_include_directories(droiddeckwayland PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/..
@@ -77,8 +77,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
target_compile_definitions(droiddeckwayland PRIVATE
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
DDFG_UPSTREAM="${DDFG_UPSTREAM}"
DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}"
)
target_link_libraries(droiddeckwayland dxbc atomic)
else()
+34 -1
View File
@@ -218,6 +218,8 @@ struct surface {
enum surface_role role;
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
uint32_t *idle_px;
int idle_w, idle_h;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -899,8 +901,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer)
g_cursor_shown = s;
}
static void surface_forget_idle_px(struct surface *s) {
free(s->idle_px);
s->idle_px = NULL;
s->idle_w = s->idle_h = 0;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer);
if (shm) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
int32_t stride = wl_shm_buffer_get_stride(shm);
uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4
? realloc(s->idle_px, (size_t)w * h * 4) : NULL;
if (px) {
wl_shm_buffer_begin_access(shm);
const uint8_t *src = wl_shm_buffer_get_data(shm);
for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4);
wl_shm_buffer_end_access(shm);
s->idle_px = px;
s->idle_w = w;
s->idle_h = h;
}
}
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
@@ -927,6 +951,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) {
}
static void surface_drop_idle(struct surface *s) {
surface_forget_idle_px(s);
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
@@ -1055,6 +1080,7 @@ static void surface_resource_destroy(struct wl_resource *r) {
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
surface_forget_idle_px(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -2042,6 +2068,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
} else if (s->idle_px) {
cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy);
g_cursor_shown = s;
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -2593,7 +2622,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy);
if (!(k && k->is_lock))
// A button-only input must not replay a cached absolute position. A game
// may have warped its cursor since the last motion (before taking lock,
// or while releasing it), so that replay can become an unintended pan.
// Absolute touch events still carry their position, including on clicks.
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
send_relative_motion(client, dx, dy);
for_each_pointer_of(client, sp) {
@@ -86,7 +86,7 @@ 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;
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG;
atomic_store(&g_kind, kind);
atomic_store(&g_failed_kind, -1);
atomic_store(&g_cfg_dirty, 1);
@@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) {
atomic_store(&g_cfg_dirty, 1);
}
void vkp_framegen_set_winfg_tuning(int model, int perf_preset) {
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) {
atomic_store(&g_model, model == 3 ? 3 : 4);
atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset);
atomic_store(&g_cfg_dirty, 1);
@@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled);
g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier;
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s",
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s",
fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(),
fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable");
fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable");
}
void vkp_framegen_device_lost(void) { g_dev_lost = 1; }
@@ -6,14 +6,14 @@
#endif
#include <vulkan/vulkan.h>
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 };
#define VKP_FG_MAX_GENERATIONS 3
void vkp_framegen_set_engine(int kind);
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
void vkp_framegen_set_lsfg_cache_path(const char *path);
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
void vkp_framegen_set_winfg_tuning(int model, int perf_preset);
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset);
int vkp_framegen_problem(void);
const char *vkp_framegen_caps_reason(void);
void vkp_framegen_stats(float out[6]);
@@ -12,7 +12,7 @@
#include "lsfg/lsfg_engine.h"
#include "lsfg/lsfg_probe.h"
#include "lsfg/lsfg_vkd.h"
#include "winfg/winfg_engine.h"
#include "ddfg/ddfg_engine.h"
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
@@ -30,7 +30,7 @@ uint32_t g_qfam = 0;
bool g_probed = false, g_ready = false;
std::unique_ptr<lsfg::Engine> g_lsfg;
std::unique_ptr<winfg::Engine> g_winfg;
std::unique_ptr<ddfg::Engine> g_ddfg;
int g_kind = -1;
char g_build[96] = "";
@@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) {
fge_stop();
if (!g_ready) return -1;
if (kind == 1) {
auto e = std::make_unique<winfg::Engine>();
auto e = std::make_unique<ddfg::Engine>();
if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) {
ELOGE("winfg-native: engine init failed");
ELOGE("ddfg-native: engine init failed");
return -1;
}
g_winfg = std::move(e);
g_ddfg = std::move(e);
g_kind = 1;
#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH)
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH);
#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH)
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH);
#else
std::snprintf(g_build, sizeof(g_build), "embedded chain");
#endif
@@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) {
}
void fge_stop(void) {
if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
g_lsfg.reset();
g_winfg.reset();
g_ddfg.reset();
g_kind = -1;
}
int fge_unavailable(void) {
if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0;
if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0;
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
}
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);
if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale);
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) {
if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0;
if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0;
if (!g_lsfg) return 0;
g_lsfg->setGuestExtent(w, h);
return g_lsfg->prepare(w, h, fmt) ? 1 : 0;
}
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0;
if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0;
if (!g_lsfg) return 0;
g_lsfg->setPresentedRate(presented_rate);
return g_lsfg->plan(capacity, source_frames);
}
void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) {
if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; }
if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; }
if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens);
}
void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view,
uint32_t w, uint32_t h) {
if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h);
}
@@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
}
}
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return g_build; }
}
@@ -34,5 +34,5 @@ void fge_forget_targets(void) {}
void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
*accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f;
}
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return "stub"; }
@@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz,
jint model, jint perfPreset) {
(void)env; (void)clazz;
vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset);
}
JNIEXPORT jint JNICALL
@@ -20,12 +20,17 @@ class App : Application() {
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
if (process == "$packageName:xboxlogin") {
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
return
}
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
prefetchAddonPacks()
GpuClockPin.clearLeftover(this)
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
@@ -35,5 +40,24 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
com.droiddeck.launcher.handtracking.HandTracking.load(app)
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
}, "addon-packs").start()
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
object LauncherIcon {
const val DARK = "dark"
const val LIGHT = "light"
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
val options: List<Pair<String, Int>> = listOf(
DARK to R.string.setup_launcher_icon_dark,
LIGHT to R.string.setup_launcher_icon_light,
)
private fun component(context: Context, style: String) =
ComponentName(context, aliases.getValue(style))
private fun enabled(context: Context, style: String): Boolean =
when (context.packageManager.getComponentEnabledSetting(component(context, style))) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK
else -> false
}
fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK
fun activity(context: Context): ComponentName = component(context, current(context))
fun set(context: Context, style: String) {
if (style !in aliases || style == current(context)) return
val pm = context.packageManager
pm.setComponentEnabledSetting(component(context, style),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
aliases.keys.filter { it != style }.forEach {
pm.setComponentEnabledSetting(component(context, it),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
}
}
@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
@@ -164,6 +165,7 @@ class MainActivity : ComponentActivity() {
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
@@ -484,6 +486,20 @@ class MainActivity : ComponentActivity() {
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
}
private fun switchLauncherIcon(style: String): Boolean {
val before = LauncherIcon.current(this)
LauncherIcon.set(this, style)
launcherIcon = LauncherIcon.current(this)
if (launcherIcon == before) return false
// Android finishes activities started through the alias that was just disabled.
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(LauncherIcon.activity(this))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher"))
finishAndRemoveTask()
return true
}
private fun readGameIntent(request: Intent) {
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
@@ -739,6 +755,7 @@ class MainActivity : ComponentActivity() {
downloadPrefix = DownloadSource.custom(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
@@ -799,6 +816,7 @@ class MainActivity : ComponentActivity() {
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
@@ -952,6 +970,7 @@ class MainActivity : ComponentActivity() {
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherIcon = { style -> switchLauncherIcon(style) },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
@@ -1106,6 +1125,13 @@ class MainActivity : ComponentActivity() {
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
@@ -1262,6 +1288,7 @@ class MainActivity : ComponentActivity() {
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1515,6 +1542,27 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
@@ -1668,12 +1716,12 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
onImportBootMovie = {
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
},
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
@@ -1882,7 +1930,7 @@ class MainActivity : ComponentActivity() {
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
steamGames = steamGames.filterNot(Library::fromSteamClient)
steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) }
}
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
if (showComponents) drivers.refreshDrivers()
@@ -1916,12 +1964,13 @@ class MainActivity : ComponentActivity() {
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
if (ready) {
val quick = runCatching { Library.steamGames(this) }.getOrNull()
val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty()
if (quick != null) ui.post {
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
val before = steamGames.map { it.gameId }.toSet()
val fresh = quick.filter { it.gameId !in before }
if (fresh.isNotEmpty()) {
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId }
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
}
}
@@ -1960,10 +2009,14 @@ class MainActivity : ComponentActivity() {
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending || installed != null && release?.version == installed) {
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -2044,7 +2097,7 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
com.droiddeck.launcher.ui.Motion.refresh(this)
recreate()
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
}
}, "reset-settings").start()
}
@@ -75,11 +75,13 @@ import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadMouse
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.DirectTouchHold
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.input.TouchscreenGestures
import com.droiddeck.launcher.input.ScrollTouchGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
@@ -188,7 +190,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
private lateinit var directTouchHold: DirectTouchHold
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private lateinit var touchscreenGestures: TouchscreenGestures
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var gestureSettingsOpen by mutableStateOf(false)
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
private var gesturePointerX = 0f
private var gesturePointerY = 0f
private var gesturePointerReady = false
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
private val gestureKeysDown = mutableSetOf<Int>()
private lateinit var scrollTouch: ScrollTouchGestures
private lateinit var padMouse: PadMouse
@Volatile private var padMouseOn = false
@@ -209,6 +222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
gesturePointerReady = false
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
padMouseCheckedAt = 0L
@@ -483,7 +497,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
touchscreenGestures = TouchscreenGestures(
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
)
refreshGestureConfig()
// A real touch target lets FrameLayout split fingers between the
// game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
}
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn
@@ -500,12 +527,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -635,6 +665,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
@@ -643,6 +674,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
LaunchedEffect(AgentGuest.focusRevision) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
else {
val snapshot = AgentGuest.focus(this@SessionActivity)
val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L }
val windows = snapshot?.optJSONArray("focusableApps")
focused?.toString() ?: windows?.let { values ->
(0 until values.length()).map { values.optLong(it) }
.filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString()
}
}
}
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
}
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
cancelTouchGestures()
}
}
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
)
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
@@ -818,6 +875,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -1296,6 +1354,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextTouchMode = SessionPrefs.touchMode(this)
if (touchMode != nextTouchMode) {
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
@@ -1320,17 +1379,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
@@ -1340,6 +1400,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installRuntime()
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
}
if (problem == null && resume && !SessionState.stopRequested) {
SessionState.installing = "package"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
?.let { getString(R.string.session_package_resume_failed, it) }
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
}
if (problem == null && desktop && !SessionState.stopRequested) {
SessionState.installing = "desktop"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
@@ -1364,7 +1431,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -1412,9 +1483,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post {
loading.percent = percent
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
}
}
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return getString(R.string.session_runtime_catalog_unreachable)
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
@@ -1537,6 +1616,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceW = width
surfaceH = height
if (resized) {
cancelTouchGestures()
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
followWindow(width, height)
@@ -1555,7 +1635,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun followChoice(): Boolean {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
}
@@ -1571,6 +1651,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
@@ -1579,12 +1660,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Hiding the phone surface after a handoff delivers a late destruction
// callback. It must not reset the new output's size or attachment.
if (holder !== outputHolder()) return
cancelTouchGestures()
surfaceW = 0
surfaceH = 0
glassesOutputActive = false
CompositorHost.detach(holder.surface)
}
private fun cancelTouchGestures() {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
@@ -1594,8 +1684,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
private fun displayMode(): String = SessionPrefs.displayMode(this,
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
private fun outputSize(): Pair<Int, Int> {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
@@ -1609,8 +1702,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
glassesScreen?.hz?.takeIf { it > 1f }?.let { return it }
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
val running = display?.refreshRate?.takeIf { it > 1f }
val hz = listOfNotNull(requested, running).minOrNull() ?: 60f
val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY
val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap)
return if (capped > 1f) capped else 60f
@@ -1714,7 +1808,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
Thread({
val hint = sessionEndHint(SessionState.logFile)
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
@@ -1733,6 +1827,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
@@ -1751,6 +1846,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.post { sessionOverlay.requestFocus() }
}
private fun repairRuntime() {
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
closeAtOnce = true
finish()
startActivity(again)
}
private fun retrySession() {
val again = Intent(intent)
closeAtOnce = true
@@ -1758,7 +1860,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
startActivity(again)
}
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
private fun runtimeDamageHint(): EndHint? {
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
if (broken.isEmpty()) return null
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
}
private fun sessionEndHint(log: File?): EndHint? {
if (log == null || !log.isFile) return null
@@ -1982,6 +2091,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.dispatchKeyEvent(event)
}
private fun refreshGestureConfig() {
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
gesturePointerReady = false
recognizedGesture = null
}
private val gestureOutput = object : TouchscreenGestures.Output {
override fun moveTo(x: Float, y: Float) {
val rect = drawnRect() ?: return
if (pointerLocked) {
if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true)
} else movePointer(x, y)
gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right)
gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom)
gesturePointerReady = true
cursorVisible = false
}
override fun moveBy(dx: Float, dy: Float, relative: Boolean) {
val rect = drawnRect() ?: return
if (relative || pointerLocked) {
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta(dx * scale, dy * scale)
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
}
override fun button(code: Int, down: Boolean) {
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
}
override fun prepareRelativePan() {
if (pointerLocked) return
val rect = drawnRect() ?: return
// Gamescope retains the last absolute position while a game can warp
// its own cursor to the center. Its first unlocked motion must start
// from that same origin, before the game switches to pointer lock.
moveTo(rect.centerX(), rect.centerY())
}
override fun key(code: Int, down: Boolean) {
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
override fun gesture(gesture: TouchscreenGestures.Gesture) {
recognizedGesture = gesture
uiHandler.removeCallbacks(clearRecognizedGesture)
uiHandler.postDelayed(clearRecognizedGesture, 1200)
}
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
@@ -2272,6 +2430,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
return when {
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
else -> SessionPrefs.TOUCH_DIRECT
}
@@ -2284,7 +2443,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
touchpad.sensitivity = 1.15f * speed
}
if (next == routedTouchMode) return
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
gesturePointerReady = false
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
@@ -2319,6 +2481,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
return touchscreenGestures.onTouch(event)
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -2352,6 +2519,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
return true
}
if (directTouchHold.onTouch(event)) return true
fun sendTouch(action: Int, index: Int) {
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
@@ -2501,6 +2669,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
controls.visibility = View.GONE
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
return
}
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
@@ -2513,6 +2682,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (steam == null) {
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
}
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
@@ -2547,7 +2717,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
private fun pipBounds(): android.graphics.Rect {
@@ -2575,6 +2745,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releasePads()
touchpad.cancel()
scrollTouch.cancel()
directTouchHold.cancel()
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
keyboard?.hide()
sessionClipboard.stop()
@@ -2701,6 +2872,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
uiHandler.removeCallbacks(clearRecognizedGesture)
gestureSettingsOpen = false
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -2950,7 +3126,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun glassesOutputSize(): Pair<Int, Int>? {
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice)
@@ -3076,6 +3252,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
if (runtimeRemovalBlocked) return
if (!hasFocus) {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
}
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
@@ -3186,6 +3366,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private var startedScreens = 0
@@ -0,0 +1,137 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
/**
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
* was made with install, whichever host serves them.
*/
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
data object Failed : State
data object Ready : State
}
private val mutableState = MutableStateFlow<State>(State.Missing)
val state: StateFlow<State> = mutableState.asStateFlow()
private val lock = Any()
val available: Boolean get() = sha256.isNotEmpty()
private val tag get() = "pack-$name-${sha256.take(16)}"
private val urls get() = listOfNotNull(
"https://github.com/$REPO/releases/download/$tag/$name.tar.zst",
BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" },
)
private fun root(context: Context) = File(context.filesDir, "addons/$name")
/** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), sha256)
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
fun refresh(context: Context): State {
val now = mutableState.value
if (now is State.Downloading) return now
return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing)
.also { mutableState.value = it }
}
/** Starts the download in the background unless the pack is already present or on its way. */
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext
synchronized(lock) {
if (installed(app)) { mutableState.value = State.Ready; return }
if (mutableState.value is State.Downloading) return
mutableState.value = State.Downloading(0f)
}
scope.launch {
val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false)
mutableState.value = if (ok) State.Ready else State.Failed
if (ok) onReady(app)
}
}
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true
ensure(context)
while (running()) {
val now = state.value
onState(now)
when (now) {
State.Ready -> return installed(context)
State.Failed -> return false
else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false }
}
}
return false
}
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
val archive = File(root, "$sha256.tar.zst")
val downloaded = urls.any { url ->
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
.also { if (!it) archive.delete() }
}
if (!downloaded) return false
val staging = File(root, "$sha256.partial")
staging.deleteRecursively()
try {
if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false
check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" }
File(staging, COMPLETE).writeText(sha256)
val target = dir(context)
target.deleteRecursively()
check(staging.renameTo(target)) { "could not move the $name pack into place" }
} finally {
staging.deleteRecursively()
archive.delete()
}
root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() }
return true
}
companion object {
private const val TAG = "AddonPack"
private const val REPO = "Droid-Deck/DroidDeck-Components"
private const val PR_REPO = "Droid-Deck/DroidDeck-CI"
const val COMPLETE = ".complete"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@JvmField
val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE,
listOf("libproot.so", "libproot-loader.so"))
@JvmField
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
}
}
@@ -200,6 +200,7 @@ object DeviceReport {
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 "")
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
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)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
@@ -224,6 +225,9 @@ object DeviceReport {
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
k("Runtime ready", LinuxRuntime.isInstalled(context))
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
k("Runtime root", LinuxRuntime.rootDir(context).path)
h("Settings in effect")
@@ -204,8 +204,10 @@ object AddedGames {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
val entries = AddedExes.list(context)
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
@@ -220,6 +222,24 @@ object AddedGames {
return null
}
private fun currentExe(context: Context, entries: List<AddedExes.Entry>, folder: File): File? =
SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File)
?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) }
?: rank(folder).exes.firstOrNull()
private fun sameGame(folder: File, current: File?, pick: File): Boolean {
val base = canonical(folder)
if (current == null || pick.parentFile?.let(::canonical) == base) return true
val known = canonical(current)
if (!inside(known, base) || known.substringBeforeLast('/') == base) return true
val picked = canonical(pick)
fun top(p: String) = p.removePrefix("$base/").substringBefore('/')
fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() }
return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) }
}
private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$")
fun addExe(context: Context, exe: File): AddResult {
val app = context.applicationContext
if (!exe.isFile) return AddResult.NotFound
@@ -228,7 +248,10 @@ object AddedGames {
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
}
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
if (guestPath(app, exe) == null) {
(owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) }
if (guestPath(app, exe) == null) return AddResult.Unreachable
}
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
@@ -243,6 +266,8 @@ object AddedGames {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
val app = context.applicationContext
// Binds are mounted at session start, so a new one reaches the session from the next start.
if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
var already = 0
@@ -266,6 +291,20 @@ object AddedGames {
return FolderResult(added, already)
}
@Synchronized
internal fun bindFolder(context: Context, dir: File) {
if (!dir.isDirectory || !dir.canRead()) return
val binds = SessionPrefs.gameFolderBinds(context)
if (binds.any { it.first == dir.path }) return
val taken = binds.map { it.second }.toSet()
val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest
var guest = base
var n = 2
while (guest in taken) guest = "$base-${n++}"
SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest))
Log.i(TAG, "games: bound $dir at $guest")
}
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
GamesFolderMigration.run(context)
@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.LauncherIcon
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import java.io.File
@@ -23,7 +24,7 @@ object GameShortcuts {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setActivity(LauncherIcon.activity(context))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
@@ -72,6 +72,7 @@ object Library {
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"")
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
@@ -137,13 +138,35 @@ object Library {
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
val root = SessionPrefs.activeSteamRoot(context)
val cache = File(root, "appcache/librarycache")
val concealed = concealedSteamApps(root)
return cache.listFiles().orEmpty().mapNotNull { dir ->
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
}
}
/** Apps the user hid (the "hidden" collection) or marked private in Steam. */
internal fun concealedSteamApps(root: File): Set<Long> {
val config = AddedGames.accountConfig(root) ?: return emptySet()
val hidden = runCatching {
val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText())
val entry = (0 until entries.length()).map { entries.getJSONArray(it) }
.firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1)
if (entry == null || entry.optBoolean("is_deleted")) emptySet() else {
val collection = org.json.JSONObject(entry.optString("value"))
fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty()
ids("added").toSet() - ids("removed").toSet()
}
}.getOrDefault(emptySet())
val private = runCatching {
PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1)
?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet()
}.getOrNull().orEmpty()
return hidden + private
}
fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM
fun launchableGames(
@@ -155,9 +178,23 @@ object Library {
var steam = steamGames(context, strictRead, arch)
val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch)
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
val claimed = claimedFolders(steam)
val own = list.filter { g ->
g.steamAppId != null || claimed.isEmpty() ||
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
}
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
}
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
val dir = g.gameFiles ?: return@flatMap emptyList()
listOfNotNull(
runCatching { dir.canonicalPath }.getOrNull(),
runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(),
)
}.toSet()
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
@@ -153,7 +153,7 @@ object SteamAppInfo {
return String(bytes, Charsets.UTF_8)
}
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
internal fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
@@ -1,8 +1,10 @@
package com.droiddeck.launcher.glasses
internal enum class HandModel(val asset: String, val sha256: String) {
FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
import com.droiddeck.launcher.handtracking.TrackingPack
internal enum class HandModel(val file: String, val sha256: String) {
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
companion object {
val DEFAULT = FULL
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.handtracking.TrackingPack
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
@@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
} }
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
val bytes=context.assets.open(selected.asset).use { it.readBytes() }
val bytes=TrackingPack.file(context,selected.file).readBytes()
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset)
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() })
.setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f)
.setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build())
@@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
}
private fun loop() {
try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return }
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
if(state is AddonPack.State.Downloading)
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
}
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
var grayL=Mat();var grayR=Mat();val rgba=Mat()
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
@@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
var rate=0f
try {
check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) }
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
joyConFlow=StereoJoyConFlow()
while(running) {
while(true) {
@@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
object FrameGen {
const val ENGINE_OFF = "off"
const val ENGINE_WINFG = "winfg"
const val ENGINE_DDFG = "ddfg"
private const val LEGACY_ENGINE_WINFG = "winfg"
const val ENGINE_LSFG = "lsfg"
private const val WINFG_PERF_PRESET = 2
private const val WINFG_MODEL = 4
private const val FLOW_SCALE_WINFG = 0.60f
private const val DDFG_PERF_PRESET = 2
private const val DDFG_MODEL = 4
private const val FLOW_SCALE_DDFG = 0.60f
private const val FLOW_SCALE_LSFG = 0.80f
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
@@ -29,7 +30,7 @@ object FrameGen {
fun mode(context: Context): Mode {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: 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))
@@ -43,7 +44,7 @@ object FrameGen {
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_DDFG -> context.getString(R.string.frame_gen_ddfg, 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)
@@ -68,10 +69,10 @@ object FrameGen {
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
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)
ENGINE_DDFG -> {
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG)
WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET)
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
}
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
@@ -11,8 +11,10 @@ object TpUbwcHint {
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
internal fun features(model: Int, systemDriverVersion: String?): String? {
// Samsung's One UI firmware sets it whatever blob version it reports.
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
if (model != 740) return null
if (samsung) return "enable_tp_ubwc_flag_hint=1"
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
}
@@ -20,11 +22,12 @@ object TpUbwcHint {
@Synchronized
fun forThisDevice(): String? {
if (resolved) return cached
val model = GpuInfo.detect().model
val gpu = GpuInfo.detect()
val model = gpu.model
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
cached = features(model, version)
cached = features(model, version, gpu.oneUi8Gen2)
resolved = true
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
return cached
}
@@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executors
import kotlin.math.abs
@@ -188,9 +186,8 @@ internal class HandCamera(
private fun prepare() {
if (stopped) return
runCatching {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
val model = TrackingPack.model(context, TrackingPack.POSE)
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO)
@@ -305,7 +302,6 @@ internal class HandCamera(
companion object {
private const val TAG = "HandCamera"
private const val MODEL = "handtracking/pose_landmarker_lite.task"
private const val ANALYSIS_WIDTH = 960
private const val ANALYSIS_HEIGHT = 720
private const val MIN_CONFIDENCE = 0.5f
@@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.hypot
import kotlin.math.max
@@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
companion object {
private const val MODEL = "handtracking/hand_landmarker.task"
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
@@ -153,10 +150,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
private const val MIN_CONFIDENCE = 0.4f
fun open(context: Context): HandModel {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
val model = TrackingPack.model(context, TrackingPack.HAND)
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(mode)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -16,9 +17,10 @@ internal object HandTracking {
val side: String? = null,
val permitted: Boolean = false,
val sides: Set<String> = emptySet(),
val downloaded: Boolean = false,
) {
val usable: Boolean get() = sides.isNotEmpty()
val ready: Boolean get() = enabled && permitted && usable
val ready: Boolean get() = enabled && permitted && usable && downloaded
}
private val state = MutableStateFlow(Settings())
@@ -38,8 +40,11 @@ internal object HandTracking {
side = prefs.getString(SIDE, null),
permitted = permitted(context),
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
downloaded = TrackingPack.installed(context),
)
state.value = next
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
TrackingPack.ensure(context)
return next
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.handtracking
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.AddonPack
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val TAG = "TrackingPack"
private val pack = AddonPack.TRACKING
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
@Volatile private var nativeLoaded = false
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
fun file(context: Context, name: String): File = pack.file(context, name)
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import kotlin.math.abs
class DirectTouchHold(
private val slopPx: Float,
private val listener: PointerGestures.Listener,
private val cancelTouch: () -> Unit,
) {
private val handler = Handler(Looper.getMainLooper())
private var x = 0f
private var y = 0f
private var consumed = false
private var pressed = false
private val release = Runnable {
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
pressed = false
}
private val hold = Runnable {
cancelTouch()
consumed = true
listener.onLongPress()
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
pressed = true
handler.postDelayed(release, 50L)
}
fun cancel() {
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
release.run()
consumed = false
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
cancel()
x = event.x; y = event.y
handler.postDelayed(hold, 500L)
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
MotionEvent.ACTION_CANCEL -> cancel()
}
return consumed
}
}
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
object GestureKeys {
val codes: Map<String, Int> = linkedMapOf(
"ESC" to 1,
"1" to 2,
"2" to 3,
"3" to 4,
"4" to 5,
"5" to 6,
"6" to 7,
"7" to 8,
"8" to 9,
"9" to 10,
"0" to 11,
"MINUS" to 12,
"EQUAL" to 13,
"BKSP" to 14,
"TAB" to 15,
"Q" to 16,
"W" to 17,
"E" to 18,
"R" to 19,
"T" to 20,
"Y" to 21,
"U" to 22,
"I" to 23,
"O" to 24,
"P" to 25,
"BRACKET_LEFT" to 26,
"BRACKET_RIGHT" to 27,
"ENTER" to 28,
"CTRL_L" to 29,
"A" to 30,
"S" to 31,
"D" to 32,
"F" to 33,
"G" to 34,
"H" to 35,
"J" to 36,
"K" to 37,
"L" to 38,
"SEMICOLON" to 39,
"APOSTROPHE" to 40,
"GRAVE" to 41,
"SHIFT_L" to 42,
"BACKSLASH" to 43,
"Z" to 44,
"X" to 45,
"C" to 46,
"V" to 47,
"B" to 48,
"N" to 49,
"M" to 50,
"COMMA" to 51,
"PERIOD" to 52,
"SLASH" to 53,
"SHIFT_R" to 54,
"KP_MULTIPLY" to 55,
"ALT_L" to 56,
"SPACE" to 57,
"CAPS_LOCK" to 58,
"F1" to 59,
"F2" to 60,
"F3" to 61,
"F4" to 62,
"F5" to 63,
"F6" to 64,
"F7" to 65,
"F8" to 66,
"F9" to 67,
"F10" to 68,
"NUM_LOCK" to 69,
"SCROLL_LOCK" to 70,
"KP_7" to 71,
"KP_8" to 72,
"KP_9" to 73,
"KP_SUBTRACT" to 74,
"KP_4" to 75,
"KP_5" to 76,
"KP_6" to 77,
"KP_ADD" to 78,
"KP_1" to 79,
"KP_2" to 80,
"KP_3" to 81,
"KP_0" to 82,
"KP_DEL" to 83,
"F11" to 87,
"F12" to 88,
"KP_ENTER" to 96,
"CTRL_R" to 97,
"KP_DIVIDE" to 98,
"PRTSCN" to 99,
"ALT_R" to 100,
"HOME" to 102,
"UP" to 103,
"PRIOR" to 104,
"LEFT" to 105,
"RIGHT" to 106,
"END" to 107,
"DOWN" to 108,
"NEXT" to 109,
"INSERT" to 110,
"DEL" to 111,
)
@JvmStatic fun code(name: String): Int? = codes[name]
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.input
import android.content.Context
/** Global defaults with optional overrides for the currently focused game. */
object GesturePrefs {
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game")
fun read(context: Context, game: String? = null): TouchGestureConfig {
val p = prefs(context)
val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null)
return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json)
}
fun save(context: Context, game: String?, config: TouchGestureConfig) {
prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply()
}
fun clear(context: Context, game: String?) {
prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply()
}
}
@@ -2,10 +2,14 @@ package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.SteamAppInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Locale
object SteamTouchConfig {
private const val TAG = "SteamTouch"
@@ -125,17 +129,37 @@ object SteamTouchConfig {
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
fun gameName(context: Context, appId: Int): String? = try {
fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try {
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
(kv.child("AppState") ?: kv).string("name")
} catch (_: Exception) {
null
}
fun steamKey(context: Context, appId: Int): String =
if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT)
?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId)
else steamId(appId)
private fun shortcutName(context: Context, appId: Int): String? {
val account = accountId(context) ?: return null
return try {
val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes()
val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null)
(root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()
?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId }
?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
}
fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context)
fun configDir(context: Context): File? {
@@ -158,10 +182,11 @@ object SteamTouchConfig {
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
val file = dir?.let { deviceConfig(context, it, appId) }
?: dir?.let { autosaveConfig(it, appId) }
val key = steamKey(context, appId)
val file = dir?.let { deviceConfig(context, it, key) }
?: dir?.let { autosaveConfig(it, key) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
?: dir?.let { configsetConfig(context, it, key) }
if (file == null) {
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
return Config(null, null, null, emptyMap())
@@ -193,7 +218,7 @@ object SteamTouchConfig {
}
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
if (touchIndex == null) null else {
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""")
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
}
@@ -201,22 +226,22 @@ object SteamTouchConfig {
null
}
private fun autosaveConfig(dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
private fun autosaveConfig(dir: File, key: String): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key)
if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null
} catch (_: Exception) {
null
}
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key)
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key)
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key)
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf")
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
@@ -333,6 +358,11 @@ object SteamTouchConfig {
}.sortedBy { it.type }
}
fun remapHeld(held: Map<Int, Int?>, next: List<Element>): Map<Int, Element> {
val byType = next.associateBy { it.type }
return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap()
}
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
@@ -359,7 +389,8 @@ object SteamTouchConfig {
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val key = steamKey(context, appId)
val target = File(dir, "$key/controller_mobile_touch.vdf")
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
val written = sets.map { (id, elements) ->
@@ -377,27 +408,27 @@ object SteamTouchConfig {
val tmp = File(target.path + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
dropDeviceEntry(dir, appId)
selectAutosave(dir, key)
dropDeviceEntry(dir, key)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
private fun dropDeviceEntry(dir: File, appId: Int) {
private fun dropDeviceEntry(dir: File, key: String) {
val file = File(dir, DEVICE_CONFIGSET)
if (!file.isFile) return
val text = file.readText()
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
if (!block.containsMatchIn(text)) return
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
}
private fun selectAutosave(dir: File, appId: Int) {
private fun selectAutosave(dir: File, key: String) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" }
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
}
@@ -154,7 +154,7 @@ class SteamTouchControls(
actionSet = state.actionSet
layers = state.layers
appId = app
rebuild()
rebuild(keepHeld = true)
}
}
@@ -175,7 +175,7 @@ class SteamTouchControls(
refreshState()
}
private fun rebuild() {
private fun rebuild(keepHeld: Boolean = false) {
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options()
if (nextOptions != options) { options = nextOptions; onOptions(options) }
@@ -183,7 +183,7 @@ class SteamTouchControls(
rebuildVisuals()
if (!editing) {
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
releaseAll()
if (keepHeld) keepHeldFingers() else releaseAll()
}
invalidate()
}
@@ -590,6 +590,13 @@ class SteamTouchControls(
invalidate()
}
private fun keepHeldFingers() {
val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live())
fingers.keys.retainAll(moved.keys)
fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) }
publish()
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
@@ -958,7 +965,7 @@ class SteamTouchControls(
BIG_PICTURE -> "Steam"
else -> gameNames[appId] ?: run {
val app = appId
gameNames[app] = context.getString(R.string.touch_app_id, app)
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
loader.execute {
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
handler.post { gameNames[app] = name; invalidate() }
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
import org.json.JSONObject
data class TouchGestureConfig(
val tapEnabled: Boolean = true,
val tapAction: String = ACTION_LEFT_CLICK,
val dragEnabled: Boolean = true,
val dragAction: String = PAN_LEFT_CLICK_DRAG,
val longPressEnabled: Boolean = false,
val longPressAction: String = ACTION_RIGHT_CLICK,
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val longPressDelay: Int = DEFAULT_DELAY_MS,
val doubleTapEnabled: Boolean = true,
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
val twoFingerDragEnabled: Boolean = true,
val twoFingerDragAction: String = PAN_ARROW_KEYS,
val pinchEnabled: Boolean = true,
val pinchAction: String = ZOOM_SCROLL_WHEEL,
val twoFingerTapEnabled: Boolean = true,
val twoFingerTapAction: String = ACTION_RIGHT_CLICK,
val twoFingerHoldEnabled: Boolean = true,
val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK,
val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val threeFingerTapEnabled: Boolean = true,
val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD,
val threeFingerDragEnabled: Boolean = false,
val threeFingerDragAction: String = PAN_ARROW_KEYS,
val threeFingerHoldEnabled: Boolean = true,
val threeFingerHoldAction: String = ACTION_KEY_ESC,
val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val showGestureDebugOverlay: Boolean = false,
val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD,
val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT,
) {
fun toJson(): String {
return JSONObject().apply {
put(KEY_TAP_ENABLED, tapEnabled)
put(KEY_TAP_ACTION, tapAction)
put(KEY_DRAG_ENABLED, dragEnabled)
put(KEY_DRAG_ACTION, dragAction)
put(KEY_LONG_PRESS_ENABLED, longPressEnabled)
put(KEY_LONG_PRESS_ACTION, longPressAction)
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
put(KEY_LONG_PRESS_DELAY, longPressDelay)
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction)
put(KEY_PINCH_ENABLED, pinchEnabled)
put(KEY_PINCH_ACTION, pinchAction)
put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled)
put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction)
put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled)
put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction)
put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior)
put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay)
put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled)
put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction)
put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled)
put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction)
put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled)
put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction)
put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior)
put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay)
put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay)
put(KEY_GESTURE_THRESHOLD, gestureThreshold)
put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode)
}.toString()
}
companion object {
const val DEFAULT_DELAY_MS = 300
const val DEFAULT_GESTURE_THRESHOLD = 40
const val ACTION_LEFT_CLICK = "left_click"
const val ACTION_RIGHT_CLICK = "right_click"
const val ACTION_MIDDLE_CLICK = "middle_click"
const val MOUSE_BEHAVIOR_HOLD = "hold"
const val MOUSE_BEHAVIOR_CLICK = "click"
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
const val ACTION_SHOW_KEYBOARD = "show_keyboard"
const val ACTION_KEY_ESC = "key_ESC"
const val ACTION_KEY_TILDE = "key_TILDE"
const val PAN_WASD = "wasd"
const val PAN_INVERTED_WASD = "inverted_wasd"
const val PAN_ARROW_KEYS = "arrow_keys"
const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys"
const val PAN_MIDDLE_MOUSE = "middle_mouse_pan"
const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan"
const val PAN_LEFT_CLICK_DRAG = "left_click_drag"
const val PAN_RIGHT_CLICK_DRAG = "right_click_drag"
const val ZOOM_SCROLL_WHEEL = "scroll_wheel"
const val ZOOM_PLUS_MINUS = "plus_minus"
const val ZOOM_PAGE_UP_DOWN = "page_up_down"
private const val KEY_TAP_ENABLED = "tapEnabled"
private const val KEY_TAP_ACTION = "tapAction"
private const val KEY_DRAG_ENABLED = "dragEnabled"
private const val KEY_DRAG_ACTION = "dragAction"
private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled"
private const val KEY_LONG_PRESS_ACTION = "longPressAction"
private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior"
private const val KEY_LONG_PRESS_DELAY = "longPressDelay"
private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled"
private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay"
private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled"
private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction"
private const val KEY_PINCH_ENABLED = "pinchEnabled"
private const val KEY_PINCH_ACTION = "pinchAction"
private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled"
private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction"
private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled"
private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction"
private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior"
private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay"
private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled"
private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction"
private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled"
private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction"
private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled"
private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction"
private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior"
private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay"
private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay"
private const val KEY_GESTURE_THRESHOLD = "gestureThreshold"
private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode"
private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig(
twoFingerHoldEnabled = false,
threeFingerTapEnabled = false,
threeFingerHoldEnabled = false,
)
fun fromJson(json: String?): TouchGestureConfig {
if (json.isNullOrBlank()) return fallbackDefaults()
return try {
val obj = JSONObject(json)
TouchGestureConfig(
tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true),
tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK),
dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true),
dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG),
longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false),
longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK),
longPressMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true),
doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS),
twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true),
twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true),
pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL),
twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true),
twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK),
twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false),
twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK),
twoFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false),
threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD),
threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false),
threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false),
threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC),
threeFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false),
gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD),
mouseDragMovementMode = normalizeMouseDragMovementMode(
obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT),
),
)
} catch (_: Exception) {
fallbackDefaults()
}
}
private fun normalizeMouseBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
private fun normalizeMouseDragMovementMode(mode: String?): String {
return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) {
MOUSE_DRAG_MOVEMENT_RELATIVE
} else {
MOUSE_DRAG_MOVEMENT_DIRECT
}
}
val PAN_ACTIONS = listOf(
PAN_ARROW_KEYS,
PAN_INVERTED_ARROW_KEYS,
PAN_WASD,
PAN_INVERTED_WASD,
PAN_MIDDLE_MOUSE,
PAN_INVERTED_MIDDLE_MOUSE,
PAN_LEFT_CLICK_DRAG,
PAN_RIGHT_CLICK_DRAG,
)
val ZOOM_ACTIONS = listOf(
ZOOM_SCROLL_WHEEL,
ZOOM_PLUS_MINUS,
ZOOM_PAGE_UP_DOWN,
)
@JvmStatic
fun isMouseButtonAction(action: String?): Boolean =
action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK
}
}
@@ -38,6 +38,7 @@ class TouchpadGestures(
private var moverLastX = 0f
private var moverLastY = 0f
private var holdDrag = false
private var scrollLocked = false
private var clicked = 0
private val longPress = Runnable {
held = true
@@ -67,6 +68,7 @@ class TouchpadGestures(
dragging = false
held = false
holdDrag = false
scrollLocked = false
anchorId = -1
moverId = -1
lastTapAt = 0L
@@ -84,7 +86,7 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
@@ -117,10 +119,12 @@ class TouchpadGestures(
val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId)
if (ai >= 0 && mi >= 0) {
val mx = event.getX(mi); val my = event.getY(mi)
if (!holdDrag && !scrolled) {
if (!holdDrag && !scrolled && !scrollLocked) {
val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY)
val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY)
if (moverMoved > slopPx && anchorMoved < slopPx) {
if (anchorMoved >= slopPx) {
scrollLocked = true
} else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) {
holdDrag = true
secondWasTap = false
if (!dragging) {
@@ -186,9 +190,9 @@ class TouchpadGestures(
if (gone == moverId) moverId = -1
if (gone == anchorId) {
anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0)
event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
}
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
@@ -212,7 +216,7 @@ class TouchpadGestures(
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; anchorId = -1; moverId = -1
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
return true
}
}
@@ -225,5 +229,7 @@ class TouchpadGestures(
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
}
File diff suppressed because it is too large. Load diff
@@ -47,6 +47,53 @@ object DesktopCatalog {
private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id")
private const val JOURNAL_SUFFIX = ".installing"
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
/** Packages whose extraction over the runtime started and never finished. */
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(root: File): List<Entry> =
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
.sortedBy { it.name }
.mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } }
private val installLock = Any()
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
/** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
}
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
for (entry in interrupted(context)) {
Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}")
install(context, entry, listener)?.let { return it }
}
null
}
internal fun entryJson(e: Entry): JSONObject = JSONObject()
.put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind)
.put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes)
.put("icon", e.icon).put("category", e.category)
internal fun entryFrom(o: JSONObject) = Entry(
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"),
o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
/** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
private fun downloaded(file: File, entry: Entry): Boolean =
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
@@ -82,22 +129,38 @@ object DesktopCatalog {
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, listener)
}
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
var keepDownload = false
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val report = object : LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
RuntimeInstallService.packageProgress(stage, percent)
listener?.onProgress(step, stage, percent)
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
}
try {
if (!downloaded(download, entry)) {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
}
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -112,23 +175,28 @@ object DesktopCatalog {
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
check(!journal.exists() || journal.delete()) { "Could not finish package installation" }
keepDownload = false
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
if (!keepDownload) download.delete()
release()
}
}
@@ -11,7 +11,7 @@ import java.io.File
object GuestCommand {
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
linkDir: File? = null, onLine: (String) -> Unit): Int {
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
@@ -19,13 +19,14 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
fastPath: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -33,8 +34,10 @@ object GuestCommand {
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
if (linkDir != null) cmd.add(1, "--link2symlink")
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
binds.forEach { cmd.add("-b"); cmd.add(it) }
// Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
@@ -42,6 +45,7 @@ object GuestCommand {
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
@@ -49,6 +53,7 @@ object GuestCommand {
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.os.Process;
import android.system.ErrnoException;
@@ -64,14 +65,12 @@ public final class LinuxRuntime {
private static final String HOST_DIR = "opt/android-host";
public static File prootBinary(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so");
return new File(rootDir(context), HOST_DIR + "/proot");
}
public static File prootLoader(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so");
return new File(rootDir(context), HOST_DIR + "/loader");
}
@@ -187,10 +186,15 @@ public final class LinuxRuntime {
} catch (java.io.IOException e) {
}
}
File fileMax = new File(fakeProc, "file-max");
if (!fileMax.isFile()) {
String[][] seeded = {
{"file-max", "9223372036854775807\n"},
{"overcommit_memory", "0\n"},
};
for (String[] entry : seeded) {
File fake = new File(fakeProc, entry[0]);
if (fake.isFile()) continue;
try {
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
}
}
@@ -205,6 +209,7 @@ public final class LinuxRuntime {
{"overflowuid", "/proc/sys/kernel/overflowuid"},
{"overflowgid", "/proc/sys/kernel/overflowgid"},
{"file-max", "/proc/sys/fs/file-max"},
{"overcommit_memory", "/proc/sys/vm/overcommit_memory"},
};
for (String[] entry : procFiles) {
File fake = new File(fakeProc, entry[0]);
@@ -214,6 +219,9 @@ public final class LinuxRuntime {
bind(cmd, fake.getPath() + ":" + entry[1]);
}
}
if (unreadablePciDevice()) {
bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/bus/pci/devices");
}
bindGpuNode(context, cmd);
bindAdrenoStats(cmd, extraBinds);
bindCpuTemps(cmd, root);
@@ -225,6 +233,15 @@ public final class LinuxRuntime {
return specs;
}
private static boolean unreadablePciDevice() {
File[] devices = new File("/sys/bus/pci/devices").listFiles();
if (devices == null) return false;
for (File device : devices) {
if (!new File(device, "vendor").canRead()) return true;
}
return false;
}
private static void bindGpuNode(Context context, List<String> cmd) {
StructStat st;
try {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.util.Log;
@@ -261,6 +262,8 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
if (!AddonPack.PROOT.await(context)) throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
@@ -456,9 +459,12 @@ public final class LinuxRuntimeInstaller {
}
}
// Packages extract over the live runtime and their archives list a link before the file it
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -477,29 +483,35 @@ public final class LinuxRuntimeInstaller {
if (parent != null && !parent.isDirectory()) parent.mkdirs();
if (entry.isSymbolicLink()) {
file.delete();
FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath());
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
links.remove(file);
File part = partFile(file);
part.delete();
try {
Files.createLink(file.toPath(), target.toPath());
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
} else if (entry.isFile()) {
try (OutputStream out = new FileOutputStream(file)) {
links.remove(file);
File part = partFile(file);
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
}
if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false);
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
} else {
continue;
}
@@ -507,10 +519,32 @@ public final class LinuxRuntimeInstaller {
listener.onProgress(extracting, -1);
}
}
for (java.util.Map.Entry<File, String> link : links.entrySet()) {
File file = link.getKey();
File part = partFile(file);
try {
Files.deleteIfExists(part.toPath());
Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue()));
place(part, file);
} catch (IOException e) {
Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e);
part.delete();
}
}
return true;
} catch (Exception e) {
Log.e(TAG, "extract", e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
private static void place(File part, File file) throws IOException {
java.nio.file.Path path = file.toPath();
if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path);
Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.security.MessageDigest
@@ -11,7 +12,7 @@ object ProotFastPath {
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
LinuxRuntime.prootBinary(context).parentFile == AddonPack.PROOT.dir(context)
fun key(root: File, binds: List<String>): String? {
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
@@ -0,0 +1,34 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
object RuntimeHealth {
private const val MAX_HOPS = 16
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
/** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List<String> {
val lib = File(root, "usr/lib")
val names = lib.list() ?: return emptyList()
return names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
var hops = 0
while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) {
val target = Files.readSymbolicLink(path)
path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target)
}
!Files.isSymbolicLink(path) && Files.exists(path)
} catch (e: Exception) {
false
}
}
@@ -19,6 +19,7 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
@@ -44,7 +45,27 @@ class RuntimeInstallService : Service() {
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
private fun holdPackage(startId: Int): Int {
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
while (packageHolds.get() > 0) {
val now = "$packageStage:$packagePercent"
if (now != shown) {
shown = now
manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
}
Thread.sleep(500)
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "package-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int, title: String? = null): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
@@ -58,7 +79,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -72,6 +93,11 @@ class RuntimeInstallService : Service() {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
@Volatile private var packageTitle = ""
@Volatile private var packageStage = ""
@Volatile private var packagePercent = -1
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -91,6 +117,24 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
/** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
fun holdForPackage(context: Context, title: String): () -> Unit {
packageTitle = title
packageStage = title
packagePercent = -1
packageHolds.incrementAndGet()
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
val released = java.util.concurrent.atomic.AtomicBoolean()
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
}
fun packageProgress(stage: String, percent: Int) {
packageStage = stage
packagePercent = percent
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -71,6 +71,9 @@ object UserApps {
private fun menuEntry(context: Context, id: String) =
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
fun launches(program: String?): Boolean =
program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER
fun list(context: Context): List<App> {
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
App(
@@ -30,7 +30,7 @@ object BootMovie {
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
sealed class Import {
data class Done(val name: String, val audioDropped: Boolean) : Import()
data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import()
object TooLong : Import()
data class Unsupported(val codec: String?) : Import()
object Failed : Import()
@@ -39,6 +39,21 @@ object BootMovie {
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
private fun customFile(context: Context) = File(dir(context), "custom.webm")
/** A movie imported before movies were kept in Steam's own folder. */
internal fun legacyCustomFile(context: Context) = customFile(context)
/** Unpacks one of DroidDeck's own movies on demand; null for anything else. */
internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) }
/** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */
fun select(context: Context, id: String) {
SessionPrefs.setBootMovie(context, id)
SessionPrefs.setBootMovieApplyNext(context, true)
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
fun customName(context: Context): String? =
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
@@ -46,21 +61,127 @@ object BootMovie {
val chosen = SessionPrefs.bootMovie(context)
return when {
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen
chosen in ASSETS -> chosen
else -> DROIDDECK_30
}
}
/** The file the current choice plays from, before any volume or length change. */
fun currentFile(context: Context): File? = sourceOf(context, choice(context))
private fun sourceOf(context: Context, choice: String): File? = when {
choice == CUSTOM -> customFile(context)
isLibraryId(choice) -> BootMovieLibrary.file(context, choice)
else -> ASSETS[choice]?.let { extractAsset(context, it) }
}
private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX)
fun prepare(context: Context): File? {
val choice = choice(context)
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
if (SessionPrefs.bootMovieSound(context)) return source
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
if (remux(source, muted, audio = false)) return muted
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
return null
BootMovieLibrary.restore(context)
val source = sourceOf(context, choice(context)) ?: return null
return variant(context, source)
}
/** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */
private fun variant(context: Context, source: File): File? {
val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0
val seconds = SessionPrefs.bootMovieSeconds(context)
val folder = File(dir(context), "variants")
val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds)
if (plan.passthrough) {
prune(folder, null)
return source
}
folder.mkdirs()
val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE
val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm")
if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) {
prune(folder, out.name)
return out
}
val staged = File(folder, out.name + ".staged")
val ok = runCatching {
plan.steps.any { step ->
when (step) {
Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs)
Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs)
Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs)
Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null
Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs)
}
}
}.getOrDefault(false)
if (ok && staged.renameTo(out)) {
prune(folder, out.name)
return out
}
staged.delete()
Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)")
return if (plan.sourceOnFailure) source else null
}
internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN }
/**
* How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and
* whether it still plays as it is when every step fails.
*/
internal class Plan(val passthrough: Boolean, val steps: List<Step>, val sourceOnFailure: Boolean)
internal fun plan(volume: Int, seconds: Int): Plan = when {
volume == 100 && seconds == 0 -> Plan(true, emptyList(), true)
volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false)
// Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder.
volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true)
else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false)
}
/** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */
internal const val MAX_LONG_SIDE = 1920
internal const val MAX_SHORT_SIDE = 1080
internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE
private fun oversized(format: MediaFormat) =
format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) &&
oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT))
private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true
/** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */
internal fun oversizedPlan(volume: Int): Plan =
Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false)
private fun transcodeSilent(source: File, target: File, limitUs: Long) =
BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null
/** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */
private fun prune(folder: File, keep: String?) {
folder.listFiles()?.forEach { if (it.name != keep) it.delete() }
}
/**
* Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before,
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
*/
fun syncFromSteam(context: Context): Boolean {
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
SessionPrefs.setBootMovieSteamSeen(context, path)
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
val prefix = "/uioverrides/movies/"
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) }
listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path
}
if (BootMovieLibrary.file(context, id) == null) return false
val changed = SessionPrefs.bootMovie(context) != id
BootMovieReport.note(context, "Steam names $path, taken over as $id")
SessionPrefs.setBootMovie(context, id)
return changed
}
private fun extractAsset(context: Context, name: String): File? {
@@ -82,11 +203,15 @@ object BootMovie {
}
}
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
fun import(
context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {},
title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null,
cover: ByteArray? = null, use: Boolean = true,
): Import {
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
val video = format.getString(MediaFormat.KEY_MIME)
val audioPlayable = audio == null || audio in AUDIO_CODECS
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format)
val copy = playable && audioPlayable && isWebm(file)
val remux = !copy && playable && video in REMUX_CODECS
if (!copy && !remux) {
@@ -95,11 +220,12 @@ object BootMovie {
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
onStart()
}
val target = customFile(context)
val staged = File(dir(context), "custom.webm.staged")
val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension
val library = BootMovieLibrary.libraryDir(context)
val staged = File(library, ".import-${System.nanoTime()}.staged")
var audioDropped = remux && !audioPlayable
return try {
target.parentFile?.mkdirs()
library.mkdirs()
val ok = when {
copy -> {
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
@@ -108,11 +234,10 @@ object BootMovie {
remux -> remux(file, staged, audio = true)
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
}
if (!ok || !staged.renameTo(target)) return Import.Failed
File(target.parentFile, "custom-muted.webm").delete()
SessionPrefs.setBootMovieCustomName(context, file.name)
SessionPrefs.setBootMovie(context, CUSTOM)
Import.Done(file.name, audioDropped)
if (!ok || !staged.setReadable(true, false)) return Import.Failed
val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed
if (use) select(context, BootMovieLibrary.idOf(name))
Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name))
} catch (e: Exception) {
Log.w(TAG, "could not import ${file.name}", e)
Import.Failed
@@ -124,8 +249,9 @@ object BootMovie {
fun removeCustom(context: Context) {
customFile(context).delete()
File(dir(context), "custom-muted.webm").delete()
File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() }
SessionPrefs.setBootMovieCustomName(context, null)
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30)
}
private fun isWebm(file: File): Boolean {
@@ -156,7 +282,7 @@ object BootMovie {
}
}
private fun remux(source: File, target: File, audio: Boolean): Boolean {
private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean {
val staged = File(target.parentFile, target.name + ".remux")
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
@@ -183,7 +309,7 @@ object BootMovie {
val info = MediaCodec.BufferInfo()
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0) break
if (size < 0 || extractor.sampleTime > limitUs) break
val track = tracks[extractor.sampleTrackIndex]
if (track != null) {
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
@@ -0,0 +1,410 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.media.MediaMetadataRetriever
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
/**
* The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in
* Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own
* three startup movies and the movies bought in Steam's points shop (neither can be removed).
*/
object BootMovieLibrary {
private const val TAG = "BootMovieLibrary"
const val PREFIX = "file:"
/** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:<path Steam names>`. */
const val STEAM_PREFIX = "steam:"
/** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */
private val lock = Any()
/** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */
internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm")
const val SOURCE_DROIDDECK = "droiddeck"
const val SOURCE_IMPORT = "import"
const val SOURCE_REPO = "repo"
const val SOURCE_STEAM = "steam"
const val SOURCE_POINTSHOP = "pointshop"
class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean)
internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies")
/**
* The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the
* x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file.
*/
internal fun customMovieDirs(context: Context): List<File> =
listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path }
/** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */
internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library")
private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json")
internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
/** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */
internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf")
private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs")
fun idOf(name: String) = PREFIX + name
/** Every Steam client folder: the usual one and the x86-64 client's. */
internal fun steamRoots(context: Context): List<File> = listOf(
steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
).filter { it.isDirectory }
private fun steamFile(context: Context, id: String): File? {
val path = id.removePrefix(STEAM_PREFIX)
if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null
return steamRoots(context).firstNotNullOfOrNull { root ->
listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L }
}
}
fun file(context: Context, id: String): File? {
if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id)
if (!id.startsWith(PREFIX)) return null
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
}
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
file.name !in STAGED && !file.name.startsWith(".")
private fun readMeta(context: Context): JSONObject =
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
/** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */
private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) {
val meta = readMeta(context)
if (change(meta)) {
val file = metaFile(context)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, file.name + ".staged")
runCatching {
staged.writeText(meta.toString())
if (!staged.renameTo(file)) staged.delete()
}.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) }
}
}
/** The post a movie was installed from, if it came from SteamDeckRepo. */
fun repoId(context: Context, name: String): String? =
readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() }
fun installedRepoIds(context: Context): Set<String> {
val meta = readMeta(context)
return meta.keys().asSequence().filter { file(context, idOf(it)) != null }
.mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet()
}
fun nameForRepo(context: Context, repoId: String): String? {
val meta = readMeta(context)
return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId }
}
fun list(context: Context): List<Entry> {
val out = ArrayList<Entry>()
out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false))
out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false))
BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) }
restore(context)
val meta = readMeta(context)
val names = HashSet<String>()
customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() }
.filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f ->
val info = meta.optJSONObject(f.name)
val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM
val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension
// Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand).
out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true))
}
val steam = LinkedHashMap<String, File>()
SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path ->
steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it }
}
val bought = HashMap<String, String>()
for (root in steamRoots(context)) {
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
val scan = pointShopMovies(root)
bought.putAll(scan.titles)
for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found)
}
val seen = HashSet<String>()
for ((id, file) in steam) {
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
val native = file.nameWithoutExtension.lowercase() in NATIVE
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
}
return out
}
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
private val scanLock = Any()
private class Scan(val at: Long, val files: List<File>, val titles: Map<String, String>)
private val scans = HashMap<String, Scan>()
/** Forgets the last search for Steam's own movies. */
internal fun forgetScan() = synchronized(scanLock) { scans.clear() }
/**
* Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the
* client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder.
*/
private fun pointShopMovies(root: File): Scan = synchronized(scanLock) {
val now = System.currentTimeMillis()
scans[root.path]?.let { if (now - it.at < 15_000L) return it }
val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top ->
val base = File(root, top)
if (!base.isDirectory) return@flatMap emptyList()
base.walkTopDown().maxDepth(5)
.onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") }
.take(20_000).filter { isMovie(it) }.toList()
}.sortedBy { it.path.lowercase() }
val titles = HashMap<String, String>()
for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } }
val scan = Scan(now, found, titles)
scans[root.path] = scan
scan
}
/** The item number in the name Steam gives a bought movie, `<item number>_<hash>.webm`; null for any other name. */
private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1)
/** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */
private fun boughtTitle(root: File, id: String): String? {
val key = "communityitemid\\\":\\\"$id\\\""
val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)")
for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) {
val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue
val at = text.indexOf(key)
if (at < 0) continue
title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private const val OVERRIDES = "/uioverrides/movies/"
/** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */
internal fun restore(context: Context) = synchronized(lock) {
migrateLegacyMeta(context)
val meta = readMeta(context)
val library = libraryDir(context)
meta.keys().asSequence().toList().forEach { name ->
val kept = File(library, name)
if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach
val copy = File(moviesDir(context), name)
if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy)
}
}
/** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */
private fun migrateLegacyMeta(context: Context) {
val legacy = File(moviesDir(context), ".droiddeck-movies.json")
if (!legacy.isFile) return
val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return
editMeta(context) { meta ->
var changed = false
old.keys().asSequence().toList().forEach { name ->
val info = old.optJSONObject(name) ?: return@forEach
if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true }
}
changed
}
legacy.delete()
}
/** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */
private fun mirror(kept: File, copy: File): Boolean {
val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged")
return try {
copy.parentFile?.mkdirs()
kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } }
staged.setReadable(true, false) && staged.renameTo(copy)
} catch (e: Exception) {
Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e)
false
} finally {
staged.delete()
}
}
fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id
/** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */
fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id)
/** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */
fun freeName(context: Context, title: String): String {
val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" }
val dirs = customMovieDirs(context)
val library = libraryDir(context)
var name = "$base.webm"
var n = 2
while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ }
return name
}
/**
* Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its
* title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time.
*/
internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? =
synchronized(lock) {
val name = freeName(context, title)
val kept = File(libraryDir(context), name)
if (!staged.renameTo(kept)) return null
if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null }
register(context, name, title, source, repoId, cover)
name
}
/** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */
fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) {
editMeta(context) { meta ->
val info = JSONObject().put("title", title).put("source", source)
if (repoId != null) info.put("repo", repoId)
meta.put(name, info)
true
}
if (cover != null) runCatching {
thumbsDir(context).mkdirs()
File(thumbsDir(context), coverName(name)).writeBytes(cover)
}
}
fun remove(context: Context, id: String): Boolean {
val removed = when {
id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true }
else -> {
// Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone.
if (!id.startsWith(PREFIX)) return false
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false
val copies = customMovieDirs(context).map { File(it, name) }
// A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted.
val target = copies.firstOrNull { it.exists() }
val managed = File(libraryDir(context), name)
if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false
synchronized(lock) {
val main = File(moviesDir(context), name)
val another = copies.any { it != target && it.exists() }
val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile)
var gone = target == null || target.delete()
if (gone && ownsRecord) {
gone = !managed.exists() || managed.delete()
if (gone) {
editMeta(context) { it.remove(name) != null }
File(thumbsDir(context), coverName(name)).delete()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
}
}
if (gone) {
val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) }
File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() }
}
gone
}
}
}
if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30)
return removed
}
/** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */
internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-"
private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun coverName(name: String) = "cover-" + safe(name)
/** How long a movie runs, in whole seconds; 0 when it cannot be read. */
fun durationSeconds(file: File?): Int {
if (file == null || !file.isFile) return 0
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
((ms + 500) / 1000).toInt()
} catch (e: Exception) {
0
} finally {
runCatching { retriever.release() }
}
}
/** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */
private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? {
var best: Bitmap? = null
var bestLuma = -1
for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) {
val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue
val luma = averageLuma(frame)
if (luma >= MIN_COVER_LUMA) return frame
if (luma > bestLuma) { best = frame; bestLuma = luma }
}
return best ?: runCatching { retriever.frameAtTime }.getOrNull()
}
private fun averageLuma(bitmap: Bitmap): Int {
val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true)
var sum = 0L
for (y in 0 until small.height) for (x in 0 until small.width) {
val c = small.getPixel(x, y)
sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10
}
return (sum / (small.width * small.height)).toInt()
}
private const val MIN_COVER_LUMA = 24
/** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */
fun cover(context: Context, entry: Entry): File? {
val file = fileOf(context, entry) ?: return null
val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id
// Points Shop covers once kept a picture that was not always the movie's own; those files are not used.
val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name))
if (given.isFile && given.length() > 0L) return given
val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg"
val frame = File(thumbsDir(context), key)
if (frame.isFile && frame.length() > 0L) return frame
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
val bitmap = brightestFrame(retriever, ms) ?: return null
val width = 480
val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap
thumbsDir(context).mkdirs()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
val staged = File(thumbsDir(context), "$key.staged")
staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) }
if (staged.renameTo(frame)) frame else null
} catch (e: Exception) {
Log.w(TAG, "no cover for ${file.name}", e)
null
} finally {
runCatching { retriever.release() }
}
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and
* where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config.
*/
object BootMovieReport {
private const val TAG = "BootMovieReport"
private const val MAX_TRACE_LINES = 200
private const val MAX_HITS = 60
private val KNOWN_TITLES = listOf("Turret Factory")
private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs")
private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak")
private val lock = Any()
private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log")
/** Adds a line to the short trace kept for the report; only the latest lines are kept. */
fun note(context: Context, line: String) {
synchronized(lock) {
runCatching {
val file = traceFile(context)
file.parentFile?.mkdirs()
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line"
file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n")
}
}
}
/** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */
fun write(context: Context): File? {
val text = runCatching { build(context) }.getOrElse {
Log.w(TAG, "could not build the report", it)
return null
}
val folders = listOfNotNull(
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"),
context.getExternalFilesDir(null),
)
for (folder in folders) {
val file = File(folder, "boot-movie-report.txt")
val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false)
if (saved) return file
Log.w(TAG, "could not write the report to ${file.path}")
}
return null
}
private fun describe(file: File) =
"${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}"
internal fun build(context: Context): String {
val out = StringBuilder()
fun line(text: String = "") { out.append(text).append('\n') }
fun listing(title: String, folder: File) {
line("-- $title: ${folder.path}")
val files = folder.listFiles()?.sortedBy { it.name.lowercase() }
if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)")
else files.forEach { line(" " + describe(it)) }
}
line("DroidDeck boot movie report, ${Date()}")
line()
line("== DroidDeck state")
line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}")
line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}")
line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}")
line("session running=${SessionState.running}")
line()
line("== The movie list as shown")
BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") }
line()
line("== DroidDeck folders")
listing("library", BootMovieLibrary.libraryDir(context))
listing("processed copies", File(context.filesDir, "boot-movie/variants"))
val meta = File(context.filesDir, "boot-movie/movies.json")
line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}")
val roots = BootMovieLibrary.steamRoots(context)
val movieIds = HashSet<String>()
for (root in roots) {
line()
line("== Steam folder ${root.path}")
val config = File(root, "config/config.vdf")
line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}")
val text = runCatching { config.readText() }.getOrNull()
if (text != null) {
line("-- StartupMovie block:")
(SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- Customization section with the lines before it:")
(SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- every line of config.vdf that mentions a movie:")
text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") }
Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) }
}
listing("custom movies", File(root, "config/uioverrides/movies"))
listing("Steam's own movies", File(root, "steamui/movies"))
listing("config/movies", File(root, "config/movies"))
line("-- every .webm below config, userdata, appcache and steamui:")
var shown = 0
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue
if (shown++ >= 300) break
line(" " + describe(file))
Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) }
}
}
}
line()
line("== Where Steam keeps movie names (text found in Steam's files)")
val needles = (KNOWN_TITLES + movieIds).distinct()
line("looking for: ${needles.joinToString(", ")}")
search(roots, needles) { line(" $it") }
line()
line("== What each session start did to Steam's config (latest last)")
val trace = traceFile(context)
if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)")
return out.toString()
}
private fun search(roots: List<File>, needles: List<String>, report: (String) -> Unit) {
if (needles.isEmpty()) return
val deadline = System.currentTimeMillis() + 25_000L
var scanned = 0
var hits = 0
outer@ for (root in roots) {
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue
if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue
for (needle in needles) {
val at = text.indexOf(needle)
if (at < 0) continue
hits++
val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length))
report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString(""))
}
}
}
}
report("(searched $scanned files, $hits matches)")
}
}
@@ -37,15 +37,77 @@ internal object BootMovieTranscoder {
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
}.getOrDefault(false)
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? {
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
val audio = if (hadAudio) runCatching { encodeAudio(source) }
val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) }
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
return Result(audioDropped = hadAudio && !muxedAudio)
}
/** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */
fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean {
val audio = runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
val staged = File(target.parentFile, target.name + ".regain")
return try {
extractor.setDataSource(source.path)
var videoTrack = -1
var videoFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) {
videoTrack = i
videoFormat = format
}
}
if (videoTrack < 0 || videoFormat == null) return false
extractor.selectTrack(videoTrack)
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
val outVideo = mux.addTrack(videoFormat)
val outAudio = mux.addTrack(audio.format)
mux.start()
val info = MediaCodec.BufferInfo()
var audioIndex = 0
fun writeAudioUpTo(timeUs: Long) {
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
val sample = audio.samples[audioIndex++]
info.set(0, sample.data.size, sample.timeUs, 0)
mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info)
}
}
var bufferSize = 8 shl 20
if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
val buffer = ByteBuffer.allocate(bufferSize)
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0 || extractor.sampleTime > limitUs) break
val time = extractor.sampleTime
writeAudioUpTo(time)
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
mux.writeSampleData(outVideo, buffer, info)
extractor.advance()
}
writeAudioUpTo(Long.MAX_VALUE)
mux.stop()
mux.release()
muxer = null
staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not change the volume of ${source.name}", e)
false
} finally {
runCatching { muxer?.release() }
extractor.release()
staged.delete()
}
}
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
val extractor = MediaExtractor()
return try {
@@ -75,6 +137,14 @@ internal object BootMovieTranscoder {
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
}
/** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */
internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray {
val frames = pcm.size / channels
val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt()
if (gain == 1f && kept == frames) return pcm
return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() }
}
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
if (rate == OPUS_RATE) return pcm
val frames = pcm.size / channels
@@ -94,7 +164,10 @@ internal object BootMovieTranscoder {
return out
}
private fun encodeAudio(source: File): Audio? {
/** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */
private const val MAX_PCM_BYTES = 64 shl 20
private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? {
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
@@ -139,13 +212,16 @@ internal object BootMovieTranscoder {
}
dec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
// Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped.
if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break
if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break }
}
}
if (channels !in 1..2) return null
val raw = pcm.toByteArray()
val shorts = ShortArray(raw.size / 2)
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
val samples = resample(shorts, channels, rate)
val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs)
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
@@ -201,7 +277,7 @@ internal object BootMovieTranscoder {
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean {
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
@@ -278,7 +354,8 @@ internal object BootMovieTranscoder {
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
if (index >= 0) {
val timeUs = decInfo.presentationTimeUs
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
val pastLimit = timeUs > limitUs
val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
dec.releaseOutputBuffer(index, keep)
if (keep) {
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
@@ -290,9 +367,10 @@ internal object BootMovieTranscoder {
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
}
}
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
enc.signalEndOfInputStream()
decoderDone = true
inputDone = true
}
}
}
@@ -77,7 +77,7 @@ class PerfHud(context: 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 name = if (lsfg) "LSFG" else "DD-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 }
@@ -1,10 +1,12 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LegacyNames
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -15,6 +17,7 @@ object SessionFiles {
private const val TAG = "SessionFiles"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
private const val STAGED_LEDGER = "usr/local/.droiddeck-staged"
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
internal val DESKTOP_FILES = arrayOf(
@@ -27,7 +30,8 @@ object SessionFiles {
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
fun stage(context: Context, root: File) {
/** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
fun stage(context: Context, root: File, startsSession: Boolean = false) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
@@ -66,6 +70,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
@@ -133,13 +138,15 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context))
for ((asset, relative) in all) {
val target = File(root, relative)
if (ledger.current(relative, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
@@ -153,7 +160,7 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "$relative NOT staged")
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
}
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
LegacyNames.migrateRootfs(root)
@@ -165,13 +172,17 @@ object SessionFiles {
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val directAudioPack = AddonPack.DIRECTAUDIO.takeIf { it.installed(context) }?.dir(context)
if (directAudioPack == null) Log.w(TAG, "DirectAudio pack is not installed; drivers not staged")
if (directAudioPack != null) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative"
val target = File(root, key)
if (ledger.current(key, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/$set/$relative").use { input ->
File(directAudioPack, "$set/$relative").inputStream().use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
@@ -180,8 +191,9 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
ledger.save()
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
@@ -198,20 +210,118 @@ object SessionFiles {
if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) {
Log.e(TAG, "could not write steam-compatibility-label")
}
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
|| !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) {
stagedSocket.delete()
Log.e(TAG, "could not write bwrap-socket")
}
BootMovie.syncFromSteam(context)
val startupMovie = BootMovie.prepare(context)
// The x86-64 client's config is left alone until its first start creates it.
val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam"))
.filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory }
for (steam in steamRoots) {
val startupMovieDir = File(steam, "config/uioverrides/movies")
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(steam, "config/config.vdf"))
val force = SessionPrefs.bootMovieApplyNext(context)
val steamHomes = listOf(File(root, "root/.local/share/Steam")) +
listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory }
var allDone = true
var allInstalled = true
var seen: String? = null
val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64"
for (home in steamHomes) {
val movieDir = File(home, "config/uioverrides/movies")
val config = File(home, "config/config.vdf")
if (startupMovie == null) {
// Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either.
for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) {
File(movieDir, name).delete()
File(movieDir, ".$name.source").delete()
}
}
val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm")
val before = startupMoviePath(config)
val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force)
BootMovieReport.note(
context,
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
)
if (!done) allDone = false
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
}
// A pick made in the app is done once a session started with every Steam client's config having it; guest commands run
// before that, and a Steam that is already running writes its own choice back when it exits.
if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false)
seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
/**
* Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that
* could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config
* no longer names a missing movie.
*/
internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean {
if (!config.isFile) return true
val text = runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config to reset the startup movie", it)
return false
}
val movie = activeStartupMovie(text) ?: return true
val block = text.substring(movie.keyStart, movie.close + 1)
val path = scalar(block, "LocalPath").orEmpty()
if (path.isEmpty()) return true
val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR))
val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH)
if (!gone) return false
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline)
val staged = File(config.parentFile, config.name + ".staged")
return try {
staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated))
(staged.renameTo(config)).also { if (!it) staged.delete() }
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not reset Steam startup movie selection", e)
false
}
}
private fun apkIdentity(context: Context): String {
val apk = File(context.applicationInfo.sourceDir)
return "${apk.path} ${apk.length()} ${apk.lastModified()}"
}
private class StageLedger(private val file: File, private val identity: String) {
private val before: Map<String, String> = FileUtils.readString(file)?.lines()
?.takeIf { it.firstOrNull() == identity }
?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap()
.orEmpty()
private val after = LinkedHashMap<String, String>()
fun current(relative: String, target: File): Boolean {
val mark = mark(target) ?: return false
if (before[relative] != mark) return false
after[relative] = mark
return true
}
fun staged(relative: String, target: File) {
mark(target)?.let { after[relative] = it }
}
fun save() {
if (after == before) return
val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n")
val staged = File(file.parentFile, file.name + ".staged")
if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) {
staged.delete()
file.delete()
}
}
private fun mark(target: File): String? =
if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null
}
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
@@ -227,7 +337,11 @@ object SessionFiles {
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
if (installed) FileUtils.writeString(stamp, identity)
if (installed) {
FileUtils.writeString(stamp, identity)
// Steam's picker draws its preview from this picture and does not redraw it when the movie changes.
File(directory, name.removeSuffix(".webm") + ".jpg").delete()
}
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
@@ -239,43 +353,108 @@ object SessionFiles {
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
private fun ensureStartupMovieDefault(config: File) {
val path = "/uioverrides/movies/bigpicture_startup.webm"
internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/"
internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm"
/**
* Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam
* settings is where older clients kept it, and DroidDeck still keeps it in step.
*/
private fun customizationMovie(text: String): VdfBlock? {
var from = 0
while (true) {
val customization = findObject(text, "Customization", from, text.length) ?: return null
findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it }
from = customization.close + 1
}
}
/** The older `StartupMovie` section: the first one that is not inside `Customization`. */
private fun olderMovie(text: String): VdfBlock? {
val inside = customizationMovie(text)
var from = 0
while (true) {
val movie = findObject(text, "StartupMovie", from, text.length) ?: return null
if (inside == null || movie.keyStart != inside.keyStart) return movie
from = movie.close + 1
}
}
/** The section Steam reads: the one under `Customization` when there is one. */
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
internal fun startupMoviePath(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val movie = activeStartupMovie(text) ?: return null
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
}
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
internal fun startupMovieBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val parts = listOfNotNull(
customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) },
olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) },
)
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
/** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */
internal fun customizationBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val customization = findObject(text, "Customization", 0, text.length) ?: return null
val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n")
return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000)
}
private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String {
val block = text.substring(movie.keyStart, movie.close + 1)
return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline))
}
/** True when Steam's config selects DroidDeck's staged movie once this returns. */
internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean {
val path = BOOT_MOVIE_PATH
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config for startup movie", it)
return
return false
} else ""
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val movie = findObject(text, "StartupMovie", 0, text.length)
if (movie != null) {
val block = text.substring(movie.keyStart, movie.close + 1)
val custom = customizationMovie(text)
val active = custom ?: olderMovie(text)
if (active != null) {
val block = text.substring(active.keyStart, active.close + 1)
val selectedId = scalar(block, "MovieID")
val selectedPath = scalar(block, "LocalPath")
if (selectedPath == path) {
if (selectedPath == path && custom != null) {
Log.i(TAG, "Steam startup movie is set to DroidDeck")
return
return true
}
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
val hasExplicitSelection =
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
if (!legacyDefault && hasExplicitSelection) {
if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) {
Log.i(TAG, "Steam startup movie selection preserved")
return
return false
}
val updated = setScalar(block, "MovieID", "0", newline)
.let { setScalar(it, "LocalPath", path, newline) }
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
} else {
}
// Later sections first, so the positions of earlier ones stay valid.
val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart }
for (target in targets) text = pointAt(text, target, path, newline)
if (olderMovie(text) == null) {
val steam = findSteamBlock(text)
if (steam == null) {
if (text.isNotBlank()) {
if (text.isNotBlank() && customizationMovie(text) == null) {
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
return
return false
}
if (text.isBlank()) {
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
}
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
} else {
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
@@ -283,22 +462,35 @@ object SessionFiles {
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
}
}
if (customizationMovie(text) == null) text = withCustomization(text, path, newline)
val staged = File(config.parentFile, config.name + ".staged")
try {
return try {
config.parentFile?.mkdirs()
staged.writeText(text)
if (!staged.renameTo(config)) {
staged.delete()
Log.e(TAG, "could not write Steam startup movie selection")
false
} else {
Log.i(TAG, "Steam startup movie default selected")
true
}
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write Steam startup movie selection", e)
false
}
}
/** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
private fun withCustomization(text: String, path: String, newline: String): String {
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' }
val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline)
return text.substring(0, lineStart) + section + newline + text.substring(lineStart)
}
private fun findSteamBlock(text: String): VdfBlock? {
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
@@ -118,8 +118,22 @@ object SessionPrefs {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
}
fun storesTabOrder(context: Context): List<String> = idList(prefs(context).getString("storesTabOrder", null))
fun setStoresTabOrder(context: Context, order: List<String>) {
prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply()
}
fun storesTabHidden(context: Context): Set<String> = idList(prefs(context).getString("storesTabHidden", null)).toSet()
fun setStoresTabHidden(context: Context, hidden: Set<String>) {
prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply()
}
private fun idList(raw: String?): List<String> = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() }
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "list")
val STORES_VIEWS = listOf("cards", "capsules", "list")
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
@@ -168,6 +182,7 @@ object SessionPrefs {
const val TOUCH_AUTO = "auto"
const val TOUCH_PAD = "touchpad"
const val TOUCH_DIRECT = "direct"
const val TOUCH_GESTURES = "gestures"
const val TOUCH_OFF = "off"
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
@@ -527,6 +542,17 @@ object SessionPrefs {
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100)
fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() }
/** How long the boot movie plays, in seconds; 0 plays all of it. */
fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0)
fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() }
/** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */
fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false)
fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() }
/** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */
fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null)
fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() }
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
fun setBootMovieCustomName(context: Context, name: String?) {
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
@@ -878,6 +904,11 @@ object SessionPrefs {
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun displayMode(context: Context, mode: String, program: String?): String =
if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) &&
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
else prefMode(mode)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
@@ -80,6 +81,7 @@ class SessionService : Service() {
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepIdle = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
@@ -293,14 +295,13 @@ class SessionService : Service() {
return lines
}
private fun tuDebug(linuxDriverId: String): String? {
val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
if (!override.isNullOrEmpty()) return override
if (SessionPrefs.tuSysmem(this)) return "sysmem"
if (linuxDriverId.isEmpty()) return null
private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() }
private fun sysmemDriver(linuxDriverId: String): Boolean {
if (linuxDriverId.isEmpty()) return false
val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null
return name.contains("710-720") || name.contains("710_720")
}
@@ -319,7 +320,9 @@ class SessionService : Service() {
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
SessionFiles.stage(this, root, startsSession = true)
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
SessionArtifacts.finishAbandoned(this)
@@ -575,7 +578,9 @@ class SessionService : Service() {
)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
val tuOverride = tuDebugOverride()
val tuDriver = sysmemDriver(linuxDriverId)
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
@@ -603,8 +608,9 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
extraEnv().forEach { guest.add(it) }
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
@@ -1019,7 +1025,7 @@ class SessionService : Service() {
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
@@ -1219,16 +1225,22 @@ class SessionService : Service() {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
if (!steamPause) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1296,7 +1308,7 @@ class SessionService : Service() {
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
@@ -1307,13 +1319,14 @@ class SessionService : Service() {
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle))
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
@@ -1339,6 +1352,7 @@ class SessionService : Service() {
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = false
}
private fun requestSteamWake() {
@@ -1665,6 +1679,7 @@ class SessionService : Service() {
private const val TAG = "SessionService"
private const val TRACER_NICE = -6
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
@@ -21,7 +21,8 @@ object SettingsReset {
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
"bootMovieApplyNext", "bootMovieSteamSeen",
"questDefaultsV1",
)
@@ -0,0 +1,218 @@
package com.droiddeck.launcher.session
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */
object SteamDeckRepo {
private const val TAG = "SteamDeckRepo"
const val BASE = "https://steamdeckrepo.com"
/** The site shows twelve movies a page. */
const val PAGE_SIZE = 12
const val MAX_DOWNLOAD_BYTES = 120L shl 20
private const val MAX_JSON_BYTES = 1 shl 20
private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)"
private const val CACHE_MS = 10 * 60 * 1000L
enum class Sort(val key: String) {
TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"),
}
/** `null` is every device. */
val DEVICES = listOf("steam_deck", "steam_machine")
class Post(
val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String,
val seconds: Int, val likes: Int, val downloads: Int, val devices: List<String>, val url: String,
)
sealed class Page {
class Ok(val posts: List<Post>) : Page()
object Failed : Page()
}
/** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */
private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun parse(json: String): List<Post>? {
val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null
val out = ArrayList<Post>()
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val id = o.text("id")
val video = o.text("video")
if (id.isEmpty() || !video.startsWith("https://")) continue
val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty()
out.add(
Post(
id = id, title = o.text("title").ifBlank { id }, content = o.text("content"),
author = o.optJSONObject("user")?.text("steam_name").orEmpty(),
thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video,
seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"),
devices = devices, url = o.text("url"),
),
)
}
return out
}
fun url(sort: Sort, device: String?, search: String, page: Int = 1): String {
val query = StringBuilder("sort=").append(sort.key)
if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8"))
val term = search.trim()
if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20"))
if (page > 1) query.append("&page=").append(page)
return "$BASE/api/posts?$query"
}
private class Cached(val at: Long, val page: Page.Ok)
private val cache = HashMap<String, Cached>()
fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page {
val url = url(sort, device, search, page)
synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } }
val body = get(url) ?: return Page.Failed
val posts = parse(body) ?: return Page.Failed
val result = Page.Ok(posts.take(PAGE_SIZE))
synchronized(cache) { cache[url] = Cached(now, result) }
return result
}
/**
* The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a
* page past the end is empty and the last one is short. Null when the site cannot be reached.
*/
fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p ->
(fetch(sort, device, search, p) as? Page.Ok)?.posts?.size
}): Int? {
val first = count(1) ?: return null
if (first < PAGE_SIZE) return 1
var good = 1
var bad = -1
var probe = 2
while (probe <= cap) {
val n = count(probe) ?: return null
if (n == 0) { bad = probe; break }
if (n < PAGE_SIZE) return probe
good = probe
probe *= 2
}
if (bad < 0) return good
while (bad - good > 1) {
val mid = (good + bad) / 2
val n = count(mid) ?: return null
if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid
}
return good
}
/** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */
internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(16 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
if (out.size() + n > limit) return null
out.write(buffer, 0, n)
}
return out.toByteArray()
}
private fun get(url: String): String? {
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json")
}
if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) }
} catch (e: Exception) {
Log.w(TAG, "request failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { connection?.disconnect() }
}
}
/** Fetches a small picture such as a cover; null when it cannot be had. */
fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? {
if (!url.startsWith("https://")) return null
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null
else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } }
} catch (e: Exception) {
null
} finally {
runCatching { connection?.disconnect() }
}
}
/**
* Downloads a movie to [target] through the site's own download address, so the site counts it like a download from
* its website, and falls back to the video's address. True when a file was saved.
*/
fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean {
for (url in listOf("$BASE/post/download/${post.id}", post.video)) {
if (save(url, target, onProgress)) return true
}
return false
}
private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean {
var connection: HttpURLConnection? = null
val staged = File(target.parentFile, target.name + ".part")
return try {
target.parentFile?.mkdirs()
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299) return false
val type = connection.contentType.orEmpty().lowercase()
if (type.startsWith("text/") || type.contains("json")) return false
val total = connection.contentLengthLong
if (total > MAX_DOWNLOAD_BYTES) return false
var done = 0L
var lastPercent = -1
connection.inputStream.use { input ->
staged.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
done += n
if (done > MAX_DOWNLOAD_BYTES) return false
output.write(buffer, 0, n)
if (total > 0) {
val percent = (done * 100 / total).toInt()
if (percent != lastPercent) { lastPercent = percent; onProgress(percent) }
}
}
}
}
done > 0 && (total <= 0 || done == total) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "download failed: ${e.javaClass.simpleName}")
false
} finally {
runCatching { connection?.disconnect() }
staged.delete()
}
}
}
@@ -13,6 +13,7 @@ import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.json.JSONObject
@@ -57,7 +58,7 @@ object CloudSaves {
val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
}
Store.AMAZON -> ""
Store.AMAZON, Store.ITCH, Store.XBOX -> ""
} ?: return null
p.edit().putString("cloud_template_$id", fetched).apply()
Log.i(TAG, "cloud ${store.id} $id template ${if (fetched.isEmpty()) "none" else "found"}")
@@ -95,7 +96,7 @@ object CloudSaves {
val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
Store.AMAZON -> null
Store.AMAZON, Store.ITCH, Store.XBOX -> null
}
return dir to (if (dir == null) "unresolved" else "ok")
}
@@ -120,7 +121,7 @@ object CloudSaves {
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
}
Store.AMAZON -> null
Store.AMAZON, Store.ITCH, Store.XBOX -> null
}
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
@@ -150,19 +151,22 @@ object CloudSaves {
fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
fun downloadOrDefer(context: Context, store: Store, id: String): Result {
fun downloadOrDefer(context: Context, store: Store, id: String): Result =
downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() })
internal fun downloadOrDefer(context: Context, store: Store, id: String, executor: Executor): Result {
val app = context.applicationContext
val g = game(app, store, id) ?: return done(store, id, "down", Result("skipped", 0, 0, "not-installed"))
if (!g.second.cloud) return done(store, id, "down", Result("skipped", 0, 0, "off"))
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) return download(app, store, id)
Thread({
executor.execute {
val end = System.currentTimeMillis() + PREFIX_WAIT_MS
while (System.currentTimeMillis() < end) {
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@Thread }
if (prefix(app, g.first)?.let { CloudSavePaths.profile(it).isDirectory } == true) { download(app, store, id); return@execute }
Thread.sleep(2000)
}
done(store, id, "down", Result("skipped", 0, 0, "no-prefix"))
}, "cloud-deferred").start()
}
return done(store, id, "down", Result("deferred", 0, 0, "no-prefix"))
}
@@ -12,6 +12,7 @@ internal object LoginFlows {
Store.GOG -> GogFlow(state)
Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow()
Store.ITCH, Store.XBOX -> null
}
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
@@ -3,7 +3,9 @@ package com.droiddeck.launcher.stores
enum class Store(val id: String, val label: String, val shortLabel: String) {
GOG("gog", "GOG", "GOG"),
EPIC("epic", "Epic Games", "Epic"),
AMAZON("amazon", "Amazon Games", "Amazon");
AMAZON("amazon", "Amazon Games", "Amazon"),
XBOX("xbox", "Xbox", "Xbox"),
ITCH("itch", "itch.io", "itch");
companion object {
fun byId(id: String?): Store? = entries.firstOrNull { it.id == id }
@@ -67,7 +67,10 @@ object StoreAccounts {
fun write(context: Context, store: Store, json: JSONObject) {
unavailable.remove(store)
val f = credentialsFile(context, store)
if (seal(f, json) == null) put(f, json.toString())
if (seal(f, json, retry = store == Store.XBOX) == null) {
check(store != Store.XBOX) { "Android Keystore is unavailable for Xbox credentials" }
put(f, json.toString())
}
}
@Synchronized
@@ -86,18 +89,19 @@ object StoreAccounts {
fun signedInAs(context: Context, store: Store): String? {
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
if (store == Store.XBOX) return json.optString("username").takeIf { it.isNotBlank() }
if (json.optString("access_token", "").isEmpty()) return null
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
private fun seal(f: File, json: JSONObject): File? {
if (keystoreFailed) return null
private fun seal(f: File, json: JSONObject, retry: Boolean = false): File? {
if (keystoreFailed && !retry) return null
val text = json.toString()
val envelope = try {
CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } }
} catch (e: Exception) {
keystoreFailed = true
Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})")
Log.w(TAG, "stores: keystore unavailable (${e.javaClass.simpleName})")
return null
}
put(f, envelope.toString())
@@ -21,7 +21,9 @@ data class CatalogItem(
val sizeBytes: Long = 0L,
val extra: Map<String, String> = emptyMap(),
val mature: Boolean = false,
val subscription: Boolean = false,
) {
val accessible: Boolean get() = owned || subscription
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
val key: String get() = "${store.id}:$id"
}
@@ -49,7 +51,9 @@ fun installedCards(store: Store, installed: List<InstalledStoreGame>, library: L
return installed.filter { it.sidecar.store == store }.map { game ->
val s = game.sidecar
byId[s.id] ?: byTitle[s.title.lowercase()]
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero, owned = true)
?: CatalogItem(store, s.id, s.title, imageUrl = s.hero ?: s.cover, tallImageUrl = s.cover ?: s.hero,
owned = s.store != Store.XBOX || s.extra["access"] == "owned",
subscription = s.store == Store.XBOX && s.extra["access"] == "gamepass")
}.distinctBy { it.id }.sortedBy { it.title.lowercase() }
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.frontend.Library
import java.io.File
@@ -63,6 +64,14 @@ object StoreLaunch {
private const val CODE_VAR = "DD_X"
private const val CLIENT_VAR = "DD_A"
fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? {
val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder ->
StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true
} ?: return null
val game = AddedGames.single(context, folder.path) ?: return null
return Library.addedGame(context, game)
}
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
if (sidecar.launcher != LAUNCHER) return
val file = File(folder, LAUNCHER)
@@ -37,6 +37,7 @@ object StoreLaunchRequests {
}
fun stop() {
com.droiddeck.launcher.stores.xbox.XboxRuntime.stop()
synchronized(lock) {
observer?.stopWatching()
observer = null
@@ -50,6 +51,13 @@ object StoreLaunchRequests {
return
}
val reply = when (request.optString("op")) {
"xbox-released" -> {
com.droiddeck.launcher.stores.xbox.XboxRuntime.released(request.optString("lease"))
JSONObject().put("ok", true)
}
"xbox-renew" -> JSONObject().put("ok", com.droiddeck.launcher.stores.xbox.XboxRuntime.renew(app, request.optString("id"), request.optString("lease")))
"xbox-prepare" -> runCatching { com.droiddeck.launcher.stores.xbox.XboxRuntime.prepare(app, request.getString("id")) }
.getOrElse { JSONObject().put("ok", false).put("reason", "Xbox account service is unavailable. Sign in again and retry.") }
"gog-achievements" -> runCatching { com.droiddeck.launcher.stores.gog.GogComet.prepare(app, request.optString("id")) }
.getOrElse { JSONObject().put("ok", false).put("ready", false).put("reason", "service-unavailable") }
"epic-code" -> {
@@ -31,7 +31,7 @@ object StoreSizes {
private fun cache(context: Context, store: Store) = caches.getOrPut(store) { SizeCache.forStore(context.applicationContext.filesDir, store) }
fun request(context: Context, item: CatalogItem) {
if (!item.owned || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
if (!item.accessible || item.sizeBytes > 0 || item.store == Store.GOG || known.containsKey(item.key)) return
resolve(context, item).thenAccept { bytes ->
if (bytes != null) StoresState.post { known[item.key] = bytes }
}
@@ -39,7 +39,7 @@ object StoreSizes {
fun resolve(context: Context, item: CatalogItem): CompletableFuture<Long?> {
val available = known[item.key] ?: item.sizeBytes.takeIf { it > 0 }
if (!item.owned || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
if (!item.accessible || item.store == Store.GOG || item.sizeBytes > 0) return CompletableFuture.completedFuture(available)
if (available != null) return CompletableFuture.completedFuture(available)
val app = context.applicationContext
val version = item.extra["version"].orEmpty()
@@ -54,7 +54,8 @@ object StoreSizes {
when (item.store) {
Store.EPIC -> EpicPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.GOG -> {}
Store.ITCH -> {}
Store.GOG, Store.XBOX -> {}
}
}
}, pool).exceptionally { error ->
@@ -107,6 +108,7 @@ object StoreSizes {
val bytes = AmazonApiClient.getBytes(AmazonApiClient.appendPath(spec.downloadUrl, "manifest.proto"), token) ?: return 0L
AmazonManifest.parse(bytes).totalInstallSize
}
Store.GOG -> 0L
Store.GOG, Store.XBOX -> 0L
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
} }
}
@@ -0,0 +1,9 @@
package com.droiddeck.launcher.stores
data class StoreTab(val store: Store, val shown: Boolean)
fun arrangeStoreTabs(order: List<String>, hidden: Set<String>): List<StoreTab> {
val known = Store.entries.associateBy { it.id }
val listed = order.mapNotNull { known[it] }.distinct()
return (listed + Store.entries.filterNot { it in listed }).map { StoreTab(it, it.id !in hidden) }
}
@@ -16,14 +16,19 @@ import java.util.Locale
object StoresState {
private const val TAG = "Stores"
private const val SEARCH_DELAY_MS = 400L
private val main = Handler(Looper.getMainLooper())
val accounts = mutableStateMapOf<Store, String>()
var accountsLoaded by mutableStateOf(false)
private set
val library = mutableStateMapOf<Store, List<CatalogItem>>()
val shelves = mutableStateMapOf<Store, StoreShelves>()
val found = mutableStateMapOf<Store, List<CatalogItem>>()
val status = mutableStateMapOf<Store, String>()
val problems = mutableStateMapOf<Store, String>()
@@ -64,6 +69,8 @@ object StoresState {
private val backends = HashMap<Store, StoreBackend>()
private val searchSeq = java.util.concurrent.ConcurrentHashMap<Store, Int>()
fun backend(store: Store): StoreBackend? = backends[store]
fun register(backend: StoreBackend) { backends[backend.store] = backend }
@@ -79,11 +86,15 @@ object StoresState {
val partial = StoreInstallRoot.unfinished(app)
val version = if (StoresNative.available) StoresNative.version ?: "" else ""
main.post {
accounts.clear()
found.forEach { (store, name) -> accounts[store] = name }
installed = onDisk
unfinished = partial
engine = version
androidx.compose.runtime.snapshots.Snapshot.withMutableSnapshot {
val restored = found.toMap()
accounts.keys.toList().filter { it !in restored }.forEach { accounts.remove(it) }
accounts.putAll(restored)
installed = onDisk
unfinished = partial
engine = version
accountsLoaded = true
}
}
}
}
@@ -129,6 +140,26 @@ object StoresState {
if (!shelves.containsKey(store)) backends[store]?.loadShelves(context.applicationContext, false)
}
fun search(context: Context, store: Store, query: String) {
val backend = backends[store] ?: return
val text = query.trim()
if (text.isEmpty()) {
searchSeq.remove(store)
found.remove(store)
return
}
val seq = (searchSeq[store] ?: 0) + 1
searchSeq[store] = seq
val app = context.applicationContext
Thread({
Thread.sleep(SEARCH_DELAY_MS)
if (searchSeq[store] != seq) return@Thread
val items = runCatching { backend.search(app, text) }.getOrDefault(emptyList())
if (searchSeq[store] != seq) return@Thread
main.post { found[store] = items }
}, "stores-search").start()
}
fun signIn(context: Context, store: Store) {
expired.remove(store)
backends[store]?.signIn(context) ?: logLine("${store.label}: this build has no sign-in for it yet")
@@ -137,10 +168,12 @@ object StoresState {
fun signOut(context: Context, store: Store) {
val app = context.applicationContext
background("stores-signout") {
backends[store]?.signOut(app)
val backend = backends[store]
backend?.signOut(app)
if (store == Store.GOG) com.droiddeck.launcher.stores.gog.GogComet.replaceAccount { StoreAccounts.clear(app, store) }
else StoreAccounts.clear(app, store)
else if (store != Store.XBOX || backend == null) StoreAccounts.clear(app, store)
main.post {
if (store == Store.XBOX && StoreAccounts.signedInAs(app, store) != null) return@post
accounts.remove(store); library.remove(store); status.remove(store); problems.remove(store)
logLine("${store.label}: signed out")
}
@@ -265,6 +298,7 @@ interface StoreBackend {
fun signOut(context: Context)
fun syncLibrary(context: Context, force: Boolean)
fun loadShelves(context: Context, force: Boolean)
fun search(context: Context, query: String): List<CatalogItem> = emptyList()
fun install(context: Context, item: CatalogItem, root: java.io.File)
fun uninstall(context: Context, game: InstalledStoreGame)
}
@@ -335,7 +335,7 @@ object DownloadQueue {
StoresState.logLine("installed \"${item.entry.name}\" → ${result}")
StoresState.notifyLibraryChanged(context)
val games = runCatching {
com.droiddeck.launcher.frontend.Library.launchableGames(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
com.droiddeck.launcher.frontend.LibraryCache.load(context).firstOrNull { it.gameFiles?.absolutePath == result }?.let { "app:${it.appId}" }
}.getOrNull()
StoreDownloadService.ended(context, item.entry, installed = true, gamesKey = games)
}
@@ -78,7 +78,7 @@ object SavedQueue {
put("store", i.store.id); put("id", i.id); put("title", i.title)
put("image", i.imageUrl ?: ""); put("tall", i.tallImageUrl ?: "")
put("tags", i.tags); put("developer", i.developer); put("description", i.description)
put("size", i.sizeBytes); put("owned", i.owned)
put("size", i.sizeBytes); put("owned", i.owned); put("subscription", i.subscription)
put("extra", JSONObject(i.extra))
}
@@ -89,7 +89,7 @@ object SavedQueue {
id = o.getString("id"), title = o.getString("title"),
imageUrl = o.optString("image").ifEmpty { null }, tallImageUrl = o.optString("tall").ifEmpty { null },
tags = o.optString("tags"), developer = o.optString("developer"), description = o.optString("description"),
owned = o.optBoolean("owned", true), sizeBytes = o.optLong("size"),
owned = o.optBoolean("owned", true), subscription = o.optBoolean("subscription"), sizeBytes = o.optLong("size"),
extra = extra?.keys()?.asSequence()?.associateWith { extra.getString(it) } ?: emptyMap(),
)
}
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPrefs
@@ -24,7 +25,8 @@ import java.util.concurrent.TimeUnit
object GogComet {
private const val TAG = "GogComet"
private const val BIN = "/usr/local/lib/droiddeck-comet/comet"
private const val DIR = "/usr/local/lib/droiddeck-comet"
private const val BIN = "$DIR/comet"
private val lock = Any()
@Volatile private var process: Process? = null
private var pid = -1
@@ -41,7 +43,27 @@ object GogComet {
fun setEnabled(context: Context, on: Boolean) {
GogPrefs.get(context).edit().putBoolean("achievements", on).apply()
if (on) AddonPack.COMET.ensure(context)
}
private fun stage(context: Context): Boolean {
if (!AddonPack.COMET.installed(context)) {
AddonPack.COMET.ensure(context)
return false
}
val pack = AddonPack.COMET.dir(context)
val root = File(LinuxRuntime.rootDir(context), DIR.removePrefix("/"))
val packaged = File(pack, "version").readText()
if (runCatching { File(root, "version").readText() }.getOrNull() == packaged) return true
root.mkdirs()
pack.listFiles().orEmpty().filter { it.isFile && it.name != "version" && it.name != AddonPack.COMPLETE }.forEach { source ->
val staged = File(root, source.name + ".staged")
source.inputStream().use { input -> staged.outputStream().use { input.copyTo(it) } }
staged.setExecutable(true, false)
check(staged.renameTo(File(root, source.name)))
}
File(root, "version").writeText(packaged)
return true
}
fun begin() = synchronized(lock) {
@@ -113,7 +135,8 @@ object GogComet {
return JSONObject().put("ok", true).put("ready", true).put("reason", "ready")
}
stopLocked()
if (!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
if (!runCatching { stage(context) }.onFailure { Log.w(TAG, "staging failed", it) }.getOrDefault(false) ||
!File(LinuxRuntime.rootDir(context), BIN.removePrefix("/")).isFile) return reply(false, "not-installed")
val data = File(context.filesDir, "gog-achievements").apply { mkdirs() }
val ready = CountDownLatch(1)
val command = LinuxRuntime.command(context, null, null, Environment.getExternalStorageDirectory(),
@@ -0,0 +1,34 @@
package com.droiddeck.launcher.stores.itch
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import org.json.JSONObject
internal object ItchApi {
const val KEYS_PAGE = "https://itch.io/user/settings/api-keys"
private const val BASE = "https://api.itch.io"
private const val WEB = "https://itch.io"
fun get(path: String, key: String): JSONObject? {
val body = StoreNet.get(BASE + path, bearer = key, timeoutMs = 25_000) ?: return null
return runCatching { JSONObject(body) }.getOrNull()
}
fun profile(key: String): JSONObject? = get("/profile", key)?.optJSONObject("user")
fun ownedKeys(key: String, page: Int): JSONObject? = get("/profile/owned-keys?page=$page&per_page=500", key)
fun uploads(key: String, gameId: String, downloadKeyId: String): JSONArray? =
get("/games/$gameId/uploads?download_key_id=$downloadKeyId", key)?.optJSONArray("uploads")
fun search(key: String, query: String, page: Int): JSONArray? {
val q = java.net.URLEncoder.encode(query, "UTF-8")
return get("/search/games?query=$q&page=$page", key)?.optJSONArray("games")
}
fun downloadUrl(uploadId: String, downloadKeyId: String): String =
"$BASE/uploads/$uploadId/download?download_key_id=$downloadKeyId"
fun pageUrl(slug: String): String = "$WEB/$slug"
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONObject
internal object ItchAuth {
private val TOKEN = Regex("[A-Za-z0-9]{40,64}")
fun key(context: Context): String = StoreAccounts.read(context, Store.ITCH)?.optString("access_token").orEmpty()
fun name(context: Context): String = StoreAccounts.signedInAs(context, Store.ITCH).orEmpty()
fun verify(key: String): JSONObject? = ItchApi.profile(key)
fun save(context: Context, key: String, user: JSONObject) {
StoreAccounts.write(
context,
Store.ITCH,
JSONObject()
.put("access_token", key)
.put("username", user.optString("username"))
.put("display_name", user.optString("display_name").ifBlank { user.optString("displayName") }.ifBlank { user.optString("username") })
.put("user_id", user.optLong("id")),
)
}
fun candidates(text: String): List<String> {
if (text.isBlank()) return emptyList()
val out = LinkedHashSet<String>()
text.lineSequence().map { it.trim() }.filter { TOKEN.matches(it) }.forEach { out.add(it) }
TOKEN.findAll(text).forEach { out.add(it.value) }
return out.take(8)
}
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreSizes
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import java.io.File
object ItchBackend : StoreBackend {
override val store: Store = Store.ITCH
override fun signIn(context: Context) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ItchApi.KEYS_PAGE)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun signOut(context: Context) {
ItchPrefs.get(context).edit().remove("library_cache").remove("library_synced_at").apply()
StoresState.post {
StoresState.library.remove(store)
StoresState.shelves.remove(store)
StoresState.found.remove(store)
StoresState.problems.remove(store)
StoresState.status.remove(store)
}
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
Thread({ ItchLibrary.sync(app, force) }, "itch-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
ItchShelves.cached(app)?.let { cached -> StoresState.post { StoresState.shelves[store] = cached } }
Thread({
val shelves = ItchShelves.featured(app, force)
if (shelves != null) {
StoresState.post { StoresState.shelves[store] = shelves }
} else {
StoresState.post { StoresState.problems[store] = app.getString(R.string.stores_itch_no_catalog) }
}
}, "itch-shelves").start()
}
override fun search(context: Context, query: String): List<CatalogItem> = ItchShelves.search(context, query)
override fun install(context: Context, item: CatalogItem, root: File) {
val app = context.applicationContext
if (item.extra["downloadKeyId"].isNullOrEmpty()) {
StoresState.logLine("itch.io: \"${item.title}\" is not in the library")
return
}
val folder = StoreInstallRoot.folderFor(app, Store.ITCH, item.id, item.title, root)
val entry = DownloadEntry(
Store.ITCH, item.id, item.title, cover = item.imageUrl, bytesTotal = StoreSizes.size(item),
location = StoreInstallRoot.labelFor(app, folder),
)
DownloadQueue.enqueue(app, entry) { ItchInstaller(app, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
}
@@ -0,0 +1,90 @@
package com.droiddeck.launcher.stores.itch
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
internal object ItchCatalog {
fun slug(url: String): String = url.substringAfter("://", url).trimEnd('/')
fun shelvesJson(shelves: StoreShelves): String = JSONObject()
.put("new", array(shelves.whatsNew))
.put("deals", array(shelves.deals))
.put("free", array(shelves.free))
.put("top", array(shelves.trending))
.toString()
fun shelves(raw: String?): StoreShelves? {
if (raw.isNullOrBlank()) return null
val o = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val shelves = StoreShelves(
whatsNew = list(o.optJSONArray("new")),
deals = list(o.optJSONArray("deals")),
free = list(o.optJSONArray("free")),
trending = list(o.optJSONArray("top")),
)
return shelves.takeUnless { it.isEmpty }
}
fun array(items: List<CatalogItem>): JSONArray {
val out = JSONArray()
items.forEach { out.put(json(it)) }
return out
}
fun list(raw: JSONArray?): List<CatalogItem> {
if (raw == null) return emptyList()
return (0 until raw.length()).mapNotNull { raw.optJSONObject(it)?.let(::item) }
}
private fun json(i: CatalogItem): JSONObject = JSONObject()
.put("id", i.id)
.put("title", i.title)
.put("image", i.imageUrl ?: "")
.put("tall", i.tallImageUrl ?: "")
.put("tags", i.tags)
.put("free", i.isFree)
.put("hasPrice", i.hasPrice)
.put("finalPrice", i.finalPrice)
.put("originalPrice", i.originalPrice)
.put("discount", i.discountPercent)
.put("url", i.storeUrl)
.put("developer", i.developer)
.put("publisher", i.publisher)
.put("releaseDate", i.releaseDate)
.put("description", i.description)
.put("owned", i.owned)
.put("size", i.sizeBytes)
.put("extra", JSONObject(i.extra))
.put("mature", i.mature)
private fun item(o: JSONObject): CatalogItem {
val extra = o.optJSONObject("extra")
val map = HashMap<String, String>()
extra?.keys()?.forEach { k -> map[k] = extra.optString(k) }
return CatalogItem(
store = Store.ITCH,
id = o.optString("id"),
title = o.optString("title"),
imageUrl = o.optString("image").ifBlank { null },
tallImageUrl = o.optString("tall").ifBlank { null },
tags = o.optString("tags"),
isFree = o.optBoolean("free"),
hasPrice = o.optBoolean("hasPrice"),
finalPrice = o.optString("finalPrice"),
originalPrice = o.optString("originalPrice"),
discountPercent = o.optInt("discount"),
storeUrl = o.optString("url"),
developer = o.optString("developer"),
publisher = o.optString("publisher"),
releaseDate = o.optString("releaseDate"),
description = o.optString("description"),
owned = o.optBoolean("owned"),
sizeBytes = o.optLong("size"),
extra = map,
mature = o.optBoolean("mature"),
)
}
}
@@ -0,0 +1,372 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.GuestCommand
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import org.apache.commons.compress.archivers.sevenz.SevenZFile
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.zip.ZipFile
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicBoolean
internal class ItchInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
val key = ItchAuth.key(app)
if (key.isBlank()) throw IllegalStateException(app.getString(R.string.stores_sync_sign_in))
val downloadKeyId = item.extra["downloadKeyId"].orEmpty()
if (downloadKeyId.isBlank()) throw IllegalStateException(app.getString(R.string.stores_itch_not_owned))
val gameId = item.extra["gameId"].orEmpty().ifBlank { item.id.substringAfterLast('/') }
StoreInstalls.begin(folder, Store.ITCH, item.id, item.title, item.imageUrl, item.imageUrl)
handle.stage(DownloadStage.MANIFEST)
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId))
?: throw IllegalStateException(app.getString(R.string.stores_itch_no_upload))
val uploadId = upload.optString("id")
val filename = ItchUploads.filename(upload)
val size = ItchUploads.size(upload)
val scratch = StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)
scratch.mkdirs()
val archive = File(scratch, filename.ifBlank { "download.bin" })
val exe = try {
handle.stage(DownloadStage.DOWNLOAD)
download(key, downloadKeyId, uploadId, upload.optString("updated_at", upload.optString("updatedAt")), archive, size, handle)
if (cancelled.get()) return null
handle.stage(DownloadStage.INSTALL)
install(archive, filename, handle)
} catch (t: Throwable) {
if (!cancelled.get()) StoreInstalls.discard(app, folder, listOf(scratch))
throw t
}
if (exe == null) {
if (cancelled.get()) return null
StoreInstalls.discard(app, folder, listOf(scratch))
throw IllegalStateException(app.getString(R.string.stores_itch_no_exe))
}
archive.delete()
StoreInstalls.deleteTree(scratch)
val sidecar = StoreGameSidecar(
store = Store.ITCH,
id = item.id,
title = item.title,
exe = exe,
installVersion = upload.optString("updated_at", upload.optString("updatedAt")),
installedAt = System.currentTimeMillis(),
cover = item.imageUrl,
hero = item.imageUrl,
extra = mapOf("gameId" to gameId, "downloadKeyId" to downloadKeyId, "uploadId" to uploadId),
cloud = false,
)
StoreInstalls.complete(app, folder, sidecar) { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(StoreInstallRoot.scratchDir(app, Store.ITCH, item.id)))
}
private fun download(key: String, downloadKeyId: String, uploadId: String, version: String, target: File, expected: Long,
handle: DownloadQueue.JobHandle) {
// A partial file is only resumed when it came from this exact upload build.
val origin = File(target.path + ".from")
val stamp = "$uploadId $version $expected"
if (!target.isFile || runCatching { origin.readText() }.getOrNull() != stamp) {
target.delete()
origin.writeText(stamp)
}
if (expected > 0 && target.length() == expected) {
handle.progress(expected, expected)
return
}
for (attempt in 0..1) {
val have = target.length()
var conn: HttpURLConnection? = null
try {
conn = (URL(ItchApi.downloadUrl(uploadId, downloadKeyId)).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 60_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
setRequestProperty("Authorization", "Bearer $key")
setRequestProperty("Accept", "*/*")
if (have > 0) setRequestProperty("Range", "bytes=$have-")
}
val code = conn.responseCode
if (code == 416 && attempt == 0) {
target.delete()
continue
}
if (code !in 200..299) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
val append = code == 206 && have > 0
if (have > 0) handle.log(if (append) "itch.io: resuming at ${have} bytes" else "itch.io: server ignored the resume, starting over")
var done = if (append) have else 0L
val total = conn.contentLengthLong.takeIf { it > 0 }?.let { if (append) it + have else it } ?: expected
// The first report is the queue's speed baseline, so resumed bytes don't read as instant throughput.
handle.progress(done, total)
val buffer = ByteArray(128 * 1024)
conn.inputStream.use { input ->
FileOutputStream(target, append).use { out ->
while (true) {
if (cancelled.get()) return
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
done += read
handle.progress(done, total)
}
}
}
if (total > 0 && done < total) throw IllegalStateException(app.getString(R.string.stores_itch_download_failed, code))
return
} finally {
runCatching { conn?.disconnect() }
}
}
}
private fun install(archive: File, filename: String, handle: DownloadQueue.JobHandle): String? {
folder.mkdirs()
when (val kind = sniff(archive, filename)) {
"zip" -> unzip(archive, handle)
"7z" -> sevenZip(archive, handle)
"rar" -> unrar(archive, handle)
"exe" -> copy(archive, File(folder, filename.ifBlank { "game.exe" }))
"tar" -> untar(archive, null, handle)
"gz", "bz2", "xz" -> untar(archive, kind, handle) ?: single(archive, kind, handle)
else -> copy(archive, File(folder, filename.ifBlank { "game" }))
}
val hint = filename.substringBeforeLast('.')
val exe = StoreExe.pick(folder, hint.ifBlank { null }, item.title)
if (exe.isBlank()) {
handle.log("itch.io: no exe found in ${folder.name}")
return null
}
return exe
}
private fun sniff(file: File, filename: String): String {
val extension = filename.substringAfterLast('.', "").lowercase()
if (extension == "exe") return "exe"
val head = runCatching { FileInputStream(file).use { input -> ByteArray(300).also { input.read(it) } } }.getOrNull()
?: return extension
fun at(i: Int, vararg bytes: Int) = bytes.indices.all { head.getOrNull(i + it) == bytes[it].toByte() }
return when {
at(0, 0x50, 0x4B) -> "zip"
at(0, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C) -> "7z"
at(0, 0x52, 0x61, 0x72, 0x21) -> "rar"
at(0, 0x1F, 0x8B) -> "gz"
at(0, 0x42, 0x5A, 0x68) -> "bz2"
at(0, 0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00) -> "xz"
at(257, 0x75, 0x73, 0x74, 0x61, 0x72) -> "tar"
else -> extension
}
}
private fun target(name: String): File? {
val file = File(folder, name.replace('\\', '/'))
val root = folder.canonicalPath
val path = file.canonicalPath
return if (path == root || path.startsWith(root + File.separator)) file else null
}
// No JVM library reads RAR5; the runtime's bsdtar (libarchive) does.
private fun unrar(archive: File, handle: DownloadQueue.JobHandle) {
if (!LinuxRuntime.isInstalled(app)) throw IllegalStateException(app.getString(R.string.stores_itch_archive, "rar"))
val binds = if (listOf(app.filesDir, app.cacheDir).any { folder.absolutePath.startsWith(it.absolutePath + "/") }) null
else listOf(folder.absolutePath)
fun bsdtar(args: List<String>, onLine: (String) -> Unit) =
GuestCommand.run(app, listOf("/usr/bin/bsdtar") + args, logName = "itch-unrar", extraBinds = binds, fastPath = true) { line ->
if (!line.startsWith("proot ")) onLine(line)
}
var entries = 0L
bsdtar(listOf("-tf", archive.absolutePath)) { entries++ }
var done = 0L
val tail = ArrayDeque<String>()
val code = bsdtar(listOf("-xvf", archive.absolutePath, "-C", folder.absolutePath)) { line ->
if (line.startsWith("x ")) handle.stageProgress(++done, entries) else {
if (tail.size == 5) tail.removeFirst()
tail.addLast(line)
}
}
if (cancelled.get()) return
if (code != 0) {
handle.log("itch.io: bsdtar exited $code: ${tail.joinToString(" | ")}")
throw IllegalStateException(app.getString(R.string.stores_itch_extract_failed, code))
}
}
private fun sevenZip(archive: File, handle: DownloadQueue.JobHandle) {
val total = archive.length()
var written = 0L
val buffer = ByteArray(64 * 1024)
SevenZFile.builder().setFile(archive).get().use { seven ->
while (true) {
if (cancelled.get()) return
val entry = seven.nextEntry ?: break
val file = target(entry.name)
if (file == null) {
handle.log("itch.io: skipped an entry outside the game folder")
continue
}
if (entry.isDirectory) {
file.mkdirs()
continue
}
file.parentFile?.mkdirs()
FileOutputStream(file).use { out ->
while (true) {
if (cancelled.get()) return
val read = seven.read(buffer, 0, buffer.size)
if (read < 0) break
out.write(buffer, 0, read)
written += read
handle.stageProgress(written, total)
}
}
}
}
}
private fun untar(archive: File, codec: String?, handle: DownloadQueue.JobHandle): Unit? {
val total = archive.length()
var written = 0L
var entries = 0
val buffer = ByteArray(64 * 1024)
val opened = runCatching {
FileInputStream(archive).use { raw ->
val stream = when (codec) {
"gz" -> GzipCompressorInputStream(BufferedInputStream(raw))
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
else -> BufferedInputStream(raw)
}
stream.use { input ->
TarArchiveInputStream(input).use { tar ->
while (true) {
if (cancelled.get()) return null
val entry = tar.nextEntry ?: break
entries++
val file = target(entry.name)
if (file == null) {
handle.log("itch.io: skipped an entry outside the game folder")
continue
}
if (entry.isDirectory) {
file.mkdirs()
continue
}
file.parentFile?.mkdirs()
FileOutputStream(file).use { out ->
while (true) {
if (cancelled.get()) return null
val read = tar.read(buffer, 0, buffer.size)
if (read < 0) break
out.write(buffer, 0, read)
written += read
handle.stageProgress(written, total)
}
}
}
}
}
}
true
}
if (opened.isSuccess && entries > 0) return Unit
if (opened.isFailure && codec == null) throw opened.exceptionOrNull() ?: IllegalStateException("tar")
return null
}
private fun single(archive: File, codec: String, handle: DownloadQueue.JobHandle) {
val name = archive.name.substringBeforeLast('.')
val file = target(name.ifBlank { "game" }) ?: throw IllegalStateException("archive")
file.parentFile?.mkdirs()
val total = archive.length()
var written = 0L
val buffer = ByteArray(64 * 1024)
FileInputStream(archive).use { raw ->
val stream = when (codec) {
"bz2" -> BZip2CompressorInputStream(BufferedInputStream(raw))
"xz" -> XZCompressorInputStream(BufferedInputStream(raw))
else -> GzipCompressorInputStream(BufferedInputStream(raw))
}
stream.use { input ->
FileOutputStream(file).use { out ->
while (true) {
if (cancelled.get()) return
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
written += read
handle.stageProgress(written, total)
}
}
}
}
}
private fun copy(source: File, target: File) {
target.parentFile?.mkdirs()
FileInputStream(source).use { input -> FileOutputStream(target).use { out -> input.copyTo(out, 128 * 1024) } }
}
// Streaming readers stop silently on zips whose data descriptors disagree with the local headers;
// the central directory is authoritative.
private fun unzip(archive: File, handle: DownloadQueue.JobHandle) {
val total = archive.length()
var written = 0L
val buffer = ByteArray(64 * 1024)
ZipFile.builder().setFile(archive).get().use { zip ->
for (entry in zip.entries) {
if (cancelled.get()) return
val target = target(entry.name)
if (target == null) {
handle.log("itch.io: skipped an entry outside the game folder")
continue
}
if (entry.isDirectory) {
target.mkdirs()
continue
}
target.parentFile?.mkdirs()
zip.getInputStream(entry).use { input ->
FileOutputStream(target).use { out ->
while (true) {
if (cancelled.get()) return
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
written += read
handle.stageProgress(written, total)
}
}
}
}
}
}
private companion object
}
@@ -0,0 +1,162 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.cleanStoreText
import com.droiddeck.launcher.stores.mergeItems
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
internal object ItchLibrary {
private const val KEY_CACHE = "library_cache"
private const val KEY_AT = "library_synced_at"
private const val MAX_PAGES = 20
private const val THROTTLE_MS = 6 * 60 * 60 * 1000L
private val running = AtomicBoolean(false)
fun cached(context: Context): List<CatalogItem> {
val raw = ItchPrefs.get(context).getString(KEY_CACHE, null) ?: return emptyList()
return runCatching { ItchCatalog.list(JSONArray(raw)) }.getOrDefault(emptyList())
}
fun sync(context: Context, force: Boolean) {
val app = context.applicationContext
if (!running.compareAndSet(false, true)) return
try {
val key = ItchAuth.key(app)
val cache = cached(app)
val prefs = ItchPrefs.get(app)
if (key.isBlank()) {
StoresState.post {
StoresState.status.remove(Store.ITCH)
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_sign_in)
}
return
}
val quiet = cache.isNotEmpty() && !force
if (quiet && System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < THROTTLE_MS) {
StoresState.post { StoresState.library[Store.ITCH] = cache }
return
}
if (!quiet) StoresState.post { StoresState.status[Store.ITCH] = app.getString(R.string.stores_sync_game_list) }
val items = fetch(key)
if (items == null) {
StoresState.post {
StoresState.status.remove(Store.ITCH)
if (cache.isNotEmpty()) StoresState.library[Store.ITCH] = cache
StoresState.problems[Store.ITCH] = app.getString(R.string.stores_sync_failed)
}
return
}
prefs.edit()
.putString(KEY_CACHE, ItchCatalog.array(items).toString())
.putLong(KEY_AT, System.currentTimeMillis())
.apply()
StoresState.post {
StoresState.status.remove(Store.ITCH)
StoresState.problems.remove(Store.ITCH)
StoresState.library[Store.ITCH] = mergeItems(StoresState.library[Store.ITCH] ?: cache, items)
}
StoresState.logLine("itch.io: ${items.size} owned games")
} finally {
running.set(false)
}
}
fun size(context: Context, item: CatalogItem): Long {
val key = ItchAuth.key(context.applicationContext)
if (key.isBlank()) return 0L
val downloadKeyId = item.extra["downloadKeyId"].orEmpty().ifEmpty { return 0L }
val gameId = item.extra["gameId"].orEmpty().ifEmpty { return 0L }
val upload = ItchUploads.windows(ItchApi.uploads(key, gameId, downloadKeyId)) ?: return 0L
return ItchUploads.size(upload)
}
private fun fetch(key: String): List<CatalogItem>? {
val out = ArrayList<CatalogItem>()
var page = 1
var keys = 0
while (page <= MAX_PAGES) {
val body = ItchApi.ownedKeys(key, page) ?: return null
val list = body.optJSONArray("owned_keys") ?: body.optJSONArray("ownedKeys") ?: return out
if (list.length() == 0) break
out.addAll(items(list))
keys += list.length()
page++
}
val games = out.distinctBy { it.id }
StoresState.logLine("itch.io: $keys download keys, ${games.size} games")
return games
}
internal fun items(keys: JSONArray): List<CatalogItem> {
val out = ArrayList<CatalogItem>(keys.length())
for (i in 0 until keys.length()) {
val entry = keys.optJSONObject(i) ?: continue
val game = entry.optJSONObject("game") ?: continue
item(game, entry.optString("id"))?.let { out.add(it) }
}
return out
}
private fun JSONObject.text(snake: String, camel: String): String =
optString(snake).ifBlank { optString(camel) }
internal fun item(game: JSONObject, downloadKeyId: String): CatalogItem? {
val gameId = game.optLong("id")
if (gameId <= 0L) return null
val user = game.optJSONObject("user")
val url = game.optString("url").ifBlank {
val owner = user?.optString("username").orEmpty()
if (owner.isBlank()) ItchApi.pageUrl(gameId.toString()) else ItchApi.pageUrl("$owner/$gameId")
}
val traits = game.optJSONArray("traits")?.let { arr ->
(0 until arr.length()).map { arr.optString(it) }
}.orEmpty()
val platforms = game.optJSONObject("platforms")
val sale = game.optJSONObject("sale")
val rate = sale?.optDouble("rate", 0.0) ?: 0.0
val cents = game.optLong("min_price", game.optLong("minPrice"))
val canBuy = game.optBoolean("can_be_bought", game.optBoolean("canBeBought", "can_be_bought" in traits))
val discount = if (rate > 0.0 && rate < 1.0) (rate * 100).roundToInt() else 0
val price = if (canBuy && cents > 0) money(cents) else ""
val salePrice = if (discount > 0 && price.isNotEmpty()) money((cents * (1.0 - rate)).roundToInt().toLong()) else ""
val tags = buildList {
if ("p_windows" in traits || platforms?.optBoolean("windows") == true) add("Windows")
if ("p_linux" in traits || platforms?.optBoolean("linux") == true) add("Linux")
if ("p_osx" in traits || platforms?.optBoolean("osx") == true) add("macOS")
}.joinToString(", ")
val cover = game.text("cover_url", "coverUrl").ifBlank { game.text("still_cover_url", "stillCoverUrl") }
val tall = game.text("still_cover_url", "stillCoverUrl")
val author = user?.text("display_name", "displayName").orEmpty().ifBlank { user?.optString("username").orEmpty() }
return CatalogItem(
store = Store.ITCH,
id = ItchCatalog.slug(url).ifBlank { gameId.toString() },
title = game.optString("title").ifBlank { "itch.io game $gameId" },
imageUrl = cover.ifBlank { null },
tallImageUrl = tall.ifBlank { null },
tags = tags,
isFree = price.isEmpty(),
hasPrice = price.isNotEmpty(),
finalPrice = salePrice.ifEmpty { price },
originalPrice = if (salePrice.isNotEmpty()) price else "",
discountPercent = if (salePrice.isNotEmpty()) discount else 0,
storeUrl = url,
developer = author,
publisher = author,
releaseDate = game.text("published_at", "publishedAt").take(10),
description = cleanStoreText(game.text("short_text", "shortText")),
owned = downloadKeyId.isNotBlank(),
extra = if (downloadKeyId.isBlank()) emptyMap() else mapOf("downloadKeyId" to downloadKeyId, "gameId" to gameId.toString()),
)
}
private fun money(cents: Long): String = "$" + (cents / 100) + "." + (cents % 100).toString().padStart(2, '0')
}
@@ -0,0 +1,11 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import android.content.SharedPreferences
object ItchPrefs {
const val FILE = "stores.itch"
@JvmStatic
fun get(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
}
@@ -0,0 +1,199 @@
package com.droiddeck.launcher.stores.itch
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import android.util.Xml
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import com.droiddeck.launcher.stores.cleanStoreText
import org.xmlpull.v1.XmlPullParser
import java.io.StringReader
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Locale
internal object ItchShelves {
private const val TAG = "ItchShelves"
private const val KEY = "shelves_cache"
private const val KEY_AT = "shelves_at"
private const val FRESH_MS = 3 * 60 * 60 * 1000L
private const val GAP_MS = 1_200L
private const val RETRY_MS = 20_000L
private val FEEDS = listOf(
"https://itch.io/games/newest.xml",
"https://itch.io/games/on-sale.xml",
"https://itch.io/games/price-free.xml",
"https://itch.io/games/top-rated.xml",
)
private val PLATFORMS = setOf("windows", "linux", "osx", "mac", "android", "web", "vr", "mobile")
@Volatile
private var memory: StoreShelves? = null
fun cached(context: Context): StoreShelves? {
memory?.let { return it }
val stored = stored(ItchPrefs.get(context.applicationContext))
if (stored != null) memory = stored
return stored
}
fun featured(context: Context, force: Boolean): StoreShelves? {
val prefs = ItchPrefs.get(context.applicationContext)
val fresh = System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) < FRESH_MS
if (fresh && !force) {
cached(context)?.let { return it }
}
val fetched = fetch()
if (fetched != null) {
memory = fetched
prefs.edit().putString(KEY, ItchCatalog.shelvesJson(fetched)).putLong(KEY_AT, System.currentTimeMillis()).apply()
return fetched
}
return cached(context)
}
fun search(context: Context, query: String): List<CatalogItem> {
val key = ItchAuth.key(context.applicationContext)
if (key.isBlank()) return emptyList()
val out = ArrayList<CatalogItem>()
for (page in 1..2) {
val games = ItchApi.search(key, query, page) ?: break
if (games.length() == 0) break
for (i in 0 until games.length()) {
val game = games.optJSONObject(i) ?: continue
ItchLibrary.item(game, "")?.let { out.add(it) }
}
if (games.length() < 30) break
Thread.sleep(GAP_MS)
}
return out.distinctBy { it.id }
}
private fun stored(prefs: SharedPreferences): StoreShelves? = ItchCatalog.shelves(prefs.getString(KEY, null))
private fun fetch(): StoreShelves? {
val lists = ArrayList<List<CatalogItem>>(FEEDS.size)
var answered = false
for ((index, url) in FEEDS.withIndex()) {
if (index > 0) Thread.sleep(GAP_MS)
var (code, body) = open(url)
if (code == 429) {
Log.w(TAG, "feed 429; backing off")
Thread.sleep(RETRY_MS)
val again = open(url)
code = again.first
body = again.second
if (code == 429) {
Log.w(TAG, "feed still 429; keeping the cached shelves")
lists.add(emptyList())
break
}
}
if (code in 200..299 && body != null) {
answered = true
lists.add(parse(body))
} else {
lists.add(emptyList())
}
}
if (!answered) return null
while (lists.size < FEEDS.size) lists.add(emptyList())
val art = (lists[0] + lists[3]).mapNotNull { it.imageUrl }.distinct().take(12)
val shelves = StoreShelves(lists[0], lists[1], lists[2], lists[3], art)
return shelves.takeUnless { it.isEmpty }
}
private fun open(url: String): Pair<Int, String?> {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20_000
readTimeout = 20_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
setRequestProperty("Accept", "application/xml, text/xml, application/rss+xml, */*")
}
val code = conn.responseCode
code to if (code in 200..299) conn.inputStream.bufferedReader().use { it.readText() } else null
} catch (e: Exception) {
Log.w(TAG, "feed ${e.javaClass.simpleName}")
0 to null
} finally {
runCatching { conn?.disconnect() }
}
}
internal fun parse(xml: String): List<CatalogItem> {
val out = ArrayList<CatalogItem>()
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(StringReader(xml))
var event = parser.eventType
var field: String? = null
var current: HashMap<String, String>? = null
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> {
if (parser.name == "item") current = HashMap()
field = parser.name
}
XmlPullParser.TEXT -> {
val map = current
val name = field
if (map != null && name != null) map[name] = (map[name] ?: "") + parser.text
}
XmlPullParser.END_TAG -> {
if (parser.name == "item") {
current?.let { item(it)?.let(out::add) }
current = null
}
field = null
}
}
event = parser.next()
}
return out
}
internal fun item(m: Map<String, String>): CatalogItem? {
val link = m["link"]?.trim().orEmpty().ifBlank { m["guid"]?.trim().orEmpty() }
if (link.isBlank()) return null
val title = m["plainTitle"]?.trim().orEmpty()
.ifBlank { m["title"].orEmpty().substringBefore(" [").trim() }
.ifBlank { ItchCatalog.slug(link) }
val price = m["price"]?.trim().orEmpty()
val free = price.isBlank() || price == "$0.00" || price == "0.00" || price.equals("free", ignoreCase = true)
val tags = Regex("\\[([^]]+)]").findAll(m["title"].orEmpty())
.map { it.groupValues[1].trim() }
.filterNot { it.endsWith("% Off") || it.startsWith("$") || it.equals("Free", true) || it.lowercase() in PLATFORMS }
.joinToString(", ")
return CatalogItem(
store = Store.ITCH,
id = ItchCatalog.slug(link),
title = title,
imageUrl = m["imageurl"]?.trim()?.ifBlank { null },
tags = tags,
isFree = free,
hasPrice = !free,
finalPrice = if (free) "" else price,
originalPrice = if (free) "" else m["fullPrice"]?.trim().orEmpty(),
discountPercent = m["discountpercent"]?.trim()?.toIntOrNull() ?: 0,
storeUrl = link,
releaseDate = date(m["pubDate"]),
description = cleanStoreText(m["description"]),
)
}
private fun date(raw: String?): String {
val text = raw?.trim().orEmpty()
if (text.isBlank()) return ""
return runCatching {
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).parse(text)
}.getOrNull()?.let { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(it) }.orEmpty()
}
}
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff. Show more