Compare commits

...
Author SHA1 Message Date
Kurt Himebauch 6ece38ddb8 Merge branch 'main' into feat/flatpak-support 2026-09-30 00:35:21 -04:00
The412Banner b4b31d5a08 ci: say what each DroidDeck-CI build is at the top of its release notes (#92) 2026-09-30 00:11:55 -04:00
xXJsonDeruloXx 9a4eeb3d06 feat: put the Flathub Store and AppImage import behind beta toggles in Setup 2026-09-30 00:08:57 -04:00
xXJsonDeruloXx 85add46809 docs: AppImage import and what x86 apps would take 2026-09-30 00:00:05 -04:00
xXJsonDeruloXx b78b06b78d fix: keep failed Flatpak extensions non-fatal, give Electron AppImages a bus, X11 and time to relaunch 2026-09-29 23:59:07 -04:00
The412Banner f394906726 Merge pull request #91 from Droid-Deck/feat/game-saves
Game saves: import and export save zips for added games
2026-09-29 23:49:50 -04:00
The412Banner b50dfa76d1 Game saves: show Manage saves only on added games 2026-09-29 23:36:49 -04:00
The412Banner 3f81ddff9b Game saves: Manage saves on each game's page instead of a separate page
The floppy disk beside the Steam cog and its Game saves page are gone. A game's
page now has Manage saves beside Proton prefix, a menu with Import saves (a
GameHub or Winlator zip, picked in the app's file picker; the saves there now are
backed up first), Export as GameHub zip, Export as Winlator zip (a folder picked
the same way) and Open save folder. The game's saves are looked for when the menu
opens. The Proton list and config.vdf reader only that page needed are removed.
2026-09-29 23:21:40 -04:00
The412Banner d1dec8d7a4 Game saves: import and export save zips per game
A floppy disk beside the Steam page's cog opens a Game saves page, laid out like
Components: the Proton at the top (from the client's compatibility tools, with
bannerlator-proton-arm64 shown as Proton Experimental), the zip type beside it
(GameHub or Winlator, LB / RB turn it), and the games the client runs with that
Proton (CompatToolMapping, its default for the rest), each with Import and Export.

Import picks a zip in the app's file picker, backs up the game's steamuser profile
to Download/DroidDeck/Saves/backups, then unzips drive_c with any user folder but
Public renamed to steamuser, so GameHub and Winlator zips both land. Export finds
the save folders by name under the usual roots (Bannerlator's SaveLocator), or
takes the whole profile, and writes <Game>_<date>.zip to a folder picked the same
way, under steamuser (GameHub) or xuser (Winlator). Imports wait while a session
runs. Ported from Bannerlator's GameSaveBackup and SaveLocator.
2026-09-29 23:01:21 -04:00
xXJsonDeruloXx 58ec3609b6 Merge remote-tracking branch 'origin/main' into feat/flatpak-support 2026-09-29 22:59:11 -04:00
The412Banner e60097e5cb Merge pull request #89 from Droid-Deck/fix/resume-restore-minimized
Restore a game that minimized itself behind the Steam menu
2026-09-29 22:14:53 -04:00
The412Banner 7c7bf2dcbe Merge branch 'main' into fix/resume-restore-minimized 2026-09-29 22:10:04 -04:00
Kurt Himebauch 75593cfd29 feat: the pad as SteamOS has it - Steam Input's virtual pads for games, a Steam Deck controller for the client (#86)
* feat: stand in for /dev/uinput so Steam Input's virtual pads reach games

* feat: present the pad to the Steam client as a Steam Deck controller over hidraw

* docs: log the uinput stand-in and the Steam Deck controller

* feat: feed the handheld's gyro and accelerometer to the Steam Deck controller

* feat: let the Steam page choose between the Steam Deck controller and the Xbox 360 pad

* feat: Deck back grips and trackpads on the second screen

* fix: serialize ring teardown with its writers, fall back to the Xbox pad when the Deck cannot be set up, and read motion only for a Deck

* fix: keep the second screen's choice through sleep and bring it back on wake
2026-09-29 21:57:52 -04:00
The412Banner 00d0f8cd2b gamescope-3.16.29-p4: restore a game minimized behind the Steam menu (patch 0112) 2026-09-29 21:56:42 -04:00
The412Banner faf4b4e8b5 Restore a game that minimized itself behind the Steam menu
The Steam menu is an overlay that takes input without changing gamescope's focus
window. A fullscreen wine game minimizes itself when it loses input, and gamescope
only takes a window out of iconic when the focus window changes, so after Resume
the game stayed minimized: a black screen with its caption in the top-left corner
(Titanfall 2 on GE-Proton 11, proven on device).

gamescope patch 0112 remembers the iconify request and puts the window back to
NormalState before input returns to it, then hands focus over again so wine
restores and activates it. The session script also asks gamescope to restore the
game once input comes back from the client (GAMESCOPE_RESTORE_FOCUS_WINDOW);
BL_RESTORE_ON_RESUME=0 turns that off.
2026-09-29 21:52:34 -04:00
MaxsTechReview abb3f5582b Merge branch 'main' into feat/flatpak-support 2026-09-29 20:43:37 -04:00
Kurt Himebauch 5a61d37c5d perf: faster local build, skip xalia and gamescope realtime by default, mic off by default, show the saved Deck mode (#85)
* perf: cache pinned downloads, skip unchanged proot rebuilds, and wait out a Docker Desktop restart in the local build

* perf: skip xalia and gamescope realtime queues by default, add a realtime toggle, default the mic off

* fix: show the saved Steam Deck mode on the Steam page before Performance has been opened
2026-09-29 20:00:10 -04:00
Kurt Himebauch 25079ab819 Merge pull request #84 from maxjivi05/fix/performance-misc 2026-09-29 19:41:38 -04:00
maxjivi05 736b7bed5e perf(gamescope): ask X for the pointer only while a cursor is drawn
The gamepad-cursor port queried the X pointer on every vblank, a
blocking round trip to Xwayland on the paint thread. With no cursor
image the position comes from wlserver as upstream; while the cursor is
hidden for inactivity X is asked every 50 ms. The realtime-queue comment
now says it is a no-op on KGSL Turnip.
2026-09-29 18:44:32 -04:00
maxjivi05 a3f4c911e5 perf: poll without starting processes, and stop rewriting unchanged Steam files
The session's watchers slept with sleep(1) and read files with cat, an
exec each under proot; they now wait in bash itself. steam-compat
writes its tool, the adopted wrappers and config.vdf only when they
change, the games watcher probes ownership when its matches change or
every five minutes instead of every 30 s, and Proton no longer inherits
the client's steamrtarm64 library path.
2026-09-29 18:44:32 -04:00
maxjivi05 caa7682c6f perf: default to Turnip sysmem, compact Zink descriptors and the Performance + TSO FEX preset
WinNative's defaults for every Linux session. Choices already saved by
a user are kept.
2026-09-29 18:44:32 -04:00
maxjivi05 e217cbe66d perf: pace buffer releases to the screen, read the requested refresh, drop sustained mode and ADPF
A replaced buffer went straight back to a game with no frame limit, so a
MAILBOX/IMMEDIATE swapchain drew frames that were never shown; it now
comes back once per refresh as in WinNative. gamescope's -r and the
compositor read the rate of the mode the activity asked for rather than
the one the panel was still in, and the zero-copy layer votes it.
Sustained performance mode, which caps clocks on most power HALs, and
the ADPF session, which reported frame intervals as work, are gone.
2026-09-29 18:44:32 -04:00
maxjivi05 4914b2cc69 perf: stop proot's fake_id0 and ioctl traps from stopping every stat, sendmsg and GPU ioctl
fake_id0 (loaded by -i for Xwayland's set*id) now traces only the set*id
family and the xattr fixups when the ids given are proot's own, and the
seccomp filter traces ioctl only for the termios and FICLONE requests
proot rewrites. The faccessat2 and statx exit stops are dropped on
kernels that have the calls.
2026-09-29 18:44:32 -04:00
xXJsonDeruloXx f5dfc946f2 Merge main into PR #77 2026-09-29 18:28:48 -04:00
Kurt Himebauch 6ecd6fa1d0 Merge pull request #81 from Droid-Deck/fix/proot-kompat-utsname-only
perf: stop proot's kompat from trapping hot syscalls, and stop respawning a crashing mangoapp
2026-09-29 15:38:52 -04:00
Kurt Himebauch a38952378d Merge pull request #82 from maxjivi05/fix/droiddeck-flatpak-fix 2026-09-29 15:32:45 -04:00
maxjivi05 29dc88dce1 fix: point store commands at the current network's DNS 2026-09-29 15:23:29 -04:00
xXJsonDeruloXx 0f4e83eefd fix: stop gamescope respawning a mangoapp that keeps dying at start 2026-09-29 15:08:28 -04:00
maxjivi05 56889fe2ba fix: let Flatpak create its repo where Android denies hard links 2026-09-29 15:04:07 -04:00
xXJsonDeruloXx d62525f5af perf: stop proot's kompat from trapping futex/epoll/fcntl and stripping the vDSO when only the hostname changes 2026-09-29 14:39:53 -04:00
xXJsonDeruloXx 463291d655 fix: report failed Flatpak installs as failures and deploy runtimes that ship a resolv.conf link 2026-09-29 13:54:02 -04:00
xXJsonDeruloXx b0e02124d8 feat: import ARM64 AppImages from storage and run them from the Desktop page 2026-09-29 13:33:29 -04:00
xXJsonDeruloXx 34640e83cb fix: add Flathub without a download, log store commands, keep Electron apps on X11 under gamescope 2026-09-29 13:17:09 -04:00
MaxsTechReview 4e9cc0bb36 Merge branch 'main' into feat/flatpak-support 2026-09-29 10:50:25 -04:00
Kurt Himebauch f4d2c37fbd Merge pull request #78 from Droid-Deck/fix/ci-publish-retention
Fix DroidDeck-CI main build publishing
2026-09-29 10:48:38 -04:00
xXJsonDeruloXx ffd9f92d7b ci: keep main DroidDeck-CI releases published 2026-09-29 10:46:50 -04:00
Kurt Himebauch 213d110766 Merge pull request #55 from Droid-Deck/fix/steamdeck-performance-overlay
Deck mode: working Steam performance overlay (MangoHud)
2026-09-29 09:41:51 -04:00
xXJsonDeruloXx 1a8c91b49b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	app/src/main/java/com/droiddeck/launcher/MainActivity.kt
2026-09-29 09:41:11 -04:00
xXJsonDeruloXx 5c1b29f47a fix: fetch Flatpak's supporting libraries when the runtime lacks them 2026-09-29 09:03:44 -04:00
xXJsonDeruloXx 672b5dc74c fix: let Chromium, Electron and Firefox Flatpaks start without their own sandboxes 2026-09-29 08:57:24 -04:00
xXJsonDeruloXx 76c82ee3ff fix: keep controller focus through Store tabs and app pages; show progress per Flatpak step 2026-09-29 08:46:45 -04:00
xXJsonDeruloXx 15a541d745 test: cover the sandbox planner and store helpers; docs: explain Flatpak on DroidDeck 2026-09-29 08:39:30 -04:00
xXJsonDeruloXx fe04315461 feat: Flatpak games on the desktop, Store apps on the Desktop page, safer remove 2026-09-29 08:29:09 -04:00
xXJsonDeruloXx 4fef38e87e feat: give Flatpak apps the KGSL GPU, the controllers and a CPU LLVM can target 2026-09-29 08:18:26 -04:00
xXJsonDeruloXx c63355be6c feat: run Flatpak apps through a proot bwrap stand-in and add a Flathub store 2026-09-29 08:06:50 -04:00
Kurt Himebauch 9644a4cc7f fix: use Android's 300 ms double-tap window for Back so the single-press action comes sooner (#73) 2026-09-29 02:10:51 -04:00
Kurt Himebauch 24c250fdb4 fix: send wl_keyboard modifiers so Shift and Caps Lock produce capitals (#71) 2026-09-29 02:10:01 -04:00
Kurt Himebauch d4636afa97 fix: give the QAM chord a Guide lead that stretches with slow frames so A is not taken as a press (#70)
* fix: give the QAM chord a Guide lead that stretches with slow frames so A is not taken as a press

* fix: start the QAM Guide lead at 80 ms and stretch it only when frames are slow

* fix: set the QAM Guide lead minimum to 80 ms

* fix: use Android's 300 ms double-tap window for Back so the single-press action comes sooner

* fix: keep the 500 ms Back double-press window
2026-09-29 02:01:21 -04:00
Kurt Himebauch 32930e704e feat: flood out of Stop and back into the launch button on quit (#72)
* feat: flood out of Stop and back into the launch button on quit

* refactor: drop the stop flood's warble to match the launch
2026-09-29 01:53:13 -04:00
Kurt Himebauch a8b18947a1 perf: cache the Steam client's repeated path lookups to cut startup from 31s to 6s (#69) 2026-09-28 23:18:48 -04:00
Kurt Himebauch 7c422577b6 fix: let the Steam client shut down cleanly when a session stops (#68) 2026-09-28 22:12:19 -04:00
Kurt Himebauch 1cc1ee994d feat: pair wireless debugging from a notification for the child-process gate (#67)
* feat: pair wireless debugging from a notification for the child-process gate

* fix: pair over loopback, handle Android 12 and 14 child-process routes, use D logo for notifications

* fix: read the adb tls port directly before falling back to mdns

* fix: end pairing on the same notification so progress never lingers
2026-09-28 21:37:10 -04:00
MaxsTechReviewandMaxsTechReview d8d375cb9a Steam Owned Games Importing (#62)
* Add ownership-aware Steam game folder imports

* Fix Steam title lookup and installed game manifests

---------

Co-authored-by: MaxsTechReview <[email protected]>
2026-09-28 18:13:08 -04:00
Kurt Himebauch 221644e8be Merge pull request #61 from magarto/guest-hostname-setting 2026-09-28 17:07:59 -04:00
magartus 448feb30bc Let the user choose the guest host name
0.2.0 hardcodes the proot utsname nodename to "DroidDeck". Steam sends
it as the machine name, so an install updated from an earlier build
(which reported Android's "localhost") shows up in Steam under a new
name, with the same machine-id and login. /etc/hostname cannot fix it
from inside: proot's -k override wins.

Add a "Host name" field (Performance > Session identity), stored in
SessionPrefs and passed to guestUtsname(). Only a valid RFC 1123 label
is saved; blank or invalid falls back to the old default, "DroidDeck",
so nothing changes for anyone who leaves it alone. The value is also
listed in device.txt.
2026-09-28 22:58:54 +02:00
Kurt Himebauch 9d891d4db1 Merge branch 'main' into fix/steamdeck-performance-overlay 2026-09-28 16:36:33 -04:00
MaxsTechReviewandMaxsTechReview 240f1194c0 Fix/variables (#60)
* Add editable per-game environment settings below FEX presets

* Add typed preset variable pickers and default D3D12 capability levels

---------

Co-authored-by: MaxsTechReview <[email protected]>
2026-09-28 15:17:57 -04:00
Kurt Himebauch 7b74eebce2 Merge pull request #59 from Droid-Deck/ci/discord-build-announce
ci: publish each signed main build to DroidDeck-CI and post it to Discord
2026-09-28 14:54:29 -04:00
xXJSONDeruloXx 7592684b6f ci: publish to DroidDeck-CI with the org's CI app, not a personal token
DROIDDECK_CI_TOKEN never existed, so PR Release Publish skipped its
upload, and a personal token would expire and belong to one person. Both
workflows now mint a token of the org's DroidDeck CI Publisher app,
installed on DroidDeck-CI only with Contents: write (its ID in the
DROIDDECK_CI_APP_ID variable, its key in DROIDDECK_CI_APP_KEY). No
fallback: a main build without them fails its announce job.
2026-09-28 14:51:13 -04:00
xXJSONDeruloXx 7b78e1629b ci: publish each signed main build to DroidDeck-CI for the Discord post
Run artifacts need a GitHub sign-in to download. Each signed main build
is now also a prerelease main-<sha> of the public DroidDeck-CI (all
variants and their SHA-256s; the last 10 kept, PR releases untouched),
and the Discord post links its standard apk and the release. Without
DROIDDECK_CI_TOKEN the post links the run's artifacts as before.
2026-09-28 14:49:43 -04:00
MaxsTechReview 30741302fb Merge pull request #53 from maxjivi05/fix/adaptive-joysticks
Hide idle adaptive sticks and activate them at touch positions
2026-09-28 14:48:26 -04:00
MaxsTechReview 645f7d0bf1 Merge branch 'main' into fix/adaptive-joysticks 2026-09-28 14:48:14 -04:00
xXJSONDeruloXx 124f162ee8 ci: post each signed main build to Discord
A job after the signing of a main build posts to the Discord's builds
channel: the merged PR (or the commit), and a link to the run's signed
apks. The channel's webhook URL is the DISCORD_BUILDS_WEBHOOK secret;
without it the job posts nothing and passes.
2026-09-28 14:46:41 -04:00
Kurt Himebauch 3c577fd9dd Merge pull request #57 from Droid-Deck/fix/desktop-hardware-cursor
Desktop: hand labwc's cursor to the app, so full-screen games scan out
2026-09-28 14:40:02 -04:00
MaxsTechReview 659ff4817f Expand adaptive joystick activation area by fifty percent 2026-09-28 14:39:55 -04:00
Kurt Himebauch 626adb62af Merge branch 'main' into fix/desktop-hardware-cursor 2026-09-28 14:39:52 -04:00
Kurt Himebauch 71cb100a40 Merge pull request #58 from Droid-Deck/tools/local-release-signing
build_local: sign with DroidDeck's key when a local .signing.env has it
2026-09-28 14:39:22 -04:00
xXJSONDeruloXx 68002f3fa7 build_local: sign with DroidDeck's key when a local .signing.env has it
A local build was signed with the testkey alone, which a release refuses
as an update ("signatures do not match"): testing on a device with a CI
build meant uninstalling it and losing its data first.

With an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD,
RELEASE_KEY_ALIAS) in the checkout, or in the main checkout for a
worktree, build_local.sh finishes with tools/release/sign-apk.sh as CI's
main builds do, and the apk installs over a release. Without the file
nothing changes. DROIDDECK_SIGNING_ENV points at the file explicitly.
2026-09-28 14:33:15 -04:00
xXJSONDeruloXx dae7592788 Desktop: hand labwc's cursor to the app, so full-screen games scan out
The desktop forced a software cursor (WLR_NO_HARDWARE_CURSORS=1) because
labwc's cursor never showed when handed to the app's compositor. With a
software cursor wlroots never scans out: every frame of a full-screen game
was composited by labwc first ("Direct scan-out disabled by software
cursor", 2284 times in one Adreno 825 report).

Why the cursor never showed: wlroots' Wayland backend attaches and commits
its cursor surface before wl_pointer.set_cursor gives it the cursor role,
and sets it again with nothing new committed each time the pointer enters.
The compositor released a role-less surface's buffer on commit, so the
cursor surface had no image by the time it became the cursor.

- A role-less surface's last buffer (cursor-sized only) is held until the
  next commit; set_cursor publishes it, and a repeat set_cursor of the
  surface already shown unhides its last image.
- A dma-buf cursor is read back after the client's implicit render fence,
  as the zero-copy path waits for it.
- The desktop no longer forces a software cursor.

Device-tested on the AYN Thor (Adreno 740, 1920x1080): labwc's own cursor
is drawn, with its shapes (I-beam over a terminal); wlroots logs "Direct
scan-out enabled" for a full-screen glxgears. At the same 60 fps on screen
the GPU is 3.0 % busy against 4.6 % with the software cursor, both at
550 MHz.
2026-09-28 14:33:08 -04:00
MaxsTechReview e10f5b4430 Limit adaptive stick activation near saved positions 2026-09-28 14:21:34 -04:00
MaxsTechReview 3e32b75a63 Merge branch 'main' into fix/adaptive-joysticks 2026-09-28 12:53:44 -04:00
Kurt Himebauch 435f7a1b03 Merge pull request #56 from Droid-Deck/ci/sign-all-variants
ci: sign every package variant on main
2026-09-28 12:46:40 -04:00
xXJSONDeruloXx 1fd8c50225 ci: sign every package variant on main
main's build signed only the standard package, so the pubg, antutu and
ludashi variants existed only after a manual Release APKs run. The build job
now reads the variant list from tools/release/variants.txt, as release.yml
does, and main's signing job signs all of them.
2026-09-28 12:42:56 -04:00
xXJSONDeruloXx e797bc5f01 mangoapp: stat sources for more Snapdragon generations
KGSL's files and thermal zone names differ between Snapdragon generations.
Each value now takes the first source that exists, in the order GameNative
and the Winlator forks read them: GPU load from gpu_busy_percentage or
devfreq/gpu_load, GPU clock from devfreq/cur_freq or gpuclk, GPU temperature
from KGSL's temp, devfreq/temp or a zone whose sensor name has "gpu". CPU
zones are any whose name has "cpu" and not "gpu" (cpuss-0, cpu0-silver-usr,
apc1-cpu0-usr, ...), matched without regard to case.
2026-09-28 12:28:45 -04:00
xXJSONDeruloXx 0cc3fe6bca mangoapp: GPU memory from KGSL
mangoapp's VRAM figure on a Qualcomm driver comes only from the gpu_mem_total
trace event. The tracefs stand-in's event list now declares that event as
Android kernels do, and reading the stand-in's events answers with one
record carrying the total KGSL reports in /sys/class/kgsl/kgsl/page_alloc,
which every Adreno under Android has and the app can read.
2026-09-28 12:28:45 -04:00
xXJSONDeruloXx ab8542e4e5 mangoapp: show Adreno GPU and CPU stats
Valve's mangoapp reads an Adreno GPU's load, clock and temperatures from the
paths of its own hardware, and the CPU temperature from thermal zones named as
mainline kernels name them. Every Adreno under Android keeps the GPU values in
KGSL's sysfs, readable by the app, and Android kernels name the CPU sensors
cpuss-N / cpu-N-M; the session binds each where mangoapp looks. Thermal zones
are found by sensor name, since their numbering differs per device, and
anything that cannot be found is left alone.
2026-09-28 12:19:01 -04:00
xXJSONDeruloXx c2bcf20172 mangoapp: run the performance overlay under Android
Three things kept Valve's mangoapp from drawing the overlay in a session:

- GLFW 3.4 takes XDG_SESSION_TYPE=wayland with WAYLAND_DISPLAY set as
  Wayland-only, and mangoapp needs gamescope's X server; the wrapper sets
  XDG_SESSION_TYPE=x11.
- On a Qualcomm display driver it opens a tracefs instance for the GPU memory
  total and aborts when that fails, which it always does for an Android app.
  The session preload now places the instance in an empty directory of its own
  when the real one cannot be made.
- gamescope sends it each frame's timings through a System V message queue,
  and Android kernels have no System V IPC. sysv.c now emulates the queue
  operations gamescope and mangoapp use, beside its semaphores and shared
  memory.
2026-09-28 12:19:01 -04:00
xXJSONDeruloXx 2341659cea Deck mode: enable Steam's performance overlay controls
The Quick Access menu's performance overlay toggle and level slider stayed
greyed out: the client exposes SteamClient.System.Perf only with -steamos3,
which SteamOS and Armada pass alongside -steamdeck. Deck mode now launches the
client with both. The session already puts package/beta back to the forced
branch before every start, which keeps -steamos3's own branch switch from
turning into an update loop.

The Deck mode switch moves from the Performance page to the Steam page's
Client settings, next to the client branch it selects by default.
2026-09-28 12:19:01 -04:00
xXJSONDeruloXx b2eaf380dc Blend translucent Wayland surfaces
The compositor composed every surface with a blit, which replaces what is under it. gamescope's
Wayland backend hands its overlay and notification planes to the host as full-size translucent
subsurfaces, so a Steam toast, the Quick Access menu or Deck mode's performance overlay blacked
out what was beneath it.

Surfaces whose buffer has alpha and that are not declared opaque now go through an alpha pass
(premultiplied source-over); everything else, and any image the driver cannot sample, keeps the
blit. wl_surface.set_opaque_region is honoured, and only for programs that declare opaque regions
at all, so Wine windows with a meaningless alpha channel are drawn as before. On the SurfaceControl
path a translucent plane is never promoted to the game layer, and the overlay layer is marked
translucent when its plane is.

From WinNative (maxjivi05, feature/wayland-gamescope 69218fc; GPL-3.0), with the SurfaceControl
layer handling added.
2026-09-28 12:18:50 -04:00
Kurt Himebauch 197fc3f163 Merge pull request #52 from maxjivi05/fix/splash-screen
Reduce Steam startup movie software decoding load
2026-09-28 11:35:25 -04:00
MaxsTechReview 9530e7b394 Hide idle adaptive sticks and activate them at touch positions 2026-09-28 10:54:24 -04:00
MaxsTechReview 0e0f133496 Reduce Steam startup movie software decoding load 2026-09-28 10:49:25 -04:00
Kurt Himebauch d98c211168 Merge pull request #50 from Droid-Deck/fix/release-blockers
Fix/release blockers
2026-09-28 10:32:44 -04:00
xXJsonDeruloXx 614b4d4de4 chore(release): set version 0.2.0 and remove code comments 2026-09-28 10:32:12 -04:00
Kurt Himebauch 43a19a831a docs(release): show every 0.2.0 section open (#48) 2026-09-28 10:24:24 -04:00
Kurt Himebauch 0d308af024 Merge branch 'main' into fix/release-blockers 2026-09-28 10:22:22 -04:00
The412Banner 128f73da8f Merge pull request #51 from Droid-Deck/fix/child-process-check
Child-process check: read the Developer options toggle too
2026-09-28 09:30:58 -04:00
The412Banner e22a55e949 Child-process check: read the Developer options toggle too
Developer options' "Disable child process restrictions" writes only the
feature-flag property persist.sys.fflag.override.settings_enable_monitor_phantom_procs,
never the global setting. Android (FeatureFlagUtils.isEnabled) uses the global
setting when it is set and that property otherwise, but the check read only the
global setting, so a device switched off in Developer options stayed flagged as
unset however often Check again was pressed.

The check now follows Android's order, and the Setup card no longer says "On"
for a setting that is unset or unreadable.
2026-09-28 09:24:19 -04:00
Kurt Himebauch 67cb005e6c fix(session): stop the session script's own loops when steam exits 2026-09-28 11:52:03 +00:00
Kurt Himebauch 73f76f6799 fix(home): open drawer apps in their own task 2026-09-28 11:52:03 +00:00
Kurt Himebauch 961a060461 fix(session): keep a retried session's first-frame and hud listeners 2026-09-28 11:52:03 +00:00
Kurt Himebauch dadbbf841a fix(session): cancel a session start that is still setting up 2026-09-28 11:52:02 +00:00
Kurt Himebauch 1ce8280371 fix(session): don't launch a cancelled session or end a newer one 2026-09-28 11:52:02 +00:00
Kurt Himebauch 78bdc0e3b5 chore(release): bump to 0.2.1 and check the version at release time 2026-09-28 11:52:02 +00:00
Kurt Himebauch ce5f2f5513 fix(runtime): keep the user's home when a runtime update fails mid-swap 2026-09-28 11:52:02 +00:00
Kurt Himebauch 0f2792c7ac docs: home app and dual-screen demos, trimmed 0.2.0 visuals, README hero (#47) 2026-09-27 22:48:33 -04:00
Kurt Himebauch 7185b775d4 0.2.0 release notes with screenshots, plus launch transition and UI fixes (#46)
* docs(release): draft 0.2.0 beta notes

* docs(release): credit prs and authors in 0.2.0 notes

* docs(release): add #39 and #45 to 0.2.0 notes

* chore: update readme

* fix(ui): cover the whole screen with the capsule wall

The wall was shifted right and then tilted around its own centre, which
left the bottom-left of the home and loading screens bare. Centre it and
size it to the tilted page's bounding box so every corner stays covered.

* feat(ui): flood from the play button into the throbber

Starting a session from a blue button no longer slides the session up
over the front end. The button stretches out on staggered springs until
the page is the ball's blue, the session opens on that same blue with no
window animation, and the blue gathers into the throbber's ball before
the throbber starts. Re-joining a running session, tiles and reduced
motion keep the old rise.

* docs(release): cover everything since 0.1 in the 0.2.0 notes

0.2.0 is the first release from the org, so list what DroidDeck does
as a whole, grouped by area: org pull requests as #N, pull requests
from before the move as The412Banner/DroidDeck#N, and the early work
that predates pull requests by author. CI and build work gets one
short section instead of a line each.

* docs(release): add screenshots and animations to the 0.2.0 notes

Lead with a game running and a few visual highlights, fold the full
list into collapsible sections by area, and shorten every line to the
feature and its credit. Pre-move pull requests read as legacy#N.

* fix(session): give the desktop its own loading hints

The loading screen's hints were written for Steam, so starting the
desktop or an emulator said the first run downloads Steam and that
leaving the app pauses Steam. Desktop and program sessions now rotate
through hints about the desktop instead.

* fix(build): skip incomplete NDKs when picking one locally

An interrupted sdkmanager install leaves a directory like
28.2.13676358.failed under ndk/, which sorted last and was picked
without a toolchain in it. Only consider directories that carry
source.properties.

* docs(release): show launching straight into Big Picture

* docs(release): credit the launch transition to #46
2026-09-27 22:14:23 -04:00
Kurt Himebauch cda6e89c12 fix(session): clear transient UI on pause (#45) 2026-09-27 20:37:58 -04:00
Kurt Himebauch 5f80898c27 Add game file and Proton prefix shortcuts (#39) 2026-09-27 20:37:43 -04:00
The412Banner 85560d6471 Merge pull request #43 from Droid-Deck/fix/session-stragglers
Session stop: kill guest processes proot lost, and cap the session log
2026-09-27 17:19:58 -04:00
The412Banner be6c875277 Session stop: kill guest processes proot lost, and cap the session log
Twice today a session's Xwayland aborted from one thread ("The futex
facility returned an unexpected error code"), proot lost the tracee, and
the process lived on reparented to init: proot's seccomp filter, with no
tracer left, answers ENOSYS, so Xwayland's main loop logged
"WaitForSomething(): poll: Function not implemented" until something killed
it - 9 GB after Dolphin, 13 GB after PPSSPP. --kill-on-exit and the tree
sweep only reach what proot still traces, and the straggler sweep ran only
at the next session's start.

Now a session stop also kills every process of ours whose executable lives
in the Linux runtime, and bannerlator-session keeps only the first
BL_LOG_CAP bytes (256 MB) of session.log, draining the rest so a writer
that filled it is not killed by SIGPIPE.
2026-09-27 16:52:51 -04:00
Kurt Himebauch 91beced154 Merge pull request #41 from Droid-Deck/fix/ui-ux-critique
Fix/UI ux critique
2026-09-27 16:41:18 -04:00
The412Banner ebc3da08b4 Merge pull request #42 from Droid-Deck/feat/cemu-wiiu
Cemu (Wii U) from the front end and the desktop, and two session fixes
2026-09-27 16:39:17 -04:00
xXJsonDeruloXx 41eb780495 fix(ui): remove duplicate focus imports 2026-09-27 16:34:34 -04:00
The412Banner 65fc2a38a3 Cemu (Wii U) from the front end and the desktop, and two session fixes
Cemu never showed a frame on DroidDeck: its Getting Started dialog blocked
every first launch (the folder picker cannot reach ROMs), the session's
controller helper aborted it, and the desktop's Vulkan renderer drew no
cursor. With the Turnip KGSL fix in the driver and the haptic-optional
Cemu in the catalog, this makes the app side complete:

- bannerlator-pad-defaults seeds Cemu's settings.xml once: the Wii U games
  folder, Vulkan, and Cubeb audio on the default device for both the TV
  and the GamePad speaker at full volume (Wipeout 3 plays through the
  GamePad; Cemu's own default is 20 %).
- The Cemu tile launches full screen (-f).
- libfakeinput.so is preloaded into every session process and exported its
  static C++ runtime (~7000 symbols); programs unwound through it and
  aborted (shadPS4 in pthread_exit, Cemu's controller thread). Link with
  --exclude-libs,ALL, and fail the build if the runtime leaks out again.
- On the Vulkan and GLES desktops the pointer worked but no cursor was
  drawn: wlroots hands the cursor to the app's compositor, which draws
  none. WLR_NO_HARDWARE_CURSORS=1 makes labwc composite it itself.

Device-tested on the FIT: Super Mario 3D World in-game at 35-37 fps with
sound, Deus Ex HR DC and Wipeout 3 launch with sound; RPCS3, Dolphin and a
Steam session (FlatOut) unchanged on the same driver.
2026-09-27 16:30:49 -04:00
Kurt Himebauch 359d1535ac fix(copy): american spelling, clearer keyboard and package labels 2026-09-27 14:23:19 -04:00
Kurt Himebauch 4c3a9f027c fix(focus): land controller focus by frame instead of fixed delays 2026-09-27 14:22:56 -04:00
Kurt Himebauch 5a6cc3c76e fix(motion): honor the system animation scale everywhere and on resume 2026-09-27 14:22:17 -04:00
Kurt Himebauch 89bd7deceb refactor(ui): share one frame generation menu and drop the unused dialog 2026-09-27 14:21:41 -04:00
Kurt Himebauch 169f2c3e34 fix(rail): keep sections in fixed places with an empty games state 2026-09-27 14:21:01 -04:00
Kurt Himebauch e6a797695a fix(setup): trim child-process fix to one line and three actions 2026-09-27 14:20:35 -04:00
Kurt Himebauch f1a2ecc857 fix(ui): raise buttons, chips and tabs to handheld touch sizes 2026-09-27 14:20:03 -04:00
Kurt Himebauch 36916f6bde fix(ui): show launch and tab hints where a pad does that 2026-09-27 14:19:32 -04:00
Kurt Himebauch 1b96d13a51 fix(ui): enable launches like play and say why they are disabled 2026-09-27 14:19:02 -04:00
Kurt Himebauch cb1d70bb14 fix(ui): flip booleans with a switch instead of on/off menus 2026-09-27 14:18:29 -04:00
Kurt Himebauch 37af590ca2 fix(ui): use one name each for child-process limit, proton and components 2026-09-27 14:17:49 -04:00
Kurt Himebauch 02f356f8c0 fix(session): keep ended screen with retry, share logs and back 2026-09-27 14:17:16 -04:00
Kurt Himebauch 5448177814 Merge pull request #40 from Droid-Deck/fix/review-hardening
Fix/review hardening
2026-09-27 13:57:20 -04:00
Kurt Himebauch ed6ce0ca41 Merge branch 'main' into fix/review-hardening 2026-09-27 12:27:22 -04:00
Kurt Himebauch 9b440686c5 Merge pull request #37 from maxjivi05/fix/performance-enhancements 2026-09-27 12:26:53 -04:00
xXJsonDeruloXx e7b7e9dc43 refactor(session): move the overlay setup out of SessionActivity.onCreate 2026-09-27 11:57:43 -04:00
xXJsonDeruloXx 1120040aee refactor(session): split runSession into its setup stages 2026-09-27 11:57:08 -04:00
xXJsonDeruloXx 3506a272da refactor(core): share one checksum helper across downloads 2026-09-27 11:56:15 -04:00
xXJsonDeruloXx b752748b7d refactor(compositor): move zwp_linux_dmabuf_v1 into wl_dmabuf.c 2026-09-27 11:54:48 -04:00
xXJsonDeruloXx 8990e7d7a4 refactor(launcher): resolve displayNameOf on the activity in DriverMenus 2026-09-27 11:48:05 -04:00
xXJsonDeruloXx c92a423ba1 refactor(launcher): move components, decky and proton state out of MainActivity 2026-09-27 11:43:35 -04:00
xXJsonDeruloXx 3b82b3e1f8 refactor(launcher): move driver menu state out of MainActivity 2026-09-27 11:41:21 -04:00
xXJsonDeruloXx e985ce1975 refactor(ui): move the session loading screen out of SessionOverlay 2026-09-27 11:39:57 -04:00
xXJsonDeruloXx 8496ce3e51 refactor(files): split FileManagerScreen helpers, items and dialog 2026-09-27 11:39:57 -04:00
xXJsonDeruloXx 47188f40ed refactor(ui): split FrontEndScreen into per-page files 2026-09-27 11:33:09 -04:00
Kurt Himebauch e186c9bc4c Merge branch 'main' into fix/performance-enhancements 2026-09-27 11:31:48 -04:00
xXJsonDeruloXx 7705f3c222 feat(runtime): install the runtime from a foreground service 2026-09-27 11:01:55 -04:00
xXJsonDeruloXx 5675b4897f build(deps): bump commons-compress to 1.27.1 2026-09-27 11:00:47 -04:00
xXJsonDeruloXx 1c87d92ef7 build(native): enable warnings for the compositor 2026-09-27 10:55:25 -04:00
xXJsonDeruloXx 031e64dfe1 fix(compositor): bound cursor buffer copies by stride and 64-bit size 2026-09-27 10:55:25 -04:00
xXJsonDeruloXx f590dd9763 fix(security): require a verified checksum for proton downloads 2026-09-27 10:54:31 -04:00
xXJsonDeruloXx af7dc40538 fix(security): require sha256 for every catalog and release download 2026-09-27 10:54:05 -04:00
xXJsonDeruloXx a28aae6e4a test: add unit tests for archive paths and log redaction 2026-09-27 10:52:01 -04:00
xXJsonDeruloXx c024470cc7 fix(runtime): refuse hard links that point outside the rootfs 2026-09-27 10:51:14 -04:00
xXJsonDeruloXx 16ae5ab84a fix(ui): keep the launcher alive when a pad connects 2026-09-27 10:49:54 -04:00
xXJsonDeruloXx 144947305a fix(runtime): single-flight the runtime install and reattach after rebuild 2026-09-27 10:49:54 -04:00
Kurt Himebauch 563bc6cad7 Merge pull request #38 from Droid-Deck/feat/steam-startup-toggle
Add optional Steam startup
2026-09-27 09:55:22 -04:00
xXJsonDeruloXx 59cde93821 Add optional Steam startup 2026-09-27 09:27:30 -04:00
maxjivi05 c104e1c276 proot: port WinNative's seccomp and tracee performance fixes
Brings the proot patch set up to WinNative 53836ca9 on top of the #772 port:

- trace prctl, setrlimit and prlimit64 in the seccomp filter only for the
  arguments proot acts on, and uname only where the core rewrites it
- answer a seccomp stop's filter flags from the registers fetched for its
  translation, reuse those registers, and skip the sysexit stop of wait4 left
  to the kernel and accept without a sockaddr; the flags come from proot's list
  merged with the enabled extensions, and a syscall an extension replaced
  keeps its exit stop
- take a clone3 child's flags and exit signal from struct clone_args
- name a thread's /proc/self by its thread group and resolve /proc/thread-self
- translate fchmodat2, send openat2 callers to openat, and bound sysnum lookups
  by the table length
- sweep relatives only for tracees that have children or ptracees, and empty
  the per-stop memory collector instead of reallocating it
2026-09-27 08:10:58 -04:00
Kurt Himebauch b5da0d0db6 Merge pull request #36 from Droid-Deck/fix/steam-startup-movie-overrides
Fix Steam startup movie override paths
2026-09-27 00:31:31 -04:00
xXJsonDeruloXx 5defc3e38b fix: use Steam startup movie override names 2026-09-27 00:16:37 -04:00
Kurt Himebauch 6a9f52f932 Merge pull request #35 from Droid-Deck/ui/launcher-refresh
feat(ui): launcher ui refresh
2026-09-26 23:19:09 -04:00
xXJsonDeruloXx 06a8d75769 feat(session): animate the logo on the loading screen 2026-09-26 22:44:09 -04:00
xXJsonDeruloXx ec12ddd0a8 ui: preserve rail focus on components 2026-09-26 22:16:38 -04:00
xXJsonDeruloXx 570902d5f3 ui: add fullscreen launcher and focus tabs 2026-09-26 22:10:17 -04:00
xXJsonDeruloXx 6120069975 ui: simplify suspended session overlay 2026-09-26 21:56:55 -04:00
xXJsonDeruloXx 3e337c9bda fix(ui): name the desktop cog's click 2026-09-26 21:37:40 -04:00
xXJsonDeruloXx 2869e76406 feat(ui): turn the steam tab into a capsule wall 2026-09-26 21:31:26 -04:00
xXJsonDeruloXx 61fdb80823 feat(ui): add a games tab with list and detail 2026-09-26 21:30:19 -04:00
Kurt Himebauch fd180a108a Merge branch 'main' into ui/launcher-refresh 2026-09-26 20:46:47 -04:00
Kurt Himebauch 4de74c740c Merge pull request #34 from Droid-Deck/fix/droiddeck-logo-white-on-black
Invert DroidDeck logo colors
2026-09-26 20:41:45 -04:00
xXJsonDeruloXx b7c32c92d2 Invert DroidDeck logo colors 2026-09-26 20:39:52 -04:00
xXJsonDeruloXx 8610ca0928 fix(ui): raise small text on the remaining pages 2026-09-26 16:43:46 -04:00
The412Banner a6b1ad37d0 Merge pull request #33 from Droid-Deck/fix/steam-readonly-files
First Steam session: Proton ARM64 ready at sign-in; Steam updates past read-only files
2026-09-26 16:43:35 -04:00
xXJsonDeruloXx 97b13f7488 feat(session): stack the loading screen on small screens 2026-09-26 16:42:58 -04:00
xXJsonDeruloXx 5d5b45a012 feat(session): explain paused sessions and offer stop 2026-09-26 16:42:28 -04:00
xXJsonDeruloXx 5ebafaabda feat(session): compact the session menu on short screens 2026-09-26 16:41:17 -04:00
xXJsonDeruloXx 56627a8aaf feat(session): redesign the session menu header and actions 2026-09-26 16:40:30 -04:00
xXJsonDeruloXx f59ced4e98 feat(ui): rework the components page layout 2026-09-26 16:38:53 -04:00
xXJsonDeruloXx 296e848718 feat(ui): split setup into tabs 2026-09-26 16:36:49 -04:00
xXJsonDeruloXx 8c63413019 feat(ui): redesign the desktop and emulator pages 2026-09-26 16:35:07 -04:00
xXJsonDeruloXx 2861c6d284 feat(ui): fit the game page to narrow screens 2026-09-26 16:33:53 -04:00
xXJsonDeruloXx 69c2d8e566 feat(ui): condense the steam page on small screens 2026-09-26 16:33:19 -04:00
xXJsonDeruloXx 414fddf7b8 feat(ui): scroll launcher pages as one and wrap actions 2026-09-26 16:32:04 -04:00
xXJsonDeruloXx b5ca6145a6 feat(ui): show an icon-only rail on narrow screens 2026-09-26 16:31:10 -04:00
xXJsonDeruloXx 5a5948b071 feat(ui): reflow settings pages in narrow panes 2026-09-26 16:30:21 -04:00
xXJsonDeruloXx e58b5088b3 feat(theme): add readable text colour for signal fills 2026-09-26 16:29:05 -04:00
The412Banner ecbce4c8d3 Steam updates: the client is not uid 0 in the session - drop that test
The session's proot runs with -i <app uid>, and the rootfs names that uid
root, so the client calls itself root while geteuid() returns the app's
uid. The read-only retry required geteuid() == 0 and never ran: the
Proton Experimental (ARM64) update still failed with Missing file
permissions on wineopenxr.dll (2026-09-26 16:07, 2,471 read-only files).

Also recognise the client by the path it was started by: the kernel's
/proc/self/exe names proot's loader for every process in the session.
2026-09-26 16:10:11 -04:00
The412Banner 96bf0a3bf3 First Steam session: Play sends no mode, and no mode is Steam - seed the Proton then too 2026-09-26 15:44:20 -04:00
The412Banner da7522a3ee First Steam session: place Valve's ARM64 Proton before the client first starts
The runtime setup now also fetches Proton Experimental (ARM64) and its
appmanifest from a catalog of their own (steam-seed.json) and unpacks them
where the client keeps them. At the first sign-in the compatibility tool is
already installed and the default, with no install dialog, no download and
no client restart; the client adopts the manifest and updates it like any
other app.

Done once: an install the client fetched itself, or one the user removed,
is left alone. A failed fetch stops nothing - the session asks the client
for it after sign-in, as before.
2026-09-26 15:36:23 -04:00
The412Banner 5605e777ca Merge remote-tracking branch 'org/main' into fix/steam-readonly-files 2026-09-26 15:33:02 -04:00
The412Banner a10e2e1fe8 Merge remote-tracking branch 'org/fix/first-login-proton-timing' into fix/steam-readonly-files 2026-09-26 15:33:01 -04:00
xXJsonDeruloXx 4fb623fa33 fix(ui): guard missing runtime version in setup 2026-09-26 15:19:40 -04:00
xXJsonDeruloXx 3b4956d088 feat(ui): add controller button hints 2026-09-26 15:19:00 -04:00
xXJsonDeruloXx 18deaafc34 feat(session): show staged progress on loading screen 2026-09-26 15:18:35 -04:00
xXJsonDeruloXx 0c4eede6d2 feat(ui): add system check to setup 2026-09-26 15:16:14 -04:00
xXJsonDeruloXx 54681f2647 feat(ui): add hero and launch settings to game page 2026-09-26 15:14:59 -04:00
xXJsonDeruloXx 5271edd972 feat(ui): add continue hero to steam page 2026-09-26 15:13:20 -04:00
xXJsonDeruloXx e4bb9856ef style(ui): drop random hue washes behind pages 2026-09-26 15:12:14 -04:00
xXJsonDeruloXx e462bbb24a feat(ui): replace nav drawer with permanent side rail 2026-09-26 15:11:47 -04:00
xXJsonDeruloXx d8f3090d9b refactor(ui): move build label into setup about 2026-09-26 15:09:34 -04:00
xXJsonDeruloXx d1af8b0114 feat(library): read hero art and last played from steam 2026-09-26 15:09:07 -04:00
xXJsonDeruloXx dae64bbe69 feat(ui): show game titles on library tiles 2026-09-26 15:08:30 -04:00
xXJsonDeruloXx 8b127bed84 fix(ui): raise small text to 12sp minimum 2026-09-26 15:07:42 -04:00
xXJsonDeruloXx 67187b1340 feat(theme): add graphite theme as default 2026-09-26 15:07:10 -04:00
Kurt Himebauch 02cac57438 Merge pull request #31 from Droid-Deck/docs/readme-banner
docs: add readme banner
2026-09-26 15:03:58 -04:00
The412Banner 277dcd174c Steam updates: rewrite read-only content files the way root could
The client runs as proot's root, which the kernel does not see, and
leaves many content files read-only; an update that rewrites one was
refused (EACCES) and cancelled with "Missing file permissions"
(Proton Experimental (ARM64), wineopenxr.dll). For the client's own
processes, a refused write-open of a file the app owns now gets the
owner's write bit - on the file, or on the directory a new file goes
into - and one more try. Games and Wine keep ordinary permissions.
2026-09-26 10:41:06 -04:00
xXJSONDeruloXx 3c23ae7310 docs: add readme banner 2026-09-26 09:48:11 -04:00
The412Banner 8cb13d40b5 First sign-in: ask for the ARM64 Proton after the client's post-logon compat phase, and retry
With Steam publicbeta 1790380355 the client spent 27 s in its post-logon
compat phase and dropped the install request the session sent 15 s after
sign-in, so a clean install never offered Proton Experimental (ARM64).
Wait for the console log's "Waiting for compat in post-logon took" (90 s
at most), then ask, up to three times a minute apart until the install
starts.
2026-09-26 08:21:14 -04:00
The412Banner c950d31960 Merge pull request #29 from Droid-Deck/feat/components-manager
Components manager: swap FEX / DXVK / VKD3D-Proton per Proton, enforced at launch
2026-09-26 06:16:04 -04:00
The412Banner ae570cd5b7 Components drawer: stacked rows so long names never squeeze the text, a focusable Running now card the pad can reach, and only the Proton a game actually runs on counts as running 2026-09-26 05:30:02 -04:00
The412Banner 0fd1dc4fce Proton wrappers: run the components check for non-Steam shortcuts too - decide by the game's own prefix rather than the app id, which is 0 for a shortcut 2026-09-26 05:15:36 -04:00
The412Banner f0854e8518 Merge org main into feat/components-manager 2026-09-26 04:41:10 -04:00
The412Banner ceffd3f759 Merge org main into feat/components-manager
Keeps both onResume additions: the phantom status refresh from main and the queued component swaps.
2026-09-26 04:36:02 -04:00
Kurt Himebauch dc1816612c Merge pull request #28 from Droid-Deck/fix/session-desktop-button-label
Keep the session Desktop button on one line
2026-09-26 00:53:17 -04:00
xXJsonDeruloXx 1d04a7e0e3 Keep session desktop button label on one line 2026-09-26 00:52:53 -04:00
Kurt Himebauch 1e523c4e93 Merge pull request #27 from Droid-Deck/fix/boot-video-pitch-black
Set boot movie background to pitch black
2026-09-26 00:36:22 -04:00
xXJsonDeruloXx d83f766cf3 Set boot movie background to pitch black 2026-09-26 00:35:54 -04:00
The412Banner bb5e1ae63d Proton wrappers: run the components check only for a real game launch (waitforexitandrun, non-zero app id)
With the check reached from the client's own runs right after login, the Steam client segfaulted
within seconds, four sessions out of four; the same build without the check (test/bisect-no-launch-check)
and the last build before it both reach Big Picture. Now every run that is not a game launch -
the client's probes, app id 0 - is left exactly as before. Each check logs one line.
2026-09-26 00:28:49 -04:00
Kurt Himebauch f27a4f6c95 Merge pull request #26 from Droid-Deck/ui/build-info-top-bar
Show build identity in the launcher top bar
2026-09-26 00:24:39 -04:00
The412Banner d7ff92106a Remove a stray __pycache__ from the overlay scripts and ignore it 2026-09-26 00:20:41 -04:00
xXJsonDeruloXx 34a17acaba Show build identity in launcher top bar 2026-09-26 00:03:56 -04:00
The412Banner 110aec0b6f Proton wrappers: drop the per-tool prefix for Steam's tool-0 runs - the client segfaulted right after its probe started (first in 25 sessions); keep the launch-time component check 2026-09-26 00:03:45 -04:00
The412Banner 06502bb49b Components: choices enforced at every game launch; Steam's tool-0 probes get their own prefix per Proton
Steam runs its graphics probe through every registered Proton at once after login, all in
compatdata/0. Prefix system32 links point into the Proton that set it up last, and the other
Protons' Wines, running outside Proton's pfx.lock, wrote their builtin DLLs through them into that
Proton's tree (device: GE's FEX DLLs became Proton Experimental's at session start).

- bannerlator-steam-compat: both wrappers (Valve's depot launcher and the third-party
  bannerlator-proton-wrap) give a tool-0 run STEAM_COMPAT_DATA_PATH=.../compatdata/0-<tool>, and
  before every other launch check the Proton's files against the app's desired.tsv and put back
  anything that differs (same Proton build only), logging to reapplied.log.
- ComponentsManager writes desired.tsv plus unpacked copies of the packages in use into the
  runtime after every change; the page and the drawer show 're-applied at launch'.
2026-09-25 23:54:10 -04:00
Kurt Himebauch c20a166134 Merge pull request #21 from Droid-Deck/fix/phantom-process-setup-gate
Gate Steam sessions on Android process limit
2026-09-25 23:51:11 -04:00
xXJsonDeruloXx 162fa8e2de Merge remote-tracking branch 'origin/main' into fix/phantom-process-setup-gate 2026-09-25 23:50:57 -04:00
xXJsonDeruloXx ae5614d212 Reuse saved wireless ADB pairing and connection 2026-09-25 23:48:47 -04:00
xXJsonDeruloXx b72d8a8bab Clarify Wireless debugging connection address after pairing 2026-09-25 23:39:31 -04:00
Kurt Himebauch d4cf890d8a feat: set DroidDeck startup movie by default (#25) 2026-09-25 23:36:29 -04:00
xXJsonDeruloXx 82e494269d Improve wireless ADB text input 2026-09-25 23:17:28 -04:00
The412Banner c982d65675 Session drawer: tab spacing fits the room between LB and RB, so all four tabs show 2026-09-25 22:59:50 -04:00
The412Banner f4e606fb15 Components: the pad's LB / RB step through FEX, DXVK and VKD3D-Proton (wrapping), with LB / RB badges beside the tabs 2026-09-25 22:49:10 -04:00
The412Banner 9dbdb5e771 Components: fixed columns so details and Download buttons line up; focused button is a solid blue fill with a white outline 2026-09-25 22:37:07 -04:00
The412Banner fa12478fdd Components: compact layout - one toolbar line (title, Proton drop-down, component tabs, in use, (i), refresh) and one list of short rows for installed and available packages 2026-09-25 22:27:25 -04:00
The412Banner c3b0d8ef03 Merge pull request #23 from Droid-Deck/docs/progress-log-0925
Progress log: 2026-09-25
2026-09-25 22:06:23 -04:00
The412Banner cbb9a168e6 Components: FEX, DXVK and VKD3D-Proton per Proton
A Components entry under Desktop opens a page built like the driver pages: a
Proton drop-down, FEX / DXVK / VKD3D-Proton tabs, what is in use, the installed
rows (each Proton build's original bundle and the stored packages; tap to
swap in, trash to delete), downloads from the Nightlies -Linux releases behind
the refresh button, and Import .wcp.

The session drawer gets a fourth tab, Components, for quick swaps between
what is installed. A swap into the Proton a running game uses waits until the
game closes and is applied then (drawer refresh, or the main screen on resume).

ComponentsManager keeps the same package format, originals layout and state as
the Decky Components Manager plugin and takes its data over once. A swap clears
only the component's own DLLs, so files a Proton keeps beside them stay.
2026-09-25 22:04:38 -04:00
The412Banner 73e2fc58a0 Merge pull request #24 from Droid-Deck/feat/drawer-tab-icons
Session drawer: QAM-style page tabs (Display, Controls, Settings)
2026-09-25 21:55:16 -04:00
The412Banner 500f4c69eb Session drawer: QAM-style page tabs - Display, Controls, Settings
The three page dots between LB and RB become icons standing in a line in page order, as in
Steam's Quick Access Menu. The selected one steps forward (full size, bright, a soft glow in the
accent), the others step back to half size and fade, and the line leans a little toward the
selection; tapping or picking a tab with the pad, and LB / RB, turn the page as before. The
first page's heading becomes Display to match its icon.
2026-09-25 18:11:04 -04:00
xXJsonDeruloXx aa843b5a31 Merge remote-tracking branch 'origin/main' into fix/phantom-process-setup-gate 2026-09-25 17:23:08 -04:00
xXJsonDeruloXx f249acc50f Compact Steam process limit recovery for split screen 2026-09-25 17:21:43 -04:00
xXJsonDeruloXx 9422608960 Replace process gate dialogs with compact pages 2026-09-25 16:34:30 -04:00
Kurt Himebauch de37ddcfb5 Merge branch 'main' into fix/phantom-process-setup-gate 2026-09-25 16:28:53 -04:00
xXJsonDeruloXx 4c90196890 Replace process gate dialogs with pages 2026-09-25 16:28:15 -04:00
xXJsonDeruloXx 30f6ca6ffc Replace wireless ADB modal with setup page 2026-09-25 16:19:01 -04:00
xXJsonDeruloXx ecff3240a6 Add reversible process limit controls 2026-09-25 14:52:04 -04:00
xXJsonDeruloXx ddd4a0c24d Add wireless ADB process-limit repair 2026-09-25 14:35:37 -04:00
xXJsonDeruloXx 019949847a Auto-dismiss process limit gate after status clears 2026-09-25 13:55:04 -04:00
xXJsonDeruloXx 4dcbf269d2 Gate Steam sessions on Android process limit 2026-09-25 13:49:58 -04:00
213 changed files with 21934 additions and 3898 deletions

No files matched your search

+159 -5
View File
@@ -22,11 +22,13 @@ permissions:
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Replace older builds only for the same PR. Each push/dispatch gets its own group so one main
# build cannot cancel another before it signs and publishes.
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v1
PRELOAD_CACHE_VERSION: v2
jobs:
build:
@@ -34,12 +36,18 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
outputs:
variants: ${{ steps.variants.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
- name: No private key in the tree
run: |
set -euo pipefail
@@ -160,7 +168,7 @@ jobs:
D=app/src/main/assets/linuxfs
mkdir -p "$D"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
@@ -270,6 +278,12 @@ jobs:
for sym in open openat ioctl read close poll ppoll select stat fstat access scandir; do
grep -qx "$sym" <<<"$FAKE" || { echo "::error::libfakeinput.so does not export $sym"; exit 1; }
done
# The static C++ runtime stays private: every session process preloads this library,
# and an exported personality routine takes over other programs' unwinding (shadPS4
# aborted in pthread_exit).
if grep -qxE '__gxx_personality_v0|__cxa_throw|_Unwind_Resume' <<<"$FAKE"; then
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
fi
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt statfs statvfs; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
@@ -295,6 +309,12 @@ jobs:
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
- name: Check the native dependency closure
run: |
set -euo pipefail
@@ -418,5 +438,139 @@ jobs:
uses: ./.github/workflows/sign.yml
with:
environment: signing-main
variants: '["standard"]'
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
# Discord's builds channel linking to it. The release is made with a token of the org's
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
announce:
needs: [build, sign]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
env:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
steps:
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-signed-*
merge-multiple: true
path: signed
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
# What this build is, for the top of the release notes: the merged PR's title and the first
# paragraph of its description, or the commit's subject for a push made without a PR.
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
if [ -n "$pr" ]; then
number=$(jq -r .number <<<"$pr")
title=$(jq -r .title <<<"$pr")
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
else
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
echo "**Pushed to main** without a PR - $subject" > notes-head.md
fi
cat notes-head.md
- name: Publish to DroidDeck-CI
id: publish
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
run: |
set -euo pipefail
tag=main-${GITHUB_SHA::7}
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="$(cat notes-head.md)
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
\`\`\`
$(cat signed/SHA256SUMS.txt)
\`\`\`"
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
# Keep the newest main builds by publication time. GitHub App releases can share createdAt.
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
while IFS= read -r old; do
# Never remove this run's release, even if the API returns an unexpected ordering.
if [ "$old" = "$tag" ]; then
continue
fi
# Another main publish may have removed the same old release concurrently.
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
echo "::error::Could not delete old release $old"
exit 1
fi
fi
done
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
echo "release=$url" >> "$GITHUB_OUTPUT"
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
echo "Published $url"
- name: Post to Discord
env:
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
RELEASE: ${{ steps.publish.outputs.release }}
APK: ${{ steps.publish.outputs.apk }}
run: |
set -euo pipefail
if [ -z "$WEBHOOK" ]; then
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
exit 0
fi
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
# A merge commit's subject is "Merge pull request #N from ..."; its body's first line is
# the PR's title, which says more.
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
if [ -n "$pr" ]; then
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
else
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
fi
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
+37 -9
View File
@@ -79,8 +79,9 @@ jobs:
}
stable=$(package_line "$RUNNER_TEMP/stable/DroidDeck-$stable_tag.apk")
candidate=$(package_line "${apks[0]}")
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" == "$stable" ]] || {
echo "::error::PR package/versionCode '$candidate' differs from stable '$stable'"; exit 1;
[[ "$stable" =~ ^com\.droiddeck\.launcher\ [0-9]+$ && "$candidate" =~ ^com\.droiddeck\.launcher\ [0-9]+$ &&
${candidate##* } -ge ${stable##* } ]] || {
echo "::error::PR package/versionCode '$candidate' cannot install over stable '$stable'"; exit 1;
}
current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
@@ -114,28 +115,55 @@ jobs:
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: actions/download-artifact@v4
with:
name: droiddeck-pr-signed-standard
path: signed
- name: Publish to DroidDeck-CI when configured
# What this build is, for the top of the release notes: the PR's title and the first
# paragraph of its description.
- name: Describe the change
env:
GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
title=$(jq -r .title <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
cat notes-head.md
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Publish to DroidDeck-CI
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ inputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
LABEL: ${{ needs.authorize.outputs.label }}
run: |
set -euo pipefail
if [[ -z "$GH_TOKEN" ]]; then
echo '::warning::No CI token; download the signed APK from this run'
exit 0
fi
repo=Droid-Deck/DroidDeck-CI
tag="pr-$PR_NUMBER"
apk="DroidDeck-$LABEL.apk"
sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1)
notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
notes="$(cat notes-head.md)
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
Commit: $HEAD_SHA
APK SHA-256: $sha"
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
+4
View File
@@ -35,6 +35,10 @@ jobs:
run: |
set -euo pipefail
[[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; }
name=$(sed -n 's/^ *versionName "\(.*\)"$/\1/p' app/build.gradle)
[[ $VERSION == "$name" || $VERSION == "$name"-* ]] || {
echo "::error::version '$VERSION' does not match versionName '$name' in app/build.gradle: bump versionName and versionCode first"; exit 1;
}
runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \
--status success --json databaseId,event,headSha -L 20)
id=$(jq -r --arg sha "$GITHUB_SHA" \
+5
View File
@@ -12,6 +12,7 @@ captures/
# copies cannot drift.
app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
# Kotlin compiler session files (build-tool state, never source).
@@ -28,3 +29,7 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
!keystore/testkey.p12
droiddeck-release-key/
password.txt
__pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
+10 -3
View File
@@ -1,10 +1,17 @@
# DroidDeck
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="artwork/droiddeck-banner-dark.svg">
<img alt="DroidDeck" src="artwork/droiddeck-banner-light.svg" width="100%">
</picture>
</p>
Run Valve's native ARM64 Steam client on an Adreno Android device. Steam runs in a Linux runtime under proot, with gamescope and an in-app Vulkan compositor. Windows games use Valve's ARM64 Proton and FEX. A desktop with LXQt, Firefox, and emulators is also available.
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
+18 -3
View File
@@ -63,8 +63,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 8
versionName "0.1.7"
versionCode 9
versionName "0.2.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
ndk {
@@ -121,6 +121,10 @@ android {
}
packagingOptions {
resources {
// Kadb's Bouncy Castle artifacts repeat the same multi-release JAR manifest.
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
}
jniLibs {
// The PulseAudio daemon is executed from the native library directory and its
// libraries are read out of the apk by the class loader: both want the old,
@@ -136,6 +140,11 @@ android {
buildConfig true
}
testOptions {
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
unitTests.returnDefaultValues = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -148,7 +157,7 @@ android {
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.20'
implementation 'org.apache.commons:commons-compress:1.27.1'
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
@@ -160,6 +169,12 @@ dependencies {
implementation 'androidx.compose.material:material-icons-extended'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// ADB-over-Wi-Fi pairing and authenticated shell client. Users still approve pairing from
// Android's Wireless debugging screen; DroidDeck only runs its one fixed setting command.
implementation 'com.flyfishxu:kadb-android:1.2.1'
// Android terminal view/emulator used by the in-session secondary-display Linux shell.
implementation 'com.termux.termux-app:terminal-view:0.118.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
}
+10 -1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
@@ -47,7 +48,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorLandscape">
android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -124,6 +126,13 @@
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
File diff suppressed because it is too large. Load diff
@@ -13,11 +13,13 @@ add_library(bannerwayland SHARED
src/ahb_swapchain.c
src/waylandcomp_jni.c
src/banner_ext.c
src/wl_dmabuf.c
src/wl_clipboard.c
src/wl_text_input.c
src/wl_toplevel_icon.c
src/wl_color_mgmt.c
src/hdr_compose.c
src/blend_pass.c
generated/color-management-v1-protocol.c
generated/xdg-shell-protocol.c
generated/linux-dmabuf-v1-protocol.c
@@ -33,6 +35,9 @@ add_library(bannerwayland SHARED
src/framegen_bridge.c
src/framegen_jni.c
)
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
target_compile_options(bannerwayland PRIVATE
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
target_include_directories(bannerwayland PRIVATE
generated
prebuilt/include
@@ -0,0 +1,9 @@
#version 450
// Wayland buffers carry premultiplied alpha; the pipeline blends ONE, ONE_MINUS_SRC_ALPHA.
layout(binding = 0) uniform sampler2D Surface;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
void main() {
outColor = texture(Surface, uv);
}
@@ -0,0 +1,10 @@
#version 450
// One surface as a quad: dst = the target rectangle in NDC, src = the buffer rectangle in UV.
layout(push_constant) uniform PC { vec4 dst; vec4 src; } pc;
layout(location = 0) out vec2 uv;
void main() {
vec2 t = vec2(gl_VertexIndex & 1, gl_VertexIndex >> 1);
gl_Position = vec4(mix(pc.dst.xy, pc.dst.zw, t), 0.0, 1.0);
uv = mix(pc.src.xy, pc.src.zw, t);
}
@@ -0,0 +1,22 @@
// 1115.1.0
#pragma once
const uint32_t blend_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x00000014,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x00000011,0x00030010,
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
0x00000000,0x00050005,0x00000009,0x4374756f,0x726f6c6f,0x00000000,0x00040005,0x0000000d,
0x66727553,0x00656361,0x00030005,0x00000011,0x00007675,0x00040047,0x00000009,0x0000001e,
0x00000000,0x00040047,0x0000000d,0x00000021,0x00000000,0x00040047,0x0000000d,0x00000022,
0x00000000,0x00040047,0x00000011,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,
0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,
0x00000003,0x00090019,0x0000000a,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
0x00000001,0x00000000,0x0003001b,0x0000000b,0x0000000a,0x00040020,0x0000000c,0x00000000,
0x0000000b,0x0004003b,0x0000000c,0x0000000d,0x00000000,0x00040017,0x0000000f,0x00000006,
0x00000002,0x00040020,0x00000010,0x00000001,0x0000000f,0x0004003b,0x00000010,0x00000011,
0x00000001,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
0x0004003d,0x0000000b,0x0000000e,0x0000000d,0x0004003d,0x0000000f,0x00000012,0x00000011,
0x00050057,0x00000007,0x00000013,0x0000000e,0x00000012,0x0003003e,0x00000009,0x00000013,
0x000100fd,0x00010038
};
@@ -0,0 +1,315 @@
/* Alpha composition: one surface drawn source-over onto the scene - see blend_pass.h. Compositor thread. */
#define _POSIX_C_SOURCE 200809L
#include "blend_pass.h"
#include "vk_loader.h"
#include "vk_present.h" /* banner_log, vkp_gpu_name */
#include <string.h>
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
* and committed next to the GLSL: the NDK build compiles no shaders. */
#include "blend_vert.h"
#include "blend_frag.h"
struct pc_blend { float dst[4]; float src[4]; };
static VkDevice g_dev;
static int g_ready; /* 0 = not built, 1 = ok, -1 = unavailable (said once) */
static VkSampler g_sampler;
static VkDescriptorSetLayout g_dsl;
static VkPipelineLayout g_pl;
static VkDescriptorPool g_dpool;
static VkShaderModule g_vert, g_frag;
static unsigned g_frame = 1;
/* One render pass + pipeline per target format: the screen swapchain's, the scene image's, the HDR
* composition's. */
#define NFMT 4
static struct { VkFormat fmt; VkRenderPass rp; VkPipeline pipe; } g_out[NFMT];
#define NSRC 32
static struct { VkImage img; VkImageView view; VkDescriptorSet ds; unsigned frame; } g_src[NSRC];
#define NDST 12
static struct { VkImage img; VkFormat fmt; int w, h; VkImageView view; VkFramebuffer fb; unsigned frame; } g_dst[NDST];
void blendp_bind_device(VkDevice dev) { g_dev = dev; }
void blendp_begin_frame(void) { g_frame++; }
static void src_release(int i) {
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
memset(&g_src[i], 0, sizeof(g_src[i]));
}
static void dst_release(int i) {
if (g_dst[i].fb) g_vk.DestroyFramebuffer(g_dev, g_dst[i].fb, NULL);
if (g_dst[i].view) g_vk.DestroyImageView(g_dev, g_dst[i].view, NULL);
memset(&g_dst[i], 0, sizeof(g_dst[i]));
}
void blendp_forget_image(VkImage img) {
if (!g_dev || !img) return;
for (int i = 0; i < NSRC; i++) if (g_src[i].img == img) src_release(i);
for (int i = 0; i < NDST; i++) if (g_dst[i].img == img) dst_release(i);
}
static VkShaderModule shader(const uint32_t *code, size_t size) {
VkShaderModule m = VK_NULL_HANDLE;
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
return m;
}
/* The target keeps what the blits put there (LOAD) and stays in TRANSFER_DST_OPTIMAL for the blits
* that follow. The dependencies order the pass between them. */
static VkRenderPass make_rp(VkFormat fmt) {
VkAttachmentDescription att = {.format = fmt, .samples = VK_SAMPLE_COUNT_1_BIT,
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL};
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
.pColorAttachments = &ref};
VkSubpassDependency deps[2] = {
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT},
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT |
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT}};
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
.dependencyCount = 2, .pDependencies = deps};
VkRenderPass rp = VK_NULL_HANDLE;
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &rp) != VK_SUCCESS) return VK_NULL_HANDLE;
return rp;
}
static VkPipeline make_pipe(VkRenderPass rp) {
VkPipelineShaderStageCreateInfo st[2] = {
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT,
.module = g_vert, .pName = "main"},
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
.module = g_frag, .pName = "main"}};
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
.dynamicStateCount = 2, .pDynamicStates = dyn};
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
.viewportCount = 1, .scissorCount = 1};
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
/* The target's alpha is left alone: the screen is opaque whatever is drawn onto it. */
VkPipelineColorBlendAttachmentState ba = {
.blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD,
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
.alphaBlendOp = VK_BLEND_OP_ADD,
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT};
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
.attachmentCount = 1, .pAttachments = &ba};
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
.renderPass = rp, .subpass = 0};
VkPipeline p = VK_NULL_HANDLE;
if (g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p) != VK_SUCCESS) return VK_NULL_HANDLE;
return p;
}
static void objects_destroy(void) {
if (g_vert) g_vk.DestroyShaderModule(g_dev, g_vert, NULL);
if (g_frag) g_vk.DestroyShaderModule(g_dev, g_frag, NULL);
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
g_vert = g_frag = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE;
}
static int objects_ensure(void) {
if (g_ready) return g_ready == 1 ? 0 : -1;
g_ready = -1;
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.bindingCount = 1, .pBindings = &b};
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .offset = 0, .size = sizeof(struct pc_blend)};
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, NSRC};
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, .maxSets = NSRC,
.poolSizeCount = 1, .pPoolSizes = &ps};
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
g_ready = 1;
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
vkp_gpu_name());
return 0;
fail:
objects_destroy();
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
"are drawn opaque this session");
return -1;
}
static int out_slot(VkFormat fmt) {
int i;
for (i = 0; i < NFMT && g_out[i].pipe; i++) if (g_out[i].fmt == fmt) return i;
if (i == NFMT) return -1;
VkRenderPass rp = make_rp(fmt);
VkPipeline pipe = rp ? make_pipe(rp) : VK_NULL_HANDLE;
if (!pipe) {
if (rp) g_vk.DestroyRenderPass(g_dev, rp, NULL);
return -1;
}
g_out[i].fmt = fmt; g_out[i].rp = rp; g_out[i].pipe = pipe;
return i;
}
static VkImageView make_view(VkImage img, VkFormat fmt) {
VkImageView v = VK_NULL_HANDLE;
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = fmt,
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
return v;
}
/* The slot to recycle: a free one, else the one unused for longest - never one this command buffer
* already refers to. -1 = every slot is in this frame. */
#define OLDEST(arr, n, out) do { \
unsigned age_ = 0; \
(out) = -1; \
for (int k_ = 0; k_ < (n); k_++) { \
if (!(arr)[k_].img) { (out) = k_; break; } \
if ((arr)[k_].frame != g_frame && g_frame - (arr)[k_].frame > age_) { age_ = g_frame - (arr)[k_].frame; (out) = k_; } \
} \
} while (0)
static VkDescriptorSet src_ensure(VkImage img, VkFormat fmt, VkImageLayout layout) {
int i;
for (i = 0; i < NSRC; i++)
if (g_src[i].img == img) { g_src[i].frame = g_frame; return g_src[i].ds; }
OLDEST(g_src, NSRC, i);
if (i < 0) return VK_NULL_HANDLE;
src_release(i);
if (!(g_src[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &g_src[i].ds) != VK_SUCCESS) {
g_src[i].ds = VK_NULL_HANDLE;
src_release(i);
return VK_NULL_HANDLE;
}
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = g_src[i].view, .imageLayout = layout};
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = g_src[i].ds, .dstBinding = 0,
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.pImageInfo = &ii};
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
g_src[i].img = img; g_src[i].frame = g_frame;
return g_src[i].ds;
}
static VkFramebuffer dst_ensure(VkImage img, VkFormat fmt, int w, int h, VkRenderPass rp) {
int i;
for (i = 0; i < NDST; i++) {
if (g_dst[i].img != img) continue;
if (g_dst[i].fmt == fmt && g_dst[i].w == w && g_dst[i].h == h) { g_dst[i].frame = g_frame; return g_dst[i].fb; }
if (g_dst[i].frame == g_frame) return VK_NULL_HANDLE;
break;
}
if (i == NDST) OLDEST(g_dst, NDST, i);
if (i < 0) return VK_NULL_HANDLE;
dst_release(i);
if (!(g_dst[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = rp,
.attachmentCount = 1, .pAttachments = &g_dst[i].view, .width = (uint32_t)w,
.height = (uint32_t)h, .layers = 1};
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_dst[i].fb) != VK_SUCCESS) {
g_dst[i].fb = VK_NULL_HANDLE;
dst_release(i);
return VK_NULL_HANDLE;
}
g_dst[i].img = img; g_dst[i].fmt = fmt; g_dst[i].w = w; g_dst[i].h = h; g_dst[i].frame = g_frame;
return g_dst[i].fb;
}
static void src_layout(VkCommandBuffer cmd, VkImage img, VkImageLayout from, VkImageLayout to) {
int sampling = to == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkImageMemoryBarrier b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = from, .newLayout = to,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = img, .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1},
.srcAccessMask = sampling ? VK_ACCESS_TRANSFER_READ_BIT : VK_ACCESS_SHADER_READ_BIT,
.dstAccessMask = sampling ? VK_ACCESS_SHADER_READ_BIT : VK_ACCESS_TRANSFER_READ_BIT};
g_vk.CmdPipelineBarrier(cmd, sampling ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
sampling ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, NULL, 0, NULL, 1, &b);
}
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit) {
if (!src || !dst || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0 || objects_ensure() != 0) return -1;
int slot = out_slot(dst_fmt);
if (slot < 0) return -1;
VkImageLayout sampled = src_general ? VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorSet ds = src_ensure(src, src_fmt, sampled);
if (!ds) return -1;
VkFramebuffer fb = dst_ensure(dst, dst_fmt, dst_w, dst_h, g_out[slot].rp);
if (!fb) return -1;
const VkOffset3D *d = blit->dstOffsets, *s = blit->srcOffsets;
int x0 = d[0].x < d[1].x ? d[0].x : d[1].x, x1 = d[0].x < d[1].x ? d[1].x : d[0].x;
int y0 = d[0].y < d[1].y ? d[0].y : d[1].y, y1 = d[0].y < d[1].y ? d[1].y : d[0].y;
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > dst_w) x1 = dst_w;
if (y1 > dst_h) y1 = dst_h;
if (x1 <= x0 || y1 <= y0) return 0;
struct pc_blend pc = {
.dst = {2.0f * d[0].x / dst_w - 1.0f, 2.0f * d[0].y / dst_h - 1.0f,
2.0f * d[1].x / dst_w - 1.0f, 2.0f * d[1].y / dst_h - 1.0f},
.src = {(float)s[0].x / src_w, (float)s[0].y / src_h, (float)s[1].x / src_w, (float)s[1].y / src_h}};
if (!src_general) src_layout(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sampled);
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_out[slot].rp,
.framebuffer = fb,
.renderArea = {{x0, y0}, {(uint32_t)(x1 - x0), (uint32_t)(y1 - y0)}}};
VkViewport vp = {0, 0, (float)dst_w, (float)dst_h, 0, 1};
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
g_vk.CmdSetScissor(cmd, 0, 1, &rpi.renderArea);
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_out[slot].pipe);
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
g_vk.CmdEndRenderPass(cmd);
if (!src_general) src_layout(cmd, src, sampled, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
return 0;
}
@@ -0,0 +1,28 @@
#ifndef BLEND_PASS_H
#define BLEND_PASS_H
/*
* Alpha composition. The scene is composed with blits, which replace what is under them; a surface
* with translucent pixels (gamescope's overlay and notification planes over a game, a toolkit's
* shadowed popup) is drawn through this pass instead, source-over with premultiplied alpha.
*
* Compositor thread. Objects are built on the first use; if the driver refuses them the pass is
* unavailable for the session (logged once) and the caller keeps blitting.
*/
#define VK_USE_PLATFORM_ANDROID_KHR
#include <vulkan/vulkan.h>
/* Called from dev_init (device up). */
void blendp_bind_device(VkDevice dev);
/* A source or target image is about to be destroyed: drop what is cached for it. Frames are fenced,
* so nothing submitted still uses it. */
void blendp_forget_image(VkImage img);
/* A new command buffer is being recorded: what the previous one used may be recycled again. */
void blendp_begin_frame(void);
/* Record one surface over `dst`. `dst` is in TRANSFER_DST_OPTIMAL before and after. `src` was created
* with SAMPLED usage and is in GENERAL (src_general) or TRANSFER_SRC_OPTIMAL, and is left there.
* `blit` is the draw as the blit path maps it. 0 = recorded, -1 = unavailable (nothing recorded). */
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit);
#endif
@@ -0,0 +1,55 @@
// 1115.1.0
#pragma once
const uint32_t blend_vert_code[] = {
0x07230203,0x00010000,0x0008000b,0x0000003a,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0008000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x0000001b,0x00000031,
0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,0x00000000,0x00030005,
0x00000009,0x00000074,0x00060005,0x0000000c,0x565f6c67,0x65747265,0x646e4978,0x00007865,
0x00060005,0x00000019,0x505f6c67,0x65567265,0x78657472,0x00000000,0x00060006,0x00000019,
0x00000000,0x505f6c67,0x7469736f,0x006e6f69,0x00070006,0x00000019,0x00000001,0x505f6c67,
0x746e696f,0x657a6953,0x00000000,0x00070006,0x00000019,0x00000002,0x435f6c67,0x4470696c,
0x61747369,0x0065636e,0x00070006,0x00000019,0x00000003,0x435f6c67,0x446c6c75,0x61747369,
0x0065636e,0x00030005,0x0000001b,0x00000000,0x00030005,0x0000001d,0x00004350,0x00040006,
0x0000001d,0x00000000,0x00747364,0x00040006,0x0000001d,0x00000001,0x00637273,0x00030005,
0x0000001f,0x00006370,0x00030005,0x00000031,0x00007675,0x00040047,0x0000000c,0x0000000b,
0x0000002a,0x00030047,0x00000019,0x00000002,0x00050048,0x00000019,0x00000000,0x0000000b,
0x00000000,0x00050048,0x00000019,0x00000001,0x0000000b,0x00000001,0x00050048,0x00000019,
0x00000002,0x0000000b,0x00000003,0x00050048,0x00000019,0x00000003,0x0000000b,0x00000004,
0x00030047,0x0000001d,0x00000002,0x00050048,0x0000001d,0x00000000,0x00000023,0x00000000,
0x00050048,0x0000001d,0x00000001,0x00000023,0x00000010,0x00040047,0x00000031,0x0000001e,
0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,
0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,
0x00000007,0x00040015,0x0000000a,0x00000020,0x00000001,0x00040020,0x0000000b,0x00000001,
0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x0004002b,0x0000000a,0x0000000e,
0x00000001,0x00040017,0x00000015,0x00000006,0x00000004,0x00040015,0x00000016,0x00000020,
0x00000000,0x0004002b,0x00000016,0x00000017,0x00000001,0x0004001c,0x00000018,0x00000006,
0x00000017,0x0006001e,0x00000019,0x00000015,0x00000006,0x00000018,0x00000018,0x00040020,
0x0000001a,0x00000003,0x00000019,0x0004003b,0x0000001a,0x0000001b,0x00000003,0x0004002b,
0x0000000a,0x0000001c,0x00000000,0x0004001e,0x0000001d,0x00000015,0x00000015,0x00040020,
0x0000001e,0x00000009,0x0000001d,0x0004003b,0x0000001e,0x0000001f,0x00000009,0x00040020,
0x00000020,0x00000009,0x00000015,0x0004002b,0x00000006,0x00000029,0x00000000,0x0004002b,
0x00000006,0x0000002a,0x3f800000,0x00040020,0x0000002e,0x00000003,0x00000015,0x00040020,
0x00000030,0x00000003,0x00000007,0x0004003b,0x00000030,0x00000031,0x00000003,0x00050036,
0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000008,
0x00000009,0x00000007,0x0004003d,0x0000000a,0x0000000d,0x0000000c,0x000500c7,0x0000000a,
0x0000000f,0x0000000d,0x0000000e,0x0004006f,0x00000006,0x00000010,0x0000000f,0x0004003d,
0x0000000a,0x00000011,0x0000000c,0x000500c3,0x0000000a,0x00000012,0x00000011,0x0000000e,
0x0004006f,0x00000006,0x00000013,0x00000012,0x00050050,0x00000007,0x00000014,0x00000010,
0x00000013,0x0003003e,0x00000009,0x00000014,0x00050041,0x00000020,0x00000021,0x0000001f,
0x0000001c,0x0004003d,0x00000015,0x00000022,0x00000021,0x0007004f,0x00000007,0x00000023,
0x00000022,0x00000022,0x00000000,0x00000001,0x00050041,0x00000020,0x00000024,0x0000001f,
0x0000001c,0x0004003d,0x00000015,0x00000025,0x00000024,0x0007004f,0x00000007,0x00000026,
0x00000025,0x00000025,0x00000002,0x00000003,0x0004003d,0x00000007,0x00000027,0x00000009,
0x0008000c,0x00000007,0x00000028,0x00000001,0x0000002e,0x00000023,0x00000026,0x00000027,
0x00050051,0x00000006,0x0000002b,0x00000028,0x00000000,0x00050051,0x00000006,0x0000002c,
0x00000028,0x00000001,0x00070050,0x00000015,0x0000002d,0x0000002b,0x0000002c,0x00000029,
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000001b,0x0000001c,0x0003003e,0x0000002f,
0x0000002d,0x00050041,0x00000020,0x00000032,0x0000001f,0x0000000e,0x0004003d,0x00000015,
0x00000033,0x00000032,0x0007004f,0x00000007,0x00000034,0x00000033,0x00000033,0x00000000,
0x00000001,0x00050041,0x00000020,0x00000035,0x0000001f,0x0000000e,0x0004003d,0x00000015,
0x00000036,0x00000035,0x0007004f,0x00000007,0x00000037,0x00000036,0x00000036,0x00000002,
0x00000003,0x0004003d,0x00000007,0x00000038,0x00000009,0x0008000c,0x00000007,0x00000039,
0x00000001,0x0000002e,0x00000034,0x00000037,0x00000038,0x0003003e,0x00000031,0x00000039,
0x000100fd,0x00010038
};
+213 -397
View File
@@ -30,6 +30,7 @@
#include <time.h>
#include <stdarg.h>
#include <errno.h>
#include <poll.h>
#include <sys/timerfd.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
@@ -51,6 +52,7 @@
#include "effects_chain.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "compositor_internal.h"
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
@@ -161,21 +163,9 @@ static int log_budget(void) {
static struct wl_display *g_display;
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
struct client_info {
struct wl_client *client;
struct wl_listener destroy;
pid_t pid;
char name[64];
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
static struct client_info *g_clients;
static struct client_info *client_info_of(struct wl_client *client) {
struct client_info *client_info_of(struct wl_client *client) {
for (struct client_info *ci = g_clients; ci; ci = ci->next)
if (ci->client == client) return ci;
return NULL;
@@ -248,6 +238,8 @@ struct surface {
struct dmabuf_buffer *dmabuf_buf; /* its imported image, kept until replaced or the surface goes */
struct wl_listener dmabuf_destroy;
int buf_w, buf_h, has_content;
int buf_alpha;
int opaque[4], pending_opaque[4], pending_opaque_set; /* x, y, w, h; w = 0: none */
int src_set, dst_set;
float src[4];
int dst[2];
@@ -258,6 +250,10 @@ struct surface {
int releases_pending; /* FPS limiter: buffers of this surface still to be released */
enum surface_role role;
/* A role-less surface's last buffer, held unreleased: it may become the pointer's image
* (wl_pointer.set_cursor after the commit, which is wlroots' order). */
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -439,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
* delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier
* limit had spaced out, and the game's first frames after that waited on empty slots. */
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
int limit = g_fps_limit;
if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t interval = 1000000000LL / limit, now = now_ns();
const int limit = g_fps_limit;
/* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for
* frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced
* buffer straight back let it draw as fast as the GPU allowed and everything above the refresh
* rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05).
* One release per refresh is the back-pressure X11 gives, and the screen shows the same frames
* either way. */
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t now = now_ns();
int64_t at = s->next_release_ns + interval;
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
if (at < now) at = now;
@@ -472,6 +475,12 @@ static struct seat_pointer g_ptrs[MAX_PTRS];
static int g_nptrs;
static struct seat_keyboard g_kbs[MAX_PTRS];
static int g_nkbs;
/* Modifier state in the keymap's real-modifier bits (Shift=0x1, Lock=0x2, Control=0x4, Mod1=0x8,
* Mod4=0x40). A client of the wayland backend - gamescope, labwc - takes modifiers only from
* wl_keyboard.modifiers and ignores what a Shift key event does to its xkb state, so without this
* every capital arrived lowercase. */
static uint32_t g_mods_depressed, g_mods_locked;
static uint32_t g_mod_keys_held; /* one bit per modifier key below, so a repeat press is harmless */
static struct seat_touch g_touches[MAX_PTRS];
static int g_ntouches;
@@ -486,6 +495,7 @@ static int g_ntouches;
#define CURSOR_MAX_PX (256 * 256)
static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER;
static struct surface *g_cursor_surface; /* compositor thread only */
static struct surface *g_cursor_shown; /* compositor thread: whose image g_cursor_px holds */
static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */
static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */
static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */
@@ -493,6 +503,14 @@ static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */
static int g_cursor_hidden = 1; /* the client asked for no pointer */
static int g_cursor_serial; /* bumped on every change; the app polls it */
/* The last image again, after a hide: the same surface set as the pointer once more with nothing
* newly committed to it (wlroots re-sets its cursor surface each time the pointer enters). */
static void cursor_publish_shown(void) {
pthread_mutex_lock(&g_cursor_lock);
if (g_cursor_w > 0) { g_cursor_hidden = 0; g_cursor_serial++; } /* serial: the hotspot may be new */
pthread_mutex_unlock(&g_cursor_lock);
}
static void cursor_publish_hidden(void) {
pthread_mutex_lock(&g_cursor_lock);
if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; }
@@ -500,7 +518,7 @@ static void cursor_publish_hidden(void) {
}
static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) {
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
pthread_mutex_lock(&g_cursor_lock);
for (int y = 0; y < h; y++)
memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4);
@@ -513,10 +531,13 @@ static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t strid
static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
int32_t stride = wl_shm_buffer_get_stride(shm);
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
/* libwayland only promises stride >= width; each row copied here is width * 4 bytes, so a
* shorter stride would read past the end of the client's pool. */
if ((int64_t)stride < (int64_t)w * 4) return;
wl_shm_buffer_begin_access(shm);
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h,
(size_t)wl_shm_buffer_get_stride(shm), hx, hy);
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, (size_t)stride, hx, hy);
wl_shm_buffer_end_access(shm);
}
@@ -557,40 +578,7 @@ static void constraints_focus_entered(struct wl_resource *target, struct wl_clie
/* ------------------------------------------------------------------ dmabuf buffers */
#define FOURCC(a, b, c, d) \
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
#define MOD_LINEAR VKP_MOD_LINEAR
#define MOD_INVALID VKP_MOD_INVALID
#define MAX_PLANES 4
struct dmabuf_params {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
uint64_t modifier[MAX_PLANES];
int n_planes;
};
struct dmabuf_buffer {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
int n_planes;
int32_t width, height;
uint32_t format;
uint64_t modifier;
struct vkp_image *img; /* imported once, reused for every frame */
int import_failed;
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
* until the surface commits something newer, so the picture never blinks to black. */
int refs;
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
};
static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
if (!b || --b->refs > 0) return;
vkp_image_destroy(b->img);
for (int i = 0; i < b->n_planes; i++)
@@ -601,7 +589,7 @@ static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
static void dbuf_buffer_destroy_req(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct wl_buffer_interface dbuf_buffer_impl = {
const struct wl_buffer_interface dbuf_buffer_impl = {
.destroy = dbuf_buffer_destroy_req,
};
@@ -743,6 +731,7 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
wl_buffer_send_release(buffer);
s->buf_w = w;
s->buf_h = h;
s->buf_alpha = wl_shm_buffer_get_format(shm) == WL_SHM_FORMAT_ARGB8888;
s->has_content = s->shm_img != NULL;
g_stat_shm++;
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
@@ -794,6 +783,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
}
s->buf_w = b->width;
s->buf_h = b->height;
s->buf_alpha = (b->format & 0xff) == 'A';
s->has_content = b->img != NULL;
g_stat_dmabuf++;
const int announced_now = !s->announced_vulkan;
@@ -965,13 +955,94 @@ static void surface_frame(struct wl_client *c, struct wl_resource *r, uint32_t c
wl_resource_set_implementation(callback, NULL, NULL, frame_callback_destroy);
wl_list_insert(s->pending_frames.prev, wl_resource_get_link(callback));
}
/* The bounding box serves pointer confinement. The exact flag also tells opaque-region handling
* whether the bounding box itself is the whole region. */
struct region { int set, exact; int x, y, w, h; };
/* A wl_surface's opaque region is pending until its next commit. An exact region that covers
* the surface lets the blit path keep handling an otherwise alpha-capable buffer. */
static void surface_set_opaque(struct wl_client *c, struct wl_resource *r,
struct wl_resource *region) {}
struct wl_resource *region) {
struct surface *s = wl_resource_get_user_data(r);
struct region *rg = region ? wl_resource_get_user_data(region) : NULL;
memset(s->pending_opaque, 0, sizeof(s->pending_opaque));
s->pending_opaque_set = 1;
if (rg && rg->set && rg->exact) {
s->pending_opaque[0] = rg->x;
s->pending_opaque[1] = rg->y;
s->pending_opaque[2] = rg->w;
s->pending_opaque[3] = rg->h;
}
struct client_info *ci = client_info_of(c);
if (ci) ci->declares_opaque = 1;
}
static void surface_set_input(struct wl_client *c, struct wl_resource *r,
struct wl_resource *region) {}
static void send_toplevel_configure(struct surface *s);
/* The pointer image from a cursor surface's buffer: wl_shm is copied, a dma-buf (labwc on a GPU
* renderer) is read back once the client's render into it is done - its implicit fence, as the
* zero-copy path waits for it (ahb_swapchain_present). */
static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) {
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
if (!db->img) db->import_failed = 1;
}
if (!db->img) return;
struct pollfd p = {.fd = db->fd[0], .events = POLLIN};
int r;
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
if (vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) != 0) return;
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
} else {
return;
}
g_cursor_shown = s;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
}
/* Only what could be a pointer image is held; anything larger goes straight back. */
static void surface_hold_idle(struct surface *s, struct wl_resource *buffer) {
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
int64_t px = db ? (int64_t)db->width * db->height
: shm ? (int64_t)wl_shm_buffer_get_width(shm) * wl_shm_buffer_get_height(shm) : 0;
if (px <= 0 || px > CURSOR_MAX_PX) { wl_buffer_send_release(buffer); return; }
s->idle_buffer = buffer;
s->idle_buffer_destroy.notify = on_idle_buffer_destroyed;
wl_resource_add_destroy_listener(buffer, &s->idle_buffer_destroy);
}
/* Lets go of the held buffer; release = give it back to the client (not while it is going away). */
static struct wl_resource *surface_take_idle(struct surface *s) {
struct wl_resource *buffer = s->idle_buffer;
if (!buffer) return NULL;
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
return buffer;
}
static void surface_drop_idle(struct surface *s) {
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
static void surface_commit(struct wl_client *c, struct wl_resource *r) {
struct surface *s = wl_resource_get_user_data(r);
struct surface *child;
@@ -995,6 +1066,10 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
memcpy(s->dst, s->pending_dst, sizeof(s->dst));
s->pending_dst_set = 0;
}
if (s->pending_opaque_set) {
memcpy(s->opaque, s->pending_opaque, sizeof(s->opaque));
s->pending_opaque_set = 0;
}
if (s->pending_attach) {
struct wl_resource *buffer = s->pending_buffer;
@@ -1011,27 +1086,19 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
s->pending_buffer = NULL;
s->pending_attach = 0;
surface_drop_idle(s); /* replaced, whatever the surface is now */
if (s->role == ROLE_CURSOR) {
/* The client's pointer image, copied out for the app's overlay (see cursor_publish_*)
* rather than composited, so it survives the zero-copy and HDR layer paths. */
if (!buffer) {
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
} else if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
if (!db->img) db->import_failed = 1;
}
if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0)
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
} else {
cursor_publish_buffer(s, buffer);
wl_buffer_send_release(buffer);
}
if (buffer) wl_buffer_send_release(buffer);
} else if (s->role == ROLE_NONE) {
/* Role-less surface: never drawn. */
if (buffer) wl_buffer_send_release(buffer);
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
if (buffer) surface_hold_idle(s, buffer);
} else if (db) {
take_dmabuf(s, db, buffer);
} else if (shm) {
@@ -1103,6 +1170,8 @@ static void surface_resource_destroy(struct wl_resource *r) {
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL;
touch_cancel_surface(s);
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -1139,24 +1208,29 @@ static void surface_resource_destroy(struct wl_resource *r) {
/* ------------------------------------------------------------------ wl_region */
/* A region is kept as the bounding box of its rectangles: enough for pointer confinement,
* where winewayland sends one rectangle (the ClipCursor area). Subtractions are ignored. */
struct region { int set; int x, y, w, h; };
static void region_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void region_add(struct wl_client *c, struct wl_resource *r,
int32_t x, int32_t y, int32_t w, int32_t h) {
struct region *rg = wl_resource_get_user_data(r);
if (!rg || w <= 0 || h <= 0) return;
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = 1; return; }
int x2 = rg->x + rg->w > x + w ? rg->x + rg->w : x + w;
int y2 = rg->y + rg->h > y + h ? rg->y + rg->h : y + h;
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = rg->exact = 1; return; }
long long x2 = (long long)rg->x + rg->w, y2 = (long long)rg->y + rg->h;
long long nx2 = (long long)x + w, ny2 = (long long)y + h;
int inside = x >= rg->x && y >= rg->y && nx2 <= x2 && ny2 <= y2;
int around = x <= rg->x && y <= rg->y && nx2 >= x2 && ny2 >= y2;
if (!inside && !around) rg->exact = 0;
if (nx2 > x2) x2 = nx2;
if (ny2 > y2) y2 = ny2;
if (x < rg->x) rg->x = x;
if (y < rg->y) rg->y = y;
rg->w = x2 - rg->x; rg->h = y2 - rg->y;
rg->w = (int)(x2 - rg->x > INT32_MAX ? INT32_MAX : x2 - rg->x);
rg->h = (int)(y2 - rg->y > INT32_MAX ? INT32_MAX : y2 - rg->y);
}
static void region_subtract(struct wl_client *c, struct wl_resource *r,
int32_t x, int32_t y, int32_t w, int32_t h) {}
int32_t x, int32_t y, int32_t w, int32_t h) {
struct region *rg = wl_resource_get_user_data(r);
if (rg && w > 0 && h > 0) rg->exact = 0;
}
static void region_resource_destroy(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
static const struct wl_region_interface region_impl = {
.destroy = region_destroy,
@@ -1179,6 +1253,7 @@ static void compositor_create_surface(struct wl_client *c, struct wl_resource *r
wl_list_init(&s->child_link);
wl_list_init(&s->toplevel_link);
wl_list_init(&s->pending_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
wl_list_insert(&g_surfaces, &s->link);
wl_resource_set_implementation(s->resource, &surface_impl, s, surface_resource_destroy);
}
@@ -1570,319 +1645,6 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
wl_resource_set_implementation(r, &desktop_impl, NULL, NULL);
}
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
dmabuf_buffer_unref(wl_resource_get_user_data(r));
}
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (plane >= MAX_PLANES) { close(fd); return; }
if (p->fd[plane] >= 0) close(p->fd[plane]);
p->fd[plane] = fd;
p->offset[plane] = offset;
p->stride[plane] = stride;
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
}
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
if (!b) return NULL;
b->n_planes = p->n_planes;
b->refs = 1; /* the wl_buffer resource's */
b->width = w; b->height = h; b->format = format;
b->modifier = p->modifier[0];
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
for (int i = 0; i < p->n_planes; i++) {
b->fd[i] = p->fd[i];
b->offset[i] = p->offset[i];
b->stride[i] = p->stride[i];
p->fd[i] = -1; /* ownership moves to the buffer */
}
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!buf) {
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
free(b);
wl_client_post_no_memory(c);
return NULL;
}
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
struct client_info *ci = client_info_of(c);
if (ci) ci->dmabuf_buffers++;
return buf;
}
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
uint32_t format, uint32_t flags) {
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
else zwp_linux_buffer_params_v1_send_failed(r);
}
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
params_do_create(c, r, buffer_id, w, h, format, flags);
}
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
.destroy = params_destroy,
.add = params_add,
.create = params_create,
.create_immed = params_create_immed,
};
static void params_resource_destroy(struct wl_resource *r) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (!p) return;
for (int i = 0; i < MAX_PLANES; i++)
if (p->fd[i] >= 0) close(p->fd[i]);
free(p);
}
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct dmabuf_params *p = calloc(1, sizeof(*p));
if (!p) { wl_client_post_no_memory(c); return; }
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
wl_resource_get_version(r), id);
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(pr, &params_impl, p, params_resource_destroy);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface);
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
.destroy = dmabuf_destroy,
.create_params = dmabuf_create_params,
.get_default_feedback = dmabuf_get_default_feedback,
.get_surface_feedback = dmabuf_get_surface_feedback,
};
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
* takes the first format it sees - gets exactly the table it always had. */
#define DMABUF_NFMT_MAX 6
#define DMABUF_NFMT_SDR 4
#define DMABUF_NMOD 4
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
static int g_dmabuf_fmts_ready;
static void dmabuf_build_feedback(void);
static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
* we advertised before this table existed. */
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
if (got[i] == MOD_LINEAR) continue;
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
g_dmabuf_fmts[f].mods[k++] = got[i];
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
}
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
g_dmabuf_fmts[f].n = k;
uint32_t fmt = g_dmabuf_fmts[f].fmt;
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
}
dmabuf_build_feedback();
}
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
*
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
*
* What a client needs from us is one tranche describing "everything the compositor can import":
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
#ifndef F_ADD_SEALS
#define F_ADD_SEALS 1033
#define F_SEAL_SEAL 0x0001
#define F_SEAL_SHRINK 0x0002
#define F_SEAL_GROW 0x0004
#define F_SEAL_WRITE 0x0008
#endif
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
static size_t g_fmt_table_size;
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
static dev_t g_main_device; /* the render node clients should allocate on */
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
static dev_t dmabuf_render_node(void) {
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
struct stat st;
if (g_no_render_node) return 0;
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
return 0;
}
static void dmabuf_build_feedback(void) {
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
int n = 0;
g_main_device = dmabuf_render_node();
for (int f = 0; f < g_dmabuf_nfmt; f++)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
entries[n].format = g_dmabuf_fmts[f].fmt;
entries[n].pad = 0;
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
n++;
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
if (write(fd, entries, size) != (ssize_t)size) {
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
close(fd);
return;
}
/* The client mmaps this read-only and trusts it not to change under it. */
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
/* One tranche: our device, every pair in the table, no scanout flag. */
static void dmabuf_feedback_send(struct wl_resource *fb) {
struct wl_array dev, idx;
uint16_t *ind;
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
wl_array_init(&dev);
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
wl_array_release(&dev);
wl_array_init(&idx);
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
wl_array_release(&idx);
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
zwp_linux_dmabuf_feedback_v1_send_done(fb);
}
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
.destroy = dmabuf_feedback_destroy,
};
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
wl_resource_get_version(parent), id);
if (!fb) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
dmabuf_feedback_send(fb);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct client_info *ci = client_info_of(c);
if (ci) ci->asked_feedback = 1;
dmabuf_new_feedback(c, r, id);
}
/* Per-surface feedback would let us hint a different tranche for a window on its own display
* layer; we have nothing better to say per surface, so it is the default one. */
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface) {
dmabuf_new_feedback(c, r, id);
}
static void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
/* From version 4 the format and modifier events are gone: the client asks for feedback
* instead, and sending both would only confuse it about which list is authoritative. */
if (ver >= 4) return;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
if (ver >= 3)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
}
}
/* ------------------------------------------------------------------ wl_output */
/* wl_output v3+ has a request (release); without an implementation the first client to send it
@@ -1942,6 +1704,17 @@ static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, i
g_hdr_unimported_below = dl->n;
}
/* A client that describes opaque regions can intentionally leave part of an alpha buffer see-through.
* Gamescope uses this for its full-size Steam notification and overlay planes. */
static int surface_translucent(const struct surface *s, int w, int h) {
if (!s->buf_alpha) return 0;
const struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
if (!ci || !ci->declares_opaque) return 0;
const int *o = s->opaque;
return !(o[2] > 0 && o[0] <= 0 && o[1] <= 0 &&
(long long)o[0] + o[2] >= w && (long long)o[1] + o[3] >= h);
}
static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy) {
struct vkp_image *img = surface_image(s);
float sx = 0, sy = 0, sw = (float)s->buf_w, sh = (float)s->buf_h;
@@ -1961,7 +1734,8 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
dl->d = d;
dl->cap = cap;
}
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh};
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh,
surface_translucent(s, dw, dh)};
s->drawn = 1;
}
@@ -2032,6 +1806,7 @@ static int on_frame_timer(void *data) {
static int layer_candidate(const struct draw_list *dl, int scene_w, int scene_h) {
for (int i = dl->n - 1; i >= 0 && i >= dl->n - 2; i--) {
const struct vkp_draw *d = &dl->d[i];
if (d->blend) continue; /* the translucent plane must not replace the opaque game layer */
if (!vkp_image_is_dmabuf(d->img)) continue;
if (d->dx != 0 || d->dy != 0 || d->dw != scene_w || d->dh != scene_h) continue;
if (d->sx != 0 || d->sy != 0 || (int)d->sw != vkp_image_width(d->img) ||
@@ -2370,7 +2145,7 @@ static void render_scene(void) {
* its own layer, cropped and placed by the display. */
int go[8], ov = 0;
if (over == 1 && li >= 0 && vkp_map_draw(&dl.d[li + 1], go))
ov = sc_layer_present_overlay(dl.d[li + 1].img, go) == 0 ? 1 : -1;
ov = sc_layer_present_overlay(dl.d[li + 1].img, go, dl.d[li + 1].blend) == 0 ? 1 : -1;
if (ov <= 0) sc_layer_hide_overlay();
if (ov < 0) { /* the overlay layer refused it: draw the whole scene the old way */
sc_layer_hide();
@@ -2496,6 +2271,16 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
g_cursor_surface = s;
g_cursor_hx = hx;
g_cursor_hy = hy;
/* Its content may already be committed: wlroots' Wayland backend attaches and commits the
* cursor surface first and sets it as the pointer after, and sets it again with nothing new
* committed each time the pointer re-enters. */
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) {
cursor_publish_buffer(s, buffer);
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_pointer_interface pointer_impl = {
@@ -2643,8 +2428,8 @@ static void keyboard_focus(struct wl_resource *target) {
if (sk->focus == target) continue;
sk->focus = target;
wl_keyboard_send_enter(sk->kb, wl_display_next_serial(g_display), target, &keys);
/* Baseline modifiers = none; Shift/Ctrl arrive as their own key events. */
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display), 0, 0, 0, 0);
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_array_release(&keys);
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
@@ -3341,7 +3126,34 @@ static void scroll_event(int steps) {
wl_display_flush_clients(g_display);
}
/* Folds a key into the modifier state; returns whether the state changed. */
static int update_modifiers(uint32_t evdev, int pressed) {
static const struct { uint32_t evdev, mask; } mod_keys[] = {
{ 42, 0x1 }, { 54, 0x1 }, /* Shift L/R */
{ 29, 0x4 }, { 97, 0x4 }, /* Control L/R */
{ 56, 0x8 }, { 100, 0x8 }, /* Alt L/R, both Mod1 in this keymap */
{ 125, 0x40 }, { 126, 0x40 }, /* Super L/R */
};
uint32_t depressed = g_mods_depressed, locked = g_mods_locked;
if (evdev == 58) { /* Caps Lock toggles on press */
if (pressed) locked ^= 0x2;
} else {
int i, n = (int)(sizeof(mod_keys) / sizeof(mod_keys[0]));
for (i = 0; i < n && mod_keys[i].evdev != evdev; i++) {}
if (i == n) return 0;
if (pressed) g_mod_keys_held |= 1u << i; else g_mod_keys_held &= ~(1u << i);
depressed = 0;
for (int j = 0; j < n; j++) if (g_mod_keys_held & (1u << j)) depressed |= mod_keys[j].mask;
}
if (depressed == g_mods_depressed && locked == g_mods_locked) return 0;
g_mods_depressed = depressed;
g_mods_locked = locked;
return 1;
}
static void key_event(uint32_t evdev, int pressed) {
/* Tracked even with nothing to deliver to, so a Shift released unseen is not left held. */
int mods_changed = update_modifiers(evdev, pressed);
/* Keys go to the program window the user last clicked (else the topmost non-shell window),
* never to Wine's desktop surface: winewayland hands each key to the hwnd of the surface that
* holds keyboard focus, and a key handed to explorer's desktop hwnd is queued to explorer's
@@ -3361,9 +3173,13 @@ static void key_event(uint32_t evdev, int pressed) {
struct seat_keyboard *sk;
if (!keyboard_for(client)) return;
keyboard_focus(target->resource);
for_each_keyboard_of(client, sk)
for_each_keyboard_of(client, sk) {
wl_keyboard_send_key(sk->kb, wl_display_next_serial(g_display), now_ms(), evdev,
pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED);
if (mods_changed)
wl_keyboard_send_modifiers(sk->kb, wl_display_next_serial(g_display),
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_display_flush_clients(g_display);
}
@@ -0,0 +1,79 @@
/*
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
*/
#pragma once
#include <stdint.h>
#include <sys/types.h>
#include <android/log.h>
#include <wayland-server.h>
#include "vk_present.h"
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
#ifndef WLOGE
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
#endif
/* ------------------------------------------------------------------ dmabuf buffers */
#define FOURCC(a, b, c, d) \
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
#define MOD_LINEAR VKP_MOD_LINEAR
#define MOD_INVALID VKP_MOD_INVALID
#define MAX_PLANES 4
struct dmabuf_params {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
uint64_t modifier[MAX_PLANES];
int n_planes;
};
struct dmabuf_buffer {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
int n_planes;
int32_t width, height;
uint32_t format;
uint64_t modifier;
struct vkp_image *img; /* imported once, reused for every frame */
int import_failed;
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
* until the surface commits something newer, so the picture never blinks to black. */
int refs;
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
};
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
struct client_info {
struct wl_client *client;
struct wl_listener destroy;
pid_t pid;
char name[64];
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
/* Clients that describe opaque regions can use alpha outside those regions. Wine windows
* never describe them, and their unused alpha channel must not make them translucent. */
unsigned declares_opaque : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
/* ---- compositor.c */
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
/* ---- wl_dmabuf.c */
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
COMPOSITOR_INTERNAL void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id);
+10 -9
View File
@@ -85,6 +85,7 @@ static struct {
#define ASC_VISIBILITY_HIDE 0
#define ASC_VISIBILITY_SHOW 1
#define ASC_TRANSPARENCY_TRANSLUCENT 1
#define ASC_TRANSPARENCY_OPAQUE 2
static int load_api(void) {
@@ -766,7 +767,8 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
/* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no
* description). 0 = applied. */
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color) {
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color,
int translucent) {
struct slot *s = &l->slots[idx];
ASurfaceTransaction *tx = api.txCreate();
if (!tx) return -1;
@@ -774,9 +776,8 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
if (old) api.setZOrder(tx, l->sc, l->z);
/* The blit was waited for on the CPU, so no acquire fence is needed (-1). */
api.setBuffer(tx, l->sc, s->ahb, -1);
/* The compositor composes with blits, which overwrite: nothing is ever alpha-blended on the
* copy path either, so every layer is opaque and the picture matches it pixel for pixel. */
api.setBufferTransparency(tx, l->sc, ASC_TRANSPARENCY_OPAQUE);
api.setBufferTransparency(tx, l->sc,
translucent ? ASC_TRANSPARENCY_TRANSLUCENT : ASC_TRANSPARENCY_OPAQUE);
apply_geometry(tx, l, r);
apply_frame_rate(tx, l);
apply_colour(tx, l, color);
@@ -883,7 +884,7 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
int idx = take_free_slot(l, sw, sh, AHB_RGBA8, &wait_fd);
if (idx < 0) { log_drop(l); return 0; }
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, color) != 0) return -1;
if (present_slot(l, idx, r, color, 0) != 0) return -1;
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
if (!l->first_logged) {
l->first_logged = 1;
@@ -908,7 +909,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
int idx = take_free_slot(l, rw, rh, AHB_RGBA8, &wait_fd);
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, NULL) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (!l->first_logged) {
l->first_logged = 1;
banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
@@ -950,7 +951,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
}
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, tm ? NULL : color) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (color && color->dataspace)
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
static int said = -1;
@@ -1031,7 +1032,7 @@ int sc_layer_overlay_affordable(void) {
return ok;
}
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent) {
struct layer *l = layer_of(SC_LAYER_OVERLAY);
if (!src || !geo) return -1;
/* The caller decides with sc_layer_overlay_affordable() BEFORE it commits the game to a layer;
@@ -1046,7 +1047,7 @@ int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
/* The window is copied into the layer buffer 1:1; `geo` crops it and places it, so the layer
* is exactly the window's rectangle on screen and nothing else is blended anywhere. */
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, geo, NULL) != 0) return -1;
if (present_slot(l, idx, geo, NULL, translucent) != 0) return -1;
if (!l->first_logged) {
l->first_logged = 1;
log_layer_count();
+2 -1
View File
@@ -150,8 +150,9 @@ int sc_layer_overlay_affordable(void);
/* OVERLAY layer: show `src` (one window's imported frame) above the game layer, at the placement
* `geo` = {src x0,y0,x1,y1 in image pixels, dst x0,y0,x1,y1 in output pixels} from vkp_map_draw.
* `translucent` preserves a client's alpha over the game for Steam notifications and overlays.
* 0 = shown or dropped, -1 = unavailable (the caller must fall back to the copy path). */
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]);
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
/* The scene is not a single fullscreen window this frame: hide every layer that is up. */
void sc_layer_hide(void);
+65 -23
View File
@@ -7,6 +7,7 @@
#include "effects_chain.h"
#include "framegen_bridge.h"
#include "hdr_compose.h"
#include "blend_pass.h"
#include "banner_color.h"
#include <stdio.h>
#include <stdlib.h>
@@ -32,6 +33,8 @@ struct vkp_image {
void *map; /* shm images: persistently mapped linear memory */
VkDeviceSize offset, row_pitch;
int in_general; /* shm images: moved from PREINITIALIZED to GENERAL */
VkFormat fmt;
int sampled; /* created with SAMPLED usage: the alpha pass can read it */
};
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
@@ -230,6 +233,7 @@ static void destroy_swapchain(void) {
if (g_dev_state != 1) return;
g_vk.DeviceWaitIdle(g_dev);
reset_sync();
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
g_swapchain = VK_NULL_HANDLE;
g_swap_md_identity = 0; /* a new swapchain gets the metadata again */
@@ -525,6 +529,7 @@ static int dev_init(void) {
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
hdrc_bind_device(g_dev, &g_memprops);
blendp_bind_device(g_dev);
g_dev_state = 1;
reset_sync();
init_black_image();
@@ -873,9 +878,8 @@ const char *vkp_modifier_name(uint64_t modifier) {
return other;
}
/* Can the driver create the image vkp_image_import_dmabuf() creates for this format+modifier
* (2D, DRM_FORMAT_MODIFIER tiling, TRANSFER_SRC, dma-buf memory) - and import it? */
static int modifier_importable(VkFormat fmt, uint64_t modifier) {
/* Can the driver create and import this format/modifier with the requested image usage? */
static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlags usage) {
if (!g_vk.GetPhysicalDeviceImageFormatProperties2) return 1; /* can't ask; the create will tell */
VkPhysicalDeviceExternalImageFormatInfo ext = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO,
@@ -886,7 +890,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier) {
VkPhysicalDeviceImageFormatInfo2 info = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, .pNext = &mod,
.format = fmt, .type = VK_IMAGE_TYPE_2D, .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT};
.usage = usage};
VkExternalImageFormatProperties extp = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES};
VkImageFormatProperties2 props = {.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, .pNext = &extp};
if (g_vk.GetPhysicalDeviceImageFormatProperties2(g_pd, &info, &props) != VK_SUCCESS) return 0;
@@ -914,7 +918,7 @@ int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
if (m != VKP_MOD_LINEAR && m != VKP_MOD_QCOM_COMPRESSED) why = "unknown layout";
else if (props[i].drmFormatModifierPlaneCount != 1) why = "not single-plane";
else if (!(props[i].drmFormatModifierTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) why = "no blit source";
else if (!modifier_importable(fmt, m)) why = "not importable as a dma-buf";
else if (!modifier_importable(fmt, m, VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) why = "not importable as a dma-buf";
if (why) {
LOGI("dmabuf: %c%c%c%c %s reported by the driver but not advertised: %s",
drm_format & 0xff, (drm_format >> 8) & 0xff, (drm_format >> 16) & 0xff,
@@ -942,6 +946,10 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = w; img->h = h; img->dmabuf = 1; img->blit_dst = as_blit_dst ? 1 : 0;
img->fmt = drm_to_vk(drm_format);
const VkImageUsageFlags sampled_usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
img->sampled = !as_blit_dst && g_vk.GetPhysicalDeviceImageFormatProperties2 &&
modifier_importable(img->fmt, modifier, sampled_usage);
VkSubresourceLayout plane = {.offset = offset, .rowPitch = stride};
VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {
@@ -952,10 +960,11 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT};
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &extImg,
.imageType = VK_IMAGE_TYPE_2D, .format = drm_to_vk(drm_format), .extent = {w, h, 1},
.imageType = VK_IMAGE_TYPE_2D, .format = img->fmt, .extent = {w, h, 1},
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT
: img->sampled ? sampled_usage : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
if (cr != VK_SUCCESS) {
@@ -1008,18 +1017,35 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
return img;
}
/* Linear wl_shm images can use the blend pass only if the driver samples them with a linear filter. */
static int shm_sampled(void) {
static int known;
if (!known) {
const VkFormatFeatureFlags need = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT |
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
VkFormatProperties2 fp = {.sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2};
if (g_vk.GetPhysicalDeviceFormatProperties2)
g_vk.GetPhysicalDeviceFormatProperties2(g_pd, VK_FORMAT_B8G8R8A8_UNORM, &fp);
known = (fp.formatProperties.linearTilingFeatures & need) == need ? 1 : -1;
}
return known == 1;
}
struct vkp_image *vkp_image_create_shm(int w, int h) {
if (w <= 0 || h <= 0 || dev_init() != 0) return NULL;
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = w; img->h = h;
img->fmt = VK_FORMAT_B8G8R8A8_UNORM;
img->sampled = shm_sampled();
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
.format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
.format = img->fmt, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | (img->sampled ? VK_IMAGE_USAGE_SAMPLED_BIT : 0),
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_PREINITIALIZED};
if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return NULL; }
@@ -1062,6 +1088,7 @@ int vkp_image_height(const struct vkp_image *img) { return img ? img->h : 0; }
void vkp_image_destroy(struct vkp_image *img) {
if (!img) return;
if (g_dev) {
blendp_forget_image(img->image);
if (img->map) g_vk.UnmapMemory(g_dev, img->mem);
if (img->image) g_vk.DestroyImage(g_dev, img->image, NULL);
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
@@ -1111,8 +1138,21 @@ static int draw_to_scene_blit(const struct vkp_draw *d, int scene_w, int scene_h
return map_draw(d, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, (float)scene_w, (float)scene_h, blit);
}
/* A translucent draw needs source-over blending; every other draw keeps the existing blit path. */
static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkImageBlit *blit, VkImage target,
VkFormat target_fmt, int target_w, int target_h, VkFilter filter) {
const struct vkp_image *im = d->img;
if (d->blend && im->sampled &&
blendp_draw(cmd, im->image, im->fmt, im->w, im->h, !im->dmabuf, target, target_fmt, target_w, target_h,
blit) == 0)
return;
g_vk.CmdBlitImage(cmd, im->image, im->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, blit, filter);
}
static void destroy_scene_image(void) {
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
if (g_scene.img) blendp_forget_image(g_scene.img);
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
if (g_scene.mem) g_vk.FreeMemory(g_dev, g_scene.mem, NULL);
memset(&g_scene, 0, sizeof(g_scene));
@@ -1150,6 +1190,7 @@ static int ensure_scene_image(int w, int h) {
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
if (s->img) hdrc_forget_image(s->img);
if (s->img) blendp_forget_image(s->img);
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
memset(s, 0, sizeof(*s));
@@ -1190,7 +1231,8 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
int scene_w, int scene_h, VkImage out, VkFormat out_fmt, enum hdrc_mode mode) {
if (!hf || !hf->is_hdr || n <= 0) return -1;
if (ensure_img(&g_mixed, scene_w, scene_h, HDR_FMT,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
"HDR composition (mixed)") != 0)
return -1;
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
@@ -1214,9 +1256,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
VkImageBlit blit;
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
/* A blit converts UNORM to UNORM by value: 8-bit sRGB and 10-bit PQ both land unchanged. */
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
g_mixed.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
record_draw(cmd, &draws[i], &blit, g_mixed.img, HDR_FMT, scene_w, scene_h, VK_FILTER_LINEAR);
if (hf->is_hdr[i] && lowest_hdr < 0) lowest_hdr = i;
}
static int truncated_said;
@@ -1249,6 +1289,7 @@ static void device_lost(const char *where) {
vkp_framegen_device_lost();
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
"restart the session", where);
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
g_swapchain = VK_NULL_HANDLE;
if (g_surface) g_vk.DestroySurfaceKHR(g_inst, g_surface, NULL);
@@ -1547,6 +1588,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
g_vk.BeginCommandBuffer(cmd, &bi);
blendp_begin_frame();
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Source images: take dmabufs from the client's queue family, move shm images to
@@ -1589,8 +1631,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
free(bars);
/* Clear the composition target to black (the letterbox on the direct path, the desktop's
* background on the pass) before the draws land on it. A draw that covers the whole scene
* overwrites every pixel of its rectangle (blits never blend), so then only what lies outside it
* background on the pass) before the draws land on it. An opaque draw that covers the whole scene
* overwrites every pixel of its rectangle, so then only what lies outside it
* needs black: the letterbox bars on the direct path, nothing at all on the pass (the scene image
* is exactly the scene). Anything else - no such draw, the HDR composition - clears the whole
* target as before. */
@@ -1600,7 +1642,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
if (!(hf && pass)) {
for (int i = n - 1; i >= 0 && !covered; i--) {
const struct vkp_draw *d = &draws[i];
if (!d->img || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w || d->dy + d->dh < scene_h) continue;
if (!d->img || d->blend || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w ||
d->dy + d->dh < scene_h) continue;
covered = pass ? draw_to_scene_blit(d, scene_w, scene_h, &cover_blit) : draw_to_blit(d, &cover_blit);
}
}
@@ -1633,10 +1676,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageBlit blit;
if (!draws[i].img) continue;
if (pass ? !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit) : !draw_to_blit(&draws[i], &blit)) continue;
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit,
pass ? VK_FILTER_LINEAR : blit_filter);
record_draw(cmd, &draws[i], &blit, target, pass ? scene_fmt : g_swap_fmt,
pass ? scene_w : (int)g_extent.width, pass ? scene_h : (int)g_extent.height,
pass ? VK_FILTER_LINEAR : blit_filter);
drawn++;
}
@@ -2073,6 +2115,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
g_vk.BeginCommandBuffer(cmd, &bi);
blendp_begin_frame();
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Same acquisition as vkp_render's compositor pass: the sources come from the client's queue
@@ -2132,9 +2175,8 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
for (int i = 0; i < n; i++) {
VkImageBlit blit;
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
g_scene.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
record_draw(cmd, &draws[i], &blit, g_scene.img, SCENE_FMT, scene_w, scene_h,
VK_FILTER_LINEAR);
}
if (vkp_effects_active()) vkp_effects_set_formats(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM);
}
@@ -79,6 +79,7 @@ struct vkp_draw {
struct vkp_image *img;
float sx, sy, sw, sh;
int dx, dy, dw, dh;
int blend; /* translucent: composed over what is under it (blend_pass.h) */
};
// The GPU the renderer runs on ("Adreno (TM) 750"), empty before the device is up.
@@ -0,0 +1,334 @@
/*
* zwp_linux_dmabuf_v1: the buffer params a client builds a dma-buf wl_buffer from, the format and
* modifier table the renderer can import, and dma-buf feedback (version 4). Split out of
* compositor.c; the buffer records themselves are in compositor_internal.h.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <wayland-server.h>
#include "linux-dmabuf-v1-server-protocol.h"
#include "vk_present.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "compositor_internal.h"
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
dmabuf_buffer_unref(wl_resource_get_user_data(r));
}
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (plane >= MAX_PLANES) { close(fd); return; }
if (p->fd[plane] >= 0) close(p->fd[plane]);
p->fd[plane] = fd;
p->offset[plane] = offset;
p->stride[plane] = stride;
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
}
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
if (!b) return NULL;
b->n_planes = p->n_planes;
b->refs = 1; /* the wl_buffer resource's */
b->width = w; b->height = h; b->format = format;
b->modifier = p->modifier[0];
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
for (int i = 0; i < p->n_planes; i++) {
b->fd[i] = p->fd[i];
b->offset[i] = p->offset[i];
b->stride[i] = p->stride[i];
p->fd[i] = -1; /* ownership moves to the buffer */
}
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!buf) {
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
free(b);
wl_client_post_no_memory(c);
return NULL;
}
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
struct client_info *ci = client_info_of(c);
if (ci) ci->dmabuf_buffers++;
return buf;
}
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
uint32_t format, uint32_t flags) {
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
else zwp_linux_buffer_params_v1_send_failed(r);
}
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
params_do_create(c, r, buffer_id, w, h, format, flags);
}
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
.destroy = params_destroy,
.add = params_add,
.create = params_create,
.create_immed = params_create_immed,
};
static void params_resource_destroy(struct wl_resource *r) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (!p) return;
for (int i = 0; i < MAX_PLANES; i++)
if (p->fd[i] >= 0) close(p->fd[i]);
free(p);
}
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct dmabuf_params *p = calloc(1, sizeof(*p));
if (!p) { wl_client_post_no_memory(c); return; }
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
wl_resource_get_version(r), id);
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(pr, &params_impl, p, params_resource_destroy);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface);
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
.destroy = dmabuf_destroy,
.create_params = dmabuf_create_params,
.get_default_feedback = dmabuf_get_default_feedback,
.get_surface_feedback = dmabuf_get_surface_feedback,
};
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
* takes the first format it sees - gets exactly the table it always had. */
#define DMABUF_NFMT_MAX 6
#define DMABUF_NFMT_SDR 4
#define DMABUF_NMOD 4
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
static int g_dmabuf_fmts_ready;
static void dmabuf_build_feedback(void);
static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
* we advertised before this table existed. */
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
if (got[i] == MOD_LINEAR) continue;
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
g_dmabuf_fmts[f].mods[k++] = got[i];
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
}
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
g_dmabuf_fmts[f].n = k;
uint32_t fmt = g_dmabuf_fmts[f].fmt;
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
}
dmabuf_build_feedback();
}
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
*
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
*
* What a client needs from us is one tranche describing "everything the compositor can import":
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
#ifndef F_ADD_SEALS
#define F_ADD_SEALS 1033
#define F_SEAL_SEAL 0x0001
#define F_SEAL_SHRINK 0x0002
#define F_SEAL_GROW 0x0004
#define F_SEAL_WRITE 0x0008
#endif
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
static size_t g_fmt_table_size;
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
dev_t g_main_device; /* the render node clients should allocate on */
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
static dev_t dmabuf_render_node(void) {
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
struct stat st;
if (g_no_render_node) return 0;
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
return 0;
}
static void dmabuf_build_feedback(void) {
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
int n = 0;
g_main_device = dmabuf_render_node();
for (int f = 0; f < g_dmabuf_nfmt; f++)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
entries[n].format = g_dmabuf_fmts[f].fmt;
entries[n].pad = 0;
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
n++;
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
if (write(fd, entries, size) != (ssize_t)size) {
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
close(fd);
return;
}
/* The client mmaps this read-only and trusts it not to change under it. */
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
/* One tranche: our device, every pair in the table, no scanout flag. */
static void dmabuf_feedback_send(struct wl_resource *fb) {
struct wl_array dev, idx;
uint16_t *ind;
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
wl_array_init(&dev);
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
wl_array_release(&dev);
wl_array_init(&idx);
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
wl_array_release(&idx);
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
zwp_linux_dmabuf_feedback_v1_send_done(fb);
}
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
.destroy = dmabuf_feedback_destroy,
};
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
wl_resource_get_version(parent), id);
if (!fb) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
dmabuf_feedback_send(fb);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct client_info *ci = client_info_of(c);
if (ci) ci->asked_feedback = 1;
dmabuf_new_feedback(c, r, id);
}
/* Per-surface feedback would let us hint a different tranche for a window on its own display
* layer; we have nothing better to say per surface, so it is the default one. */
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface) {
dmabuf_new_feedback(c, r, id);
}
void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
/* From version 4 the format and modifier events are gone: the client asks for feedback
* instead, and sending both would only confuse it about which list is authoritative. */
if (ver >= 4) return;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
if (ver >= 3)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
}
}
@@ -1,12 +1,17 @@
package com.droiddeck.launcher
import android.app.Application
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.session.CrashHandler
/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */
/**
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
* the socket Flatpak's sandboxes are started through (BwrapSpawner).
*/
class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
BwrapSpawner.start(this)
}
}
@@ -0,0 +1,84 @@
package com.droiddeck.launcher
import android.os.Handler
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentsManager
/**
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
*/
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
var compComp by mutableStateOf("dxvk")
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
fun refreshComponents(snapshotFirst: Boolean = false) {
Thread({
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(activity) }
runCatching { ComponentsManager.applyQueued(activity) }
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
}
}, "components").start()
}
fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compBusy = null
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-action").start()
}
fun refreshComponentCatalog() {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
}
fun downloadComponent(item: ComponentsManager.CatalogItem) {
if (compDownloads.containsKey(item.file)) return
compDownloads = compDownloads + (item.file to -1)
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
}, "components-download").start()
}
private companion object {
const val TAG = "MainActivity"
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionState
/**
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
* in progress. The launcher screen keeps one and shows it.
*/
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
var deckySupervisor by mutableStateOf(false)
var deckyReleaseRequest = 0
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
// Use the newest compatible stable build, or the newest compatible
// prerelease when the fork has not published a stable ARM64 asset.
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-decky").start()
}
}
@@ -0,0 +1,217 @@
package com.droiddeck.launcher
import android.app.Activity
import android.content.Context
import java.io.File
import android.net.Uri
import android.provider.OpenableColumns
import android.os.Handler
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.DriverRow
/**
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
* rows to the mode settings dialog.
*/
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
var releaseChecking by mutableStateOf(false)
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
var linuxSteam by mutableStateOf("")
var linuxDesktop by mutableStateOf("")
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
).joinToString(" · "),
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
val td = TurnipDriver(activity)
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(activity)
refreshReleaseRows()
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
*/
fun importDriver(uri: Uri, linux: Boolean) {
val name = activity.displayNameOf(uri)
Thread({
val problem = try {
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
else TurnipDriver(activity).installFromZip(uri, name)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
}
ui.post {
android.widget.Toast.makeText(
activity, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
fun deleteDriver(id: String, linux: Boolean) {
if (linux) {
LinuxVulkanDriverManager(activity).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
}
} else {
val td = TurnipDriver(activity)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
}
TurnipReleases.forget(activity, id)
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
/** The download entries and the refresh line, from what the last check found. */
fun refreshReleaseRows() {
val check = TurnipReleases.cached(activity)
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
}
}
/** Only when the user taps refresh: nothing goes online on its own. */
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
}
ui.post {
releaseChecking = false
refreshReleaseRows()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
}
/** Download one release driver and import it through the same importer a picked zip uses. */
fun downloadReleaseDriver(assetName: String) {
val asset = TurnipReleases.cached(activity)?.assets?.firstOrNull { it.name == assetName } ?: return
if (releaseProgress.containsKey(assetName)) return
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(activity, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else TurnipDriver(activity).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(activity, asset, id)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
}
}, "download-turnip").start()
}
private companion object {
const val TAG = "MainActivity"
}
}
internal fun Context.displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
} catch (e: Exception) {
null
}
@@ -97,13 +97,11 @@ object HomeApp {
val intent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(ComponentName(app.packageName, app.className))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (displayId == null) {
context.startActivity(intent)
return
}
// Keep the target app's task separate from DroidDeck. Without this, Android can put the
// app on top of the caller's task and move the entire task to the chosen display.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
context.startActivity(intent, options.toBundle())
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,62 @@
package com.droiddeck.launcher
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.session.SessionState
/**
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
* removal in progress. The launcher screen keeps one and shows it.
*/
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(null)
var protonPercent by mutableIntStateOf(-1)
fun installProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = "Starting…"
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-proton-$id").start()
}
fun removeProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonPercent = -1
Thread({
ProtonExtras.remove(activity, tool)
ui.post {
protonBusyId = null
protonStage = null
refreshProtons()
}
}, "remove-proton-$id").start()
}
fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
}
}
@@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -45,7 +47,6 @@ import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHints
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SessionPrefs
@@ -69,6 +70,8 @@ import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import kotlin.math.abs
/**
@@ -88,6 +91,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private var padBridge: PadBridge? = null
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
}
private var onScreenControls: OnScreenControls? = null
private var keyboard: KeyboardHost? = null
private var watching = true
@@ -95,6 +109,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f))
private var cursorVisible by mutableStateOf(false)
/** The blue the front end flooded to on the way in; the loading screen opens on it (LaunchFlood). */
private var floodColor by mutableStateOf<Int?>(null)
private val cursorHide = Runnable { cursorVisible = false }
/** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */
private var lastPadInputMs = 0L
@@ -140,6 +156,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
private var quitFlooded = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
@@ -154,9 +176,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(-1)
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
private var started = false
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
@@ -191,8 +215,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
// Game-tier power policy for the whole session: the panel's fastest mode, and the OS told
// it is in gameplay. Logged so a slow device says why.
Log.i(TAG, "perf: " + PerfMode.apply(this))
goFullscreen()
// The device's volume keys change the stream the session plays on (the relay and
@@ -212,6 +236,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
}
SessionState.deckPadListener = deckPadListener
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
bridge.setOnPlayerInput {
@@ -228,12 +257,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
root.pointerIcon = noCursor
surfaceView.pointerIcon = noCursor
loading = LoadingState(this)
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
if (needRuntime || needDesktop) installThenStart(needRuntime, needDesktop)
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -246,121 +277,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
readPrefs()
// One Compose layer for every menu and overlay. Touches nothing in it consumes fall
// through to the pad and the game underneath.
sessionOverlay = ComposeView(this).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
} else {
releaseDrawerDirection()
}
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app, displayId ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
))
if (SessionState.suspended) SessionPausedOverlay {
SessionService.resume(this@SessionActivity)
}
}
}
intent.getIntExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, 0).takeIf { it != 0 && loading.visible }?.let {
floodColor = it
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
}
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
setContentView(root)
handleHomeGuideIntent(intent)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
WaylandCompositor.setFirstFrameListener(firstFrameListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
@@ -369,6 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun handleOnBackPressed() {
// The PC keyboard closes first (B on a controller), then Back is the drawer again.
when {
::loading.isInitialized && loading.ended -> finish()
drawerOpen -> drawerOpen = false
pcKeyboardOpen -> pcKeyboardOpen = false
else -> routeBackAction()
@@ -378,6 +306,147 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
watchSession()
}
/**
* One Compose layer for every menu and overlay. Touches nothing in it consumes fall through to
* the pad and the game underneath.
*/
private fun createSessionOverlay(): ComposeView = ComposeView(this).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
onFlooded = {
floodColor = null
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
} else {
releaseDrawerDirection()
}
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app, displayId ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onStopFrom = { onScreen ->
drawerOpen = false
if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) {
if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() }
} else {
val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) }
quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat())
}
},
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
))
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
quitFrom?.let { from ->
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
com.droiddeck.launcher.ui.LaunchFlood(
from, onProgress = {},
fromColors = error to androidx.compose.ui.graphics.lerp(error, androidx.compose.ui.graphics.Color.Black, 0.12f),
cornerAtRest = 20.dp,
) { finishFlooded() }
}
}
}
}
private fun refreshHomeApp() {
isHomeApp = HomeApp.isDefault(this)
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
@@ -408,7 +477,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
performBackAction(double = false)
}
pendingBackAction = pending
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
}
private fun performBackAction(double: Boolean) {
@@ -424,6 +494,62 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
}
/** Re-reads the Protons for the drawer's Components tab, first running swaps a closed game was holding back. */
private fun refreshDrawerComponents() {
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
val snap = runCatching { ComponentsManager.snapshot(this) }.getOrNull()
uiHandler.post {
drawerComponents = snap
if (applied.isNotEmpty()) android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
}
}, "drawer-components").start()
}
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
Thread({
val message = runCatching {
val snap = ComponentsManager.snapshot(this)
val queued = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.queued
val active = snap.protons.firstOrNull { it.proton.id == protonId }?.components?.get(comp)?.activeFile
val build = snap.protons.firstOrNull { it.proton.id == protonId }?.proton?.version?.let(ComponentsManager::safeName)
val current = active ?: "orig:$build"
if (queued != null && value == current) {
// Picking what is already in place again cancels the swap waiting for the game.
ComponentsManager.cancelQueued(this, protonId, comp); "The waiting swap was cancelled."
} else if (value.startsWith("orig:")) ComponentsManager.restore(this, protonId, comp, value.removePrefix("orig:"))
else ComponentsManager.swap(this, protonId, value)
}.getOrElse { e -> "Swap failed: ${e.message ?: e.javaClass.simpleName}" }
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
}
}, "drawer-components-swap").start()
}
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
private fun loadingMode(): String =
if (SessionState.running) SessionState.mode
else intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
private fun pausedTitle(): String = when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "The desktop is paused"
SessionService.MODE_RUN -> (com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program") + " is paused"
else -> "Steam is paused"
}
private fun loadingTitle(): String = when (loadingMode()) {
SessionService.MODE_DESKTOP ->
if (!SessionState.running && intent.getStringExtra(SessionService.EXTRA_STEAM_UI) != null) "Starting Steam on the desktop"
else "Starting the desktop"
SessionService.MODE_RUN -> {
val program = if (SessionState.running) SessionState.program else intent.getStringExtra(SessionService.EXTRA_PROGRAM)
"Starting " + (com.droiddeck.launcher.frontend.Library.nameForProgram(program) ?: "the program")
}
else -> "Starting Steam"
}
private fun shareCurrentSessionLogs() {
val folder = SessionPaths.current()
if (folder == null || !folder.isDirectory) {
@@ -471,17 +597,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* First Play (or Desktop) on a fresh install: the runtime, and for the desktop its package,
* are downloaded and unpacked here, on the loading screen's own line and bar, and the session
* starts when they are in. Nothing else changes.
*
* The first Steam session also gets Valve's ARM64 Proton here, before the client has ever
* started, so the compatibility tool is installed and the default at sign-in with no install
* dialog, download or client restart. Not being able to fetch it stops nothing: the session
* then asks the client for it after sign-in, as it always has.
*/
private fun installThenStart(runtime: Boolean, desktop: Boolean) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else "desktop"
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
if (runtime) "runtime.installing" else "desktop.installing",
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop"
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
else "downloading Proton Experimental (ARM64)"
loading.percent = -1
Thread({
var failedComponent: String? = null
@@ -496,6 +628,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installDesktop()
if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop"
}
if (problem == null && proton && !SessionState.stopRequested) {
SessionState.installing = "proton"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton"))
val protonProblem = installProton()
if (protonProblem == null) SessionEvents.record("proton.ready")
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
}
uiHandler.post {
installingRuntime = false
SessionState.installing = null
@@ -511,6 +650,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
focusEndedScreen()
return@post
}
loading.percent = -1
@@ -561,6 +701,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
}
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
private fun installProton(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
val entry = catalog.fetch(catalog.STEAM_SEED_URL)?.firstOrNull { it.id == catalog.PROTON_SEED_ID }
?: return "catalog unreachable"
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
}
override fun surfaceCreated(holder: SurfaceHolder) {
if (installingRuntime || SessionState.stopRequested) return
if (!SessionState.running) {
@@ -629,9 +778,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
)
if (!SessionState.running) SessionEvents.record("compositor.started")
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
// period, so a long frame raises CPU clocks now rather than after the load averages up.
PerfHints.arm(this, refreshHz())
// The service owns everything below the compositor. It is started whenever no session is
// running - NOT only when the compositor was just started: the compositor lives for the
// whole process, so the second Play after a session ended used to re-attach the Surface,
@@ -717,9 +863,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return Pair(width and 1.inv(), height and 1.inv())
}
/**
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
*/
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
}
@@ -791,19 +944,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun showEnded(status: Int, hint: String?) {
run {
if (status != 0) {
loading.showEnded(
"The session ended ($status)\n${SessionState.logFile?.path ?: "-"}" +
(if (hint != null) "\n\n$hint" else "")
)
// A moment on screen, so a failure is readable rather than a flash of black;
// longer when there is advice to read.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (hint != null) 9000 else 4000)
} else {
finish()
}
if (status == 0) {
finish()
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
val what = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
SessionService.MODE_DESKTOP -> "The desktop"
else -> "Steam"
}
loading.showEnded(
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
}
/** The ended screen's buttons take the pad: it is no longer driving a game. */
private fun focusEndedScreen() {
drawerOpen = false
padBridge?.releaseAll()
uiHandler.post { sessionOverlay.requestFocus() }
}
/** The same start again, in a fresh activity: the ended one keeps no session to reuse. */
private fun retrySession() {
val again = Intent(intent)
finish()
startActivity(again)
}
/**
@@ -851,10 +1020,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
}
// While the session starts, B is the loading screen's Cancel, as its corner says.
if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
SessionService.stop(this)
finish()
}
return true
}
// The ended screen is a page of buttons: the pad drives them, and B leaves.
if (::loading.isInitialized && loading.ended) {
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B || event.keyCode == KeyEvent.KEYCODE_BACK) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) finish()
return true
}
return super.dispatchKeyEvent(event)
}
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else 2) % 3
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
releaseDrawerDirection()
}
return true
@@ -1112,11 +1297,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
// A choice kept while its display was away (asleep, waking): back as soon as it is.
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
}
private fun selectSecondScreenDisplay(displayId: Int) {
if (secondScreenDisplays.none { it.id == displayId }) return
selectedSecondScreenDisplay = displayId
SessionState.secondScreenDisplay = displayId
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
}
@@ -1138,8 +1326,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showSecondScreen(mode: SecondScreenMode) {
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
closeSecondScreen(reset = true)
refreshSecondScreenDisplays()
// Not there right now - a panel still waking, say. The choice is kept; the display
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
closeSecondScreen(reset = false)
return
}
var presentation = secondScreenPresentation
@@ -1171,6 +1360,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!presentation.isShowing) presentation.show()
presentation.showMode(mode)
secondScreenMode = mode
SessionState.secondScreenMode = mode
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
} catch (e: Exception) {
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
closeSecondScreen(reset = true)
@@ -1190,7 +1381,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun closeSecondScreen(reset: Boolean) {
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
if (reset) secondScreenMode = SecondScreenMode.NONE
if (reset) {
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
}
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
@@ -1303,15 +1497,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onStart() {
super.onStart()
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
SessionService.setActivityVisible(this, true)
}
override fun onStop() {
started = false
displayManager.unregisterDisplayListener(displayListener)
closeSecondScreen(reset = true)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
// onStart puts it back.
closeSecondScreen(reset = false)
SessionService.setActivityVisible(this, false)
super.onStop()
}
@@ -1338,6 +1536,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
intent.action = null
@@ -1347,15 +1546,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
onScreenControls?.reload()
updateOnScreenControls()
resumed = true
updatePadMotion()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
}
override fun onPause() {
// Do not carry transient session UI across an app/display transition. In particular, the
// drawer's dim layer can otherwise remain over Steam when this activity returns.
drawerOpen = false
pcKeyboardOpen = false
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
}
@@ -1391,6 +1598,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Ours, so a finishing activity never unhooks the one that replaced it. */
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
private val firstFrameListener = Runnable {
SessionEvents.firstFrame()
runOnUiThread {
loading.visible = false
hud.start()
}
}
override fun onDestroy() {
// Deliberately does NOT end the session: this activity can be destroyed while the user is
@@ -1402,21 +1616,34 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = true)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.setFirstFrameListener(null)
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
super.onDestroy()
}
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
private fun finishFlooded() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
quitFlooded = true
SessionService.stop(this)
finish()
}
/** Leaving the session sinks its surface back down onto the front end. */
override fun finish() {
super.finish()
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
companion object {
private const val TAG = "SessionActivity"
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
private const val CURSOR_PAD_HOLD_MS = 1200L
private const val DRAWER_HAT_THRESHOLD = 0.5f
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
@@ -0,0 +1,25 @@
package com.droiddeck.launcher.core;
import java.io.File;
import java.io.IOException;
/**
* Where an archive entry may land. An entry name ("../x", "/etc/x") or a symlink already written
* by an earlier entry can point outside the directory being filled; the canonical path follows
* both, so anything that does not resolve inside it is refused.
*/
public final class ArchivePaths {
private ArchivePaths() {}
/**
* The file for {@code name} under {@code destination}, or null when it would resolve outside
* it. The destination itself counts as inside (a "./" entry).
*/
public static File inside(File destination, String name) throws IOException {
String base = destination.getCanonicalPath();
File file = new File(destination, name);
String path = file.getCanonicalPath();
if (path.equals(base) || path.startsWith(base + File.separator)) return file;
return null;
}
}
@@ -5,7 +5,6 @@ import android.content.Context
import android.os.Build
import android.os.Environment
import android.os.StatFs
import android.provider.Settings
import android.util.Log
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
@@ -145,9 +144,12 @@ object DeviceReport {
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
@@ -162,21 +164,7 @@ object DeviceReport {
})
h("Android process limits")
// Android 12 kills "phantom" processes - the children an app forks itself rather than
// starting through the framework - once there are more than a handful of them. A session is
// nothing but those: proot, gamescope, Xwayland, the client, its helpers, Wine, FEX. Where
// this is left on, the OS kills the session and nothing in our logs says why, because
// nothing in the session did it. Some ROMs expose it in Developer options as a
// "restrict child processes" switch; otherwise it is
// adb shell settings put global settings_enable_monitor_phantom_procs false
val phantom = runCatching {
Settings.Global.getString(context.contentResolver, "settings_enable_monitor_phantom_procs")
}.getOrNull()
k("Phantom proc monitor", when (phantom?.lowercase()) {
"false", "0" -> "disabled (good - the OS will not kill the session's children)"
null, "" -> "not set (ROM default, usually ENABLED - see the note below)"
else -> "ENABLED (the OS may kill the session with no log; turn off \"restrict child processes\")"
})
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
@@ -1,42 +1,33 @@
package com.droiddeck.launcher.core
/**
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
* session's environment; the games under FEX inherit them from the client. The default is FEX's
* own defaults - no variables at all - which is what every session has run on so far.
*
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
*/
import com.droiddeck.launcher.R
object FexPreset {
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
)
val all: List<Preset> = listOf(
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
)
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
/** Game-launch variables in KEY=VALUE form. */
fun env(id: String): List<String> = byId(id).env
}
@@ -104,6 +104,7 @@ public final class FileUtils {
public static String sizeToString(long bytes) {
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
return bytes + " B";
}
}
@@ -0,0 +1,38 @@
package com.droiddeck.launcher.core
object GameEnvironment {
data class Config(
val shared: Map<String, String?> = emptyMap(),
val games: Map<String, Map<String, String?>> = emptyMap(),
) {
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
fun withEntries(scope: String, entries: Map<String, String?>): Config =
if (scope.isEmpty()) copy(shared = entries.toMap())
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
}
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val unused = setOf(
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
)
fun validName(name: String) = namePattern.matches(name)
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -0,0 +1,43 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.R
object GameEnvironmentOptions {
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
val all = listOf(
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
)
fun find(name: String): Option? = all.firstOrNull { it.name == name }
fun toggle(value: String, choice: String): String {
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
if (!selected.remove(choice)) selected.add(choice)
return selected.joinToString(",")
}
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.core
import java.io.File
import java.security.MessageDigest
/** File checksums as lowercase hex, and GitHub's release-asset digests, in one place. */
object Hashes {
private val GITHUB_SHA256 = Regex("(?i)sha256:[0-9a-f]{64}")
@JvmStatic
fun sha256(file: File): String = hex(file, "SHA-256")
@JvmStatic
fun sha512(file: File): String = hex(file, "SHA-512")
/** True when [digest] is GitHub's asset digest form, "sha256:" and 64 hex digits. */
@JvmStatic
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
/** The hex of a GitHub asset digest ("sha256:<hex>"), or null when [digest] is not one. */
@JvmStatic
fun githubSha256(digest: String?): String? = digest?.takeIf { isGithubSha256(it) }?.substringAfter(':')
private fun hex(file: File, algorithm: String): String {
val digest = MessageDigest.getInstance(algorithm)
file.inputStream().buffered(1 shl 16).use { input ->
val buffer = ByteArray(1 shl 20)
while (true) {
val n = input.read(buffer)
if (n < 0) break
digest.update(buffer, 0, n)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
}
@@ -64,7 +64,8 @@ object HostProcess {
}
}
private fun pidOf(process: Process): Int = try {
@JvmStatic
fun pidOf(process: Process): Int = try {
val field = process.javaClass.getDeclaredField("pid")
field.isAccessible = true
val pid = field.getInt(process)
@@ -0,0 +1,131 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.os.Build
import android.provider.Settings
/** Android's phantom process monitor can kill the many child processes used by a Steam session. */
enum class PhantomProcessStatus {
NOT_APPLICABLE,
DISABLED,
ENABLED,
UNSET,
UNREADABLE,
}
object PhantomProcessLimit {
private const val SETTING = "settings_enable_monitor_phantom_procs"
private const val MAX_PHANTOM = "2147483647"
private const val PREFS = "phantom-process-limit"
private const val PREF_ANDROID_12_OFF = "android12-off"
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
usesDeviceConfig(sdk) && enabled -> listOf(
"device_config delete activity_manager max_phantom_processes",
"device_config set_sync_disabled_for_tests none",
)
usesDeviceConfig(sdk) -> listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
)
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
}
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
else "settings get global $SETTING"
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
val value = output.trim()
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
else value == if (enabled) "true" else "false"
}
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
fun adbCommand(): String = adbCommand(false)
fun rememberAndroid12(context: Context, off: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
}
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
if (usesDeviceConfig(sdk)) {
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
}
val global = try {
Settings.Global.getString(context.contentResolver, SETTING)
} catch (_: Exception) {
return PhantomProcessStatus.UNREADABLE
}
return status(global, systemProperty(OVERRIDE_PROPERTY))
}
/**
* The value Android itself acts on, in FeatureFlagUtils.isEnabled's order: the global setting
* when it is set, otherwise the feature-flag override property. Developer options' "Disable
* child process restrictions" writes only the property, so a device switched off there has no
* global setting at all and must not be reported as unset.
*/
fun status(global: String?, override: String?): PhantomProcessStatus {
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
return when (value) {
"false", "0" -> PhantomProcessStatus.DISABLED
null -> PhantomProcessStatus.UNSET
else -> PhantomProcessStatus.ENABLED
}
}
private const val OVERRIDE_PROPERTY = "persist.sys.fflag.override.$SETTING"
private fun systemProperty(name: String): String? = try {
Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java)
.invoke(null, name) as? String
} catch (_: Exception) {
null
}
fun hasDeveloperToggle(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
fun title(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
PhantomProcessStatus.UNREADABLE -> "Child-process limit could not be checked"
PhantomProcessStatus.DISABLED -> "Child-process limit is off"
PhantomProcessStatus.NOT_APPLICABLE -> "Child-process limit not required"
}
fun instructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
}
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
else -> instructions(status)
}
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
fun reportValue(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
PhantomProcessStatus.UNSET -> "not set (ROM default applies; DroidDeck requires an explicit disabled value)"
PhantomProcessStatus.UNREADABLE -> "unreadable (DroidDeck could not verify the setting)"
PhantomProcessStatus.NOT_APPLICABLE -> "not applicable before Android 12"
}
}
@@ -37,11 +37,10 @@ public final class TarZst {
destination.mkdirs();
try (InputStream in = new ZstdCompressorInputStream(new BufferedInputStream(source, 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
String base = destination.getCanonicalPath() + File.separator;
TarArchiveEntry entry;
while ((entry = tar.getNextTarEntry()) != null) {
File file = new File(destination, entry.getName());
if (!file.getCanonicalPath().startsWith(base)) continue;
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) continue;
if (entry.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
file.mkdirs();
@@ -0,0 +1,208 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.ArrayDeque
/** A narrowly scoped Wireless debugging client for the child-process setting repair. */
object WirelessAdbFix {
private const val CERT_FILE = "wireless-adb-cert.pem"
private const val KEY_FILE = "wireless-adb-key.pem"
private const val HOST_FILE = "wireless-adb-host.txt"
private const val CONNECTION_FILE = "wireless-adb-connection.txt"
const val LOOPBACK = "127.0.0.1"
private data class SavedConnection(val host: String, val port: Int?)
@Synchronized
private fun loadOrCreateIdentity(context: Context) {
val directory = context.noBackupFilesDir
val cert = File(directory, CERT_FILE)
val key = File(directory, KEY_FILE)
if (cert.isFile && key.isFile) {
KadbCert.set(cert.readBytes(), key.readBytes())
return
}
val identity = KadbCert.get(notAfter = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(3650))
KadbCert.set(identity.first, identity.second)
writeAtomically(cert, identity.first)
writeAtomically(key, identity.second)
}
private fun writeAtomically(destination: File, contents: ByteArray) {
val temporary = File(destination.parentFile, "${destination.name}.tmp")
temporary.writeBytes(contents)
check(temporary.renameTo(destination)) { "Could not save Wireless debugging identity" }
}
suspend fun pair(context: Context, host: String, port: Int, pairingCode: String) {
loadOrCreateIdentity(context.applicationContext)
Kadb.pair(host, port, pairingCode, "DroidDeck")
writeAtomically(File(context.noBackupFilesDir, HOST_FILE), host.toByteArray())
writeAtomically(File(context.noBackupFilesDir, CONNECTION_FILE), ByteArray(0))
}
private fun savedConnection(context: Context): SavedConnection? {
val directory = context.applicationContext.noBackupFilesDir
if (!File(directory, CERT_FILE).isFile || !File(directory, KEY_FILE).isFile) return null
val host = File(directory, HOST_FILE).takeIf(File::isFile)?.readText()?.trim()
?.takeIf(String::isNotEmpty) ?: return null
val lines = File(directory, CONNECTION_FILE).takeIf(File::isFile)?.readLines()
val port = lines?.takeIf { it.size == 2 && it[0] == host }
?.get(1)?.toIntOrNull()?.takeIf { it in 1..65535 }
return SavedConnection(host, port)
}
/** A saved pairing skips the PIN; an unknown connection port can be filled in from Settings. */
fun savedConnectionAddress(context: Context): String? = savedConnection(context)?.let { saved ->
val host = if (':' in saved.host) "[${saved.host}]" else saved.host
"$host:${saved.port ?: ""}"
}
private fun saveConnection(context: Context, host: String, port: Int) {
writeAtomically(
File(context.applicationContext.noBackupFilesDir, CONNECTION_FILE),
"$host\n$port".toByteArray(),
)
}
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
fun isLocalAddress(address: InetAddress): Boolean = runCatching {
if (address.isLoopbackAddress) return true
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { network ->
network.inetAddresses.toList().any { it.address.contentEquals(address.address) }
}
}.getOrDefault(address is Inet4Address)
fun localConnectPort(context: Context): Int? =
tlsPortProperty()
?: findConnectPort(context, LOOPBACK)
private fun tlsPortProperty(): Int? = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
.invoke(null, "service.adb.tls.port") as? String
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
val paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
val matches: (InetAddress) -> Boolean =
if (paired.isLoopbackAddress) ::isLocalAddress else { address -> address.address.contentEquals(paired.address) }
val resolvedPort = AtomicInteger(-1)
val finished = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queue = ArrayDeque<NsdServiceInfo>()
val seen = mutableSetOf<String>()
var resolving = false
val lock = Any()
lateinit var resolveNext: () -> Unit
resolveNext = {
val next = synchronized(lock) {
if (finished.get() || resolving || queue.isEmpty()) null
else queue.removeFirst().also { resolving = true }
}
if (next != null) {
val listener = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
synchronized(lock) { resolving = false }
resolveNext()
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
resolvedPort.set(serviceInfo.port)
latch.countDown()
} else {
synchronized(lock) { resolving = false }
resolveNext()
}
}
}
runCatching { manager.resolveService(next, listener) }.onFailure {
synchronized(lock) { resolving = false }
resolveNext()
}
}
}
val discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType?.contains("_adb-tls-connect._tcp") == true) {
synchronized(lock) {
if (seen.add(serviceInfo.serviceName)) queue.addLast(serviceInfo)
}
resolveNext()
}
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) { latch.countDown() }
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { latch.countDown() }
}
try {
manager.discoverServices("_adb-tls-connect._tcp.", NsdManager.PROTOCOL_DNS_SD, discoveryListener)
latch.await(timeoutSeconds, TimeUnit.SECONDS)
} catch (_: Exception) {
return null
} finally {
runCatching { manager.stopServiceDiscovery(discoveryListener) }
}
return resolvedPort.get().takeIf { it in 1..65535 }
}
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
loadOrCreateIdentity(context.applicationContext)
Kadb.create(host, port).use { adb ->
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
val change = adb.shell(command)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
}
val result = adb.shell(PhantomProcessLimit.verifyCommand())
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
}
}
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
saveConnection(context, host, port)
}
/** Tries the app's saved pairing without asking for a new pairing code. */
fun setUsingSavedPairing(context: Context, enabled: Boolean) {
val appContext = context.applicationContext
val saved = savedConnection(appContext)
?: error("No Wireless debugging pairing is saved")
val cachedPort = saved.port
if (cachedPort != null) {
val cachedResult = runCatching {
setChildProcessLimit(appContext, saved.host, cachedPort, enabled)
}
if (cachedResult.isSuccess) return
val discoveredPort = findConnectPort(appContext, saved.host)
if (discoveredPort != null && discoveredPort != cachedPort) {
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
} else {
cachedResult.getOrThrow()
}
} else {
val discoveredPort = findConnectPort(appContext, saved.host)
?: error("Enter the current Wireless debugging IP address & Port")
setChildProcessLimit(appContext, saved.host, discoveredPort, enabled)
}
}
}
@@ -0,0 +1,270 @@
package com.droiddeck.launcher.core
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.RemoteInput
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class WirelessAdbPairingService : Service() {
sealed interface Stage {
data object Idle : Stage
data object Waiting : Stage
data class CodeNeeded(val error: String? = null) : Stage
data class Working(val step: String) : Stage
data object Done : Stage
data class Failed(val error: String) : Stage
}
private val main = Handler(Looper.getMainLooper())
private var nsd: NsdManager? = null
private var discovery: NsdManager.DiscoveryListener? = null
private var pairingHost: String? = null
private var pairingPort: Int? = null
@Volatile private var working = false
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CODE -> {
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
?.filter(Char::isDigit)?.toString().orEmpty()
onCode(code)
}
ACTION_CANCEL -> finish(Stage.Idle)
else -> begin()
}
return START_NOT_STICKY
}
private fun begin() {
pairingHost = null
pairingPort = null
working = false
publish(Stage.Waiting)
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
startDiscovery()
main.removeCallbacks(timeout)
main.postDelayed(timeout, TIMEOUT_MS)
}
private fun startDiscovery() {
stopDiscovery()
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
.onSuccess { nsd = manager; discovery = listener }
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
}
private fun resolveListener() = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host ?: return
if (!WirelessAdbFix.isLocalAddress(address)) return
main.post {
if (working || current.value == Stage.Done) return@post
pairingHost = address.hostAddress
pairingPort = serviceInfo.port
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
}
}
}
private fun onCode(code: String) {
val host = pairingHost
val port = pairingPort
if (working) return
if (host == null || port == null) {
show(Stage.Waiting)
return
}
if (code.length != 6) {
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
return
}
working = true
show(Stage.Working("Pairing…"))
Thread({
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
if (paired.isFailure) {
main.post {
working = false
pairingPort = null
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
}
return@Thread
}
main.post {
stopDiscovery()
show(Stage.Working("Paired. Connecting…"))
}
val result = runCatching {
val connectPort = WirelessAdbFix.localConnectPort(this)
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
main.post { show(Stage.Working("Applying the setting…")) }
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
}
main.post {
working = false
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
}
}, "wireless-adb-pairing").start()
}
private fun show(stage: Stage) {
publish(stage)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
}
private fun finish(stage: Stage) {
main.removeCallbacks(timeout)
stopDiscovery()
publish(stage)
val manager = getSystemService(NotificationManager::class.java)
if (stage is Stage.Done || stage is Stage.Failed) {
manager?.notify(NOTIFICATION_ID, notification(stage))
stopForeground(STOP_FOREGROUND_DETACH)
} else {
stopForeground(STOP_FOREGROUND_REMOVE)
manager?.cancel(NOTIFICATION_ID)
}
stopSelf()
}
private fun stopDiscovery() {
val listener = discovery ?: return
runCatching { nsd?.stopServiceDiscovery(listener) }
discovery = null
}
override fun onDestroy() {
main.removeCallbacks(timeout)
stopDiscovery()
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
super.onDestroy()
}
private fun notification(stage: Stage): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
NotificationManager.IMPORTANCE_HIGH).apply {
description = "Asks for the Wireless debugging pairing code while Settings is open"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
val cancel = PendingIntent.getService(this, 1,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
val builder = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentIntent(open)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
when (stage) {
Stage.Waiting, Stage.Idle -> builder
.setContentTitle("Pair Wireless debugging")
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
.setStyle(Notification.BigTextStyle().bigText(
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
is Stage.CodeNeeded -> {
val reply = PendingIntent.getService(this, 2,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else PendingIntent.FLAG_UPDATE_CURRENT)
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
builder
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
}
is Stage.Working -> builder
.setContentTitle("Setting up Steam")
.setContentText(stage.step)
.setProgress(0, 0, true)
.setOngoing(true)
.setOnlyAlertOnce(true)
Stage.Done -> builder
.setContentTitle("Steam is ready")
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
.setAutoCancel(true)
is Stage.Failed -> builder
.setContentTitle("Wireless debugging setup stopped")
.setContentText(stage.error)
.setStyle(Notification.BigTextStyle().bigText(stage.error))
.setAutoCancel(true)
}
return builder.build()
}
companion object {
private const val CHANNEL_ID = "wireless-adb-pairing"
private const val NOTIFICATION_ID = 3
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
private const val KEY_CODE = "code"
private const val TIMEOUT_MS = 5 * 60_000L
private val current = MutableStateFlow<Stage>(Stage.Idle)
val stage: StateFlow<Stage> = current
private fun publish(stage: Stage) { current.value = stage }
fun start(context: Context) {
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
publish(Stage.Waiting)
val intent = Intent(context, WirelessAdbPairingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun cancel(context: Context) {
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
publish(Stage.Idle)
return
}
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
}
fun notificationsEnabled(context: Context): Boolean =
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
}
}
@@ -249,7 +249,7 @@ fun CollapsibleRail(
TextButton(onClick = link.onClick, modifier = Modifier.padding(start = 4.dp)) {
Icon(link.icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp))
Spacer(Modifier.width(6.dp))
Text(link.label, fontSize = 11.sp)
Text(link.label, fontSize = 12.sp)
}
}
}
@@ -269,7 +269,7 @@ fun CollapsibleRail(
Text(
section.header,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
fontSize = 9.sp,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
modifier = Modifier.padding(start = 14.dp, top = 10.dp, bottom = 2.dp),
@@ -0,0 +1,93 @@
package com.droiddeck.launcher.files
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import java.io.File
// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr).
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
internal data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
internal fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
internal fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.droiddeck.launcher.ui.DroidDeckTheme
import java.io.File
/** The File Manager as a screen of its own, from the Files tile on the main screen. */
class FileManagerActivity : ComponentActivity() {
@@ -19,10 +20,11 @@ class FileManagerActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen()
FileManagerScreen(initialDir = initialDir)
}
}
}
@@ -32,4 +34,8 @@ class FileManagerActivity : ComponentActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) recreate()
}
companion object {
const val EXTRA_START_DIR = "com.droiddeck.launcher.files.START_DIR"
}
}
@@ -0,0 +1,599 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's rows, tiles, favourites and context menu.
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
internal fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.style.TextOverflow
import java.io.File
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
internal fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
internal fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
@@ -7,12 +7,7 @@ import androidx.compose.foundation.Image
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
private fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
private fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
private fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
private data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
private fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
private fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1030,7 +813,7 @@ fun FileManagerScreen(
// when it doesn't fit we drop the /storage/emulated/0 prefix, not the tail.
text = elidePathStart(currentDir.absolutePath, 52),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontSize = 12.sp,
maxLines = 1,
// fill = true: the path takes all remaining width, so the free-space figure
// is pinned to the right edge instead of sliding around with the path length.
@@ -1040,7 +823,7 @@ fun FileManagerScreen(
Text(
"${FileOps.formatBytes(freeSpace)} free",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontSize = 12.sp,
maxLines = 1,
)
}
@@ -1414,675 +1197,3 @@ fun FileManagerScreen(
} // end rail + content Row
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
private fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 11.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
private fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.files
import android.widget.Toast
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's properties dialog.
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
internal fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -1,24 +1,15 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.zip.CRC32
/**
* The user's own Windows games, added to the Steam client's library.
*
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
* is the .exe in it that looks most like the game - the one named after the folder, else the
* largest, never an installer, redistributable or crash reporter - unless the user picked one
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
* with the appid the client itself would derive, so launching from the rail and from the
* client's own library are the same thing. Any number of Games folders, from anywhere on the
* device: each is bound into the session under /root/Games on its own.
*/
/** Windows game folders shared with the Steam session. */
object AddedGames {
private const val TAG = "AddedGames"
@@ -29,6 +20,7 @@ object AddedGames {
/** What steam://rungameid/ takes for a shortcut. */
val gameId: Long,
val candidates: List<File>,
val steamAppId: Int? = null,
) {
fun folderName(): String = folder.name
}
@@ -96,12 +88,15 @@ object AddedGames {
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
for (root in roots(context)) {
val dir = root.host
val folders = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
for (dir in folders.distinctBy { it.absolutePath }) {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
}
return out
return out.distinctBy { it.folder.canonicalPath }
}
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
@@ -115,10 +110,23 @@ object AddedGames {
val name = folder.name
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
val appId = crc or 0x80000000L
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
}
}
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
val users = File(root, "config/loginusers.vdf").readText()
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
?: return@runCatching null
val account = recent.groupValues[1].toLong() - 76561197960265728L
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
.optInt(appId.toString()).takeIf { it > 0 }
}.getOrNull()
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
fun writeListing(context: Context, games: List<Game>): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
@@ -126,6 +134,7 @@ object AddedGames {
games.forEachIndexed { i, g ->
if (i > 0) json.append(',')
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
@@ -145,5 +154,5 @@ object AddedGames {
return guestPath(context, file)
}
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun quote(s: String): String = JSONObject.quote(s)
}
@@ -14,7 +14,18 @@ import java.io.File
*/
object Library {
/** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong())
/**
* [art] is the portrait capsule; [hero] the wide banner Steam shows above a game's page, when
* the client has cached one. [lastPlayed] is Steam's own "LastPlayed" (unix seconds), 0 = never.
*/
class SteamGame(
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
const val ADDED = "added"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
@@ -55,16 +66,35 @@ object Library {
4690330, // Legacy Steam Runtime
)
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
private val STEAM_HEROES = listOf("library_hero.jpg")
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
/** Steam's library roots visible to this launcher: its private default plus the selected library. */
private fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
}
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first })
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
return roots.asSequence()
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
val libraries = steamLibraries(context)
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
@@ -77,19 +107,31 @@ object Library {
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
// StateFlags 4 = fully installed; anything else is downloading, updating or broken.
if (name.isEmpty() || flags and 4 == 0) return@forEach
val art = steamCapsule(cache, appId)
out[appId] = SteamGame(appId, name, art, label)
val art = steamCacheImage(cache, appId, STEAM_CAPSULES)
val hero = steamCacheImage(cache, appId, STEAM_HEROES)
val lastPlayed = LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
// App manifests are Valve KeyValues (VDF) files. installdir is one folder
// below steamapps/common; only expose it when the directory exists and the
// manifest value cannot escape that directory.
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
out[appId] = SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
)
}
}
return out.values.toList()
}
/** Steam stores current library capsules inside hash-named folders under the app's cache dir. */
private fun steamCapsule(cache: File, appId: Int): File? {
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
val appDir = File(cache, appId.toString())
val dirs = listOf(appDir) + appDir.listFiles()
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
return STEAM_CAPSULES.asSequence()
return names.asSequence()
.flatMap { name -> dirs.asSequence().map { File(it, name) } }
.firstOrNull { it.isFile && it.length() > 0L }
}
@@ -124,7 +166,13 @@ object Library {
)
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
fun nameForProgram(program: String?): String? =
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
} else specs.firstOrNull { it.program == program }?.name
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
/**
* Whether a program from the rail runs at the panel's own resolution rather than the session's
@@ -278,7 +326,9 @@ object Library {
"-C", "Dolphin.General.HotkeysRequireFocus=False", "-C", "Dolphin.Input.BackgroundInput=True",
"-e", guestPath,
)
"cemu" -> listOf("-g", guestPath)
// Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started
// dialog away (bannerlator-pad-defaults).
"cemu" -> listOf("-f", "-g", guestPath)
// Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and
// comes back (HK_FullscreenToggle, bannerlator-pad-defaults).
"melonds" -> listOf("-f", guestPath)
@@ -40,9 +40,11 @@ object FrameGen {
}
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
fun label(context: Context): String = when (engine(context)) {
ENGINE_WINFG -> "Win-FG ${multiplier(context)}×"
ENGINE_LSFG -> "LSFG ${multiplier(context)}×"
fun label(context: Context): String = label(engine(context), multiplier(context))
fun label(engine: String, multiplier: Int): String = when (engine) {
ENGINE_WINFG -> "Win-FG $multiplier×"
ENGINE_LSFG -> "LSFG $multiplier×"
else -> "Off"
}
@@ -82,47 +84,5 @@ object FrameGen {
return problem
}
/**
* Off, or an engine at 2x/3x/4x; saved on pick, then [onChanged]. The LSFG rows are greyed
* and unpickable until Lossless Scaling is installed in the Steam client - the engine cannot
* run without its DLL, so offering it would only produce a setting that silently does nothing.
*/
fun showPicker(activity: android.app.Activity, onChanged: () -> Unit) {
val lsfgReady = LsfgNative.isInstalled(activity)
val lsfgNote = if (lsfgReady) "" else " - install Lossless Scaling in Steam"
val choices = listOf("Off",
"Win-FG 2×", "Win-FG 3×", "Win-FG 4×",
"LSFG 2×$lsfgNote", "LSFG 3×$lsfgNote", "LSFG 4×$lsfgNote")
val current = when (engine(activity)) {
ENGINE_WINFG -> multiplier(activity) - 1
ENGINE_LSFG -> if (lsfgReady) multiplier(activity) + 2 else 0
else -> 0
}
val adapter = object : android.widget.ArrayAdapter<String>(
activity, android.R.layout.simple_list_item_single_choice, choices) {
override fun isEnabled(position: Int) = lsfgReady || position < 4
override fun areAllItemsEnabled() = lsfgReady
override fun getView(position: Int, convertView: android.view.View?, parent: android.view.ViewGroup): android.view.View {
val view = super.getView(position, convertView, parent)
view.isEnabled = isEnabled(position)
view.alpha = if (isEnabled(position)) 1f else 0.38f
return view
}
}
android.app.AlertDialog.Builder(activity)
.setTitle(com.droiddeck.launcher.R.string.frame_gen_title)
.setSingleChoiceItems(adapter, current) { dialog, which ->
when (which) {
0 -> set(activity, ENGINE_OFF, 2)
in 1..3 -> set(activity, ENGINE_WINFG, which + 1)
else -> set(activity, ENGINE_LSFG, which - 2)
}
dialog.dismiss()
onChanged()
}
.setNeutralButton(android.R.string.ok, null)
.show()
}
private const val TAG = "FrameGen"
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONArray
@@ -27,7 +28,9 @@ object TurnipReleases {
private const val KEY_DOWNLOADS = "downloads"
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
val sha256: String = "")
/** [latest] = each source's newest release tag, for the refresh line. */
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
@@ -89,11 +92,13 @@ object TurnipReleases {
val a = list.getJSONObject(j)
val name = a.getString("name")
val (linux, label) = src.classify(name, tag) ?: continue
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
// Newest first: the first release carrying a variant is the one offered.
if (!seen.add("$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label))
.put("linux", linux).put("label", label).put("sha256", sha))
}
}
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
@@ -131,7 +136,7 @@ object TurnipReleases {
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
}
val latest = ArrayList<Pair<String, String>>()
val l = json.optJSONArray("latest") ?: JSONArray()
@@ -167,6 +172,7 @@ object TurnipReleases {
/** Download an asset into the cache; the caller imports it and deletes the file. */
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
val target = File(context.cacheDir, asset.name)
val c = URL(asset.url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
@@ -191,6 +197,9 @@ object TurnipReleases {
}
}
}
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("Checksum mismatch - the download was discarded")
}
return target
} catch (e: Exception) {
FileUtils.delete(target)
@@ -25,6 +25,7 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.onSignal
import com.droiddeck.launcher.ui.LocalPalette
class ControllerEditorActivity : ComponentActivity() {
@@ -47,7 +48,7 @@ class ControllerEditorActivity : ComponentActivity() {
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), RoundedCornerShape(50))
.padding(start = 14.dp, end = 5.dp, top = 5.dp, bottom = 5.dp),
) {
Text("Drag to move", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
Text("Drag to move", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
EditorButton("Reset", false) { controls.resetLayout() }
EditorButton("Cancel", false) { finish() }
EditorButton("Save", true) {
@@ -67,7 +68,7 @@ class ControllerEditorActivity : ComponentActivity() {
val pal = LocalPalette.current
Text(
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = if (primary) Color.White else MaterialTheme.colorScheme.onBackground,
color = if (primary) pal.onSignal else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.clip(RoundedCornerShape(50))
.background(if (primary) pal.signal else Color.White.copy(alpha = 0.06f))
.clickable(onClick = onClick)
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.input
/**
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
*
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
* position, a pad clicked also reports full pressure.
*/
object DeckControls {
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
const val L4 = 1
const val R4 = 2
const val L5 = 4
const val R5 = 8
private const val LEFT_TOUCH = 16
private const val RIGHT_TOUCH = 32
private const val LEFT_CLICK = 64
private const val RIGHT_CLICK = 128
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
private var controls = 0
private val pads = ShortArray(4)
private val pressure = ShortArray(2)
@Synchronized
fun setGrip(bit: Int, down: Boolean) {
set(bit, down)
publish()
}
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
val at = if (right) 2 else 0
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
pads[at] = if (touching) axis(x) else 0
pads[at + 1] = if (touching) axis(y) else 0
publish()
}
@Synchronized
fun setClick(right: Boolean, down: Boolean) {
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
publish()
}
/** Everything let go - for when the panel goes away with a finger still on it. */
@Synchronized
fun releaseAll() {
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
controls = 0
pads.fill(0)
pressure.fill(0)
publish()
}
private fun set(bit: Int, on: Boolean) {
controls = if (on) controls or bit else controls and bit.inv()
}
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
}
@@ -67,6 +67,9 @@ public class FakeInputWriter {
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
* button is not, because PadState keeps 10 and 11 for the triggers. */
private static final int SNAPSHOT_IDX_MODE = 10;
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
* libfakeinput's Deck controller reads it from here. */
private static final int SNAPSHOT_IDX_QAM = 11;
private static final int EVENT_SIZE = 24;
static final int MAX_EVENTS_PER_UPDATE = 32;
@@ -92,6 +95,18 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
private static final int RING_FILE_SIZE = RING_SIZE + 64;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -110,6 +125,7 @@ public class FakeInputWriter {
private volatile boolean destroyed = false;
private final boolean[] prevButtonStates = new boolean[12];
private boolean prevQam;
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
private int prevTriggerL, prevTriggerR;
private int prevHatX, prevHatY;
@@ -217,30 +233,40 @@ public class FakeInputWriter {
for (int i = 0; i < 8; i++) {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
private static void releaseRingSlotLocked(int slot) {
RingSlot ringSlot = RING_SLOTS[slot];
if (ringSlot == null) {
return;
}
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
synchronized (ringSlot) {
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
}
ringSlot.ringChannel = null;
}
ringSlot.ringChannel = null;
}
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
RING_SLOTS[slot] = null;
}
@@ -289,9 +315,9 @@ public class FakeInputWriter {
FileChannel channel = null;
try {
raf = new RandomAccessFile(ringFile, "rw");
raf.setLength(RING_SIZE);
raf.setLength(RING_FILE_SIZE);
channel = raf.getChannel();
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
initializeRingHeader(data);
RingSlot ringSlot = new RingSlot();
@@ -332,6 +358,9 @@ public class FakeInputWriter {
return false;
}
synchronized (ringSlot) {
if (ringSlot.data == null) {
return false;
}
if (!ringSlot.active) {
if (ringSlot.everActivated) {
ringSlot.generation++;
@@ -381,6 +410,9 @@ public class FakeInputWriter {
ByteBuffer source = this.buffer.duplicate();
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) {
return false;
}
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
int sourceLimit = source.limit();
while (source.remaining() >= EVENT_SIZE) {
@@ -416,6 +448,9 @@ public class FakeInputWriter {
buttons |= (1 << i);
}
}
if (this.prevQam) {
buttons |= (1 << SNAPSHOT_IDX_QAM);
}
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
@@ -434,6 +469,58 @@ public class FakeInputWriter {
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
}
/**
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
* controller's axes and units (see PadMotion). Safe from any thread.
*/
public static void writeMotion(int slot, short[] accel, short[] gyro) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
for (int i = 0; i < 3; i++) {
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
}
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
*/
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
nativeStoreFence();
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
ring.putInt(DECK_CONTROLS_OFFSET, controls);
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -512,6 +599,11 @@ public class FakeInputWriter {
}
}
if (prevQam) {
prevQam = false;
hasChanges = true;
}
// Zero all axes
if (prevThumbLX != 0) {
prevThumbLX = 0;
@@ -620,6 +712,11 @@ public class FakeInputWriter {
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
writeButton(SNAPSHOT_IDX_MODE,
state.isDown(PadState.GUIDE));
boolean qam = state.isDown(PadState.QAM);
if (forceResend || qam != prevQam) {
prevQam = qam;
hasChanges = true;
}
// Sticks
int lx = (int) (state.leftX * 32767);
@@ -45,11 +45,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
fun contains(x: Float, y: Float, r: Float): Boolean {
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
val dx = x - cx
val dy = y - cy
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
val hit = r * 1.25f
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
val hit = r * 1.25f + padding
return dx * dx + dy * dy <= hit * hit
}
@@ -350,6 +350,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
override fun onDraw(canvas: Canvas) {
for (control in controls) {
if (!isVisible(control)) continue
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
val held = control.pressedBy != -1
val radius = control.radius
if (control.stick >= 0) {
@@ -426,10 +427,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
val x = event.getX(index)
val y = event.getY(index)
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
if (control.pressedBy != -1) return true
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
@@ -468,7 +472,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
control.pressedBy = -1
control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
@@ -505,7 +509,25 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
controls.firstOrNull {
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
}
private fun adaptiveStickAt(x: Float, y: Float): Control? {
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
return controls.filter {
val dx = x - it.cx
val dy = y - it.cy
val reach = it.radius * 1.4f * sqrt(1.5f)
it.stick >= 0 && isVisible(it) && it.pressedBy == -1 && dx * dx + dy * dy <= reach * reach
}.minByOrNull {
val dx = x - it.cx
val dy = y - it.cy
dx * dx + dy * dy
}
}
private fun isVisible(control: Control): Boolean = when {
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
@@ -574,6 +596,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
controls.forEach {
it.pressedBy = -1
it.clicked = false
it.lastUp = 0L
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
it.kx = 0f; it.ky = 0f
}
@@ -7,6 +7,8 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.droiddeck.launcher.session.SessionState;
import java.io.File;
/**
@@ -19,16 +21,34 @@ import java.io.File;
* SDL → Big Picture.
*
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
* mapping without the user configuring anything.
* mapping without the user configuring anything. In a Steam session it is a Steam Deck controller
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
* Guide-then-A chord the client itself answers.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
private static final int SLOT = 0;
private static final float DEAD_ZONE = 0.12f;
private static final long QAM_GUIDE_LEAD_MS = 80;
private static final long QAM_A_HOLD_MS = 120;
private static final long QAM_GUIDE_TAIL_MS = 40;
// The client takes A as part of the chord only once it has had Guide held for a while, and a
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
// none in 20; with the session down to 1 fps a fixed 400 ms still let 3 in 15 through, where
// 1000 ms let none. The lead keeps 80 ms when frames are quick, for a QAM that feels immediate,
// and stretches to eight frames when they are slow.
private static final long QAM_GUIDE_LEAD_MIN_MS = 80;
private static final long QAM_GUIDE_LEAD_MAX_MS = 1500;
private static final int QAM_GUIDE_LEAD_FRAMES = 8;
private static final long QAM_A_HOLD_MS = 200;
private static final long QAM_GUIDE_TAIL_MS = 200;
// Off the main thread, so a busy UI cannot shorten or stretch the steps.
private static final Handler chordHandler;
static {
android.os.HandlerThread thread = new android.os.HandlerThread("qam-chord", android.os.Process.THREAD_PRIORITY_DISPLAY);
thread.start();
chordHandler = new Handler(thread.getLooper());
}
private final FakeInputWriter writer;
private final PadState state = new PadState();
@@ -39,6 +59,8 @@ public final class PadBridge {
private boolean systemQamPressed;
private boolean qamChordActive;
private boolean qamSyntheticAPressed;
/** The Deck controller's Quick Access button, held for one tap. */
private boolean qamTapPressed;
private int qamChordGeneration;
/** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */
private volatile Runnable onPlayerInput;
@@ -62,6 +84,7 @@ public final class PadBridge {
systemQamPressed = false;
qamChordActive = false;
qamSyntheticAPressed = false;
qamTapPressed = false;
qamChordGeneration++;
if (open) {
state.clear();
@@ -189,7 +212,8 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
if (qamStarted) startQamChord();
// A Deck controller's QAM button is held for as long as the touch one is.
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
else publish();
}
@@ -202,9 +226,23 @@ public final class PadBridge {
publish();
}
/** Starts the same Guide-then-A sequence used by the touch QAM button. */
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
public synchronized void triggerQam() {
startQamChord();
if (!SessionState.getDeckPad()) {
startQamChord();
return;
}
if (qamTapPressed) return;
qamTapPressed = true;
int generation = qamChordGeneration;
publish();
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamTap(int generation) {
if (generation != qamChordGeneration || !qamTapPressed) return;
qamTapPressed = false;
publish();
}
private void startQamChord() {
@@ -212,21 +250,27 @@ public final class PadBridge {
qamChordActive = true;
int generation = ++qamChordGeneration;
publish();
mainHandler.postDelayed(() -> pressQamA(generation), QAM_GUIDE_LEAD_MS);
chordHandler.postDelayed(() -> pressQamA(generation), qamGuideLeadMs());
}
static long qamGuideLeadMs() {
long frame = com.droiddeck.launcher.wayland.WaylandCompositor.recentFrameIntervalMs();
if (frame <= 0) return QAM_GUIDE_LEAD_MIN_MS;
return Math.max(QAM_GUIDE_LEAD_MIN_MS, Math.min(QAM_GUIDE_LEAD_MAX_MS, frame * QAM_GUIDE_LEAD_FRAMES));
}
private synchronized void pressQamA(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = true;
publish();
mainHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
chordHandler.postDelayed(() -> releaseQamA(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamA(int generation) {
if (generation != qamChordGeneration || !qamChordActive) return;
qamSyntheticAPressed = false;
publish();
mainHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
chordHandler.postDelayed(() -> releaseQamGuide(generation), QAM_GUIDE_TAIL_MS);
}
private synchronized void releaseQamGuide(int generation) {
@@ -237,16 +281,17 @@ public final class PadBridge {
private void publish() {
if (!open && !start()) return;
if (systemGuidePressed || qamChordActive) {
effectiveState.copyFrom(state);
effectiveState.press(PadState.GUIDE, true);
if (qamSyntheticAPressed) {
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
}
writer.writePad(effectiveState);
} else {
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
writer.writePad(state);
return;
}
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
writer.writePad(effectiveState);
}
private static float axis(MotionEvent event, int axis) {
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import kotlin.math.roundToInt
/**
* The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad).
*
* The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android
* reports them in the device's natural orientation; they are turned to the screen as it is shown
* (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
private var thread: HandlerThread? = null
/** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */
@Volatile private var active = false
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
fun start() {
if (thread != null || sensors == null) return
val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (gyroscope == null && accelerometer == null) {
Log.i(TAG, "pad motion: this device has no gyro or accelerometer")
return
}
val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() }
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
fun stop() {
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
worker.quitSafely()
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
// Natural orientation to the screen's: Android's display-rotation transform.
val (x, y) = when (rotation()) {
Surface.ROTATION_90 -> -values[1] to values[0]
Surface.ROTATION_180 -> -values[0] to -values[1]
Surface.ROTATION_270 -> values[1] to -values[0]
else -> values[0] to values[1]
}
val z = values[2]
val (target, scale) = when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
target[0] = counts(x * scale)
target[1] = counts(-z * scale)
target[2] = counts(y * scale)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort()
companion object {
private const val TAG = "PadMotion"
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}
}
@@ -57,5 +57,7 @@ class PadState {
const val R3 = 9
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
const val GUIDE = 12
/** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */
const val QAM = 13
}
}
@@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -0,0 +1,181 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
/**
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
*
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
*/
object AppImageManager {
private const val TAG = "AppImageManager"
const val GUEST_DIR = "/opt/appimages/user"
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
val guestDir: String get() = "$GUEST_DIR/$id"
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
?.map { dir ->
Item(
dir.name,
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
/**
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
else -> null
}
}
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
internal fun idFor(fileName: String, taken: Set<String>): String {
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
var id = base
var n = 2
while (id in taken) id = "$base-${n++}"
return id
}
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
}
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
val dir = File(base, id)
val image = File(dir, "image.AppImage")
try {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
val guestDir = "$GUEST_DIR/$id"
val out = StringBuilder()
val status = GuestCommand.run(context, listOf(
"/bin/bash", "-c",
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
"extract", guestDir,
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
FileUtils.delete(dir)
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
}
image.delete()
describe(dir, file)
writeMenuEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
} finally {
image.delete()
}
}
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
private fun describe(dir: File, source: File) {
val app = File(dir, "app")
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
val text = entry?.let { FileUtils.readString(it) }
fun key(k: String) = text?.let { desktopKey(it, k) }
val name = key("Name") ?: source.name.substringBeforeLast('.')
FileUtils.writeString(File(dir, "name"), name)
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
val iconName = key("Icon")
val candidates = listOfNotNull(
iconName?.let { File(app, "$it.png") },
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
File(app, ".DirIcon"),
)
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
}
private fun isPng(f: File): Boolean = try {
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
} catch (e: Exception) { false }
/** A key of a desktop entry's main group, unlocalised. */
internal fun desktopKey(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
private fun menuEntry(context: Context, id: String) =
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
private fun writeMenuEntry(context: Context, dir: File) {
val id = dir.name
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
FileUtils.writeString(menuEntry(context, id),
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
menuEntry(context, id).delete()
}
private object Files {
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
}
}
@@ -0,0 +1,424 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.net.LocalServerSocket
import android.net.LocalSocket
import android.os.Process
import android.system.Os
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicInteger
/**
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap).
*
* A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount
* namespaces Android gives no app. proot can show the same picture, and started here - beside the
* session's proot rather than inside it - it traces the app once instead of twice. The stand-in
* sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths),
* this builds it under the cache and starts it, then streams the output, the pid and the exit
* status back until one side goes away: the stand-in ending takes the sandbox down with it.
*
* The socket is abstract, so anything on the device could connect: only this app's own uid - the
* uid every program in the runtime already runs as - is served.
*/
object BwrapSpawner {
private const val TAG = "BwrapSpawner"
const val SOCKET = "com.droiddeck.launcher.bwrap"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
/** Listens for sandboxes for the rest of the process's life; later calls do nothing. */
@Synchronized
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val socket = try {
LocalServerSocket(SOCKET)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $SOCKET", e)
return
}
server = socket
FileUtils.delete(File(app.cacheDir, "bwrap"))
Thread({
while (true) {
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() }
}
}, "bwrap-server").apply { isDaemon = true; start() }
}
private fun serve(context: Context, client: LocalSocket) {
val id = seq.incrementAndGet()
val dir = File(context.cacheDir, "bwrap/$id")
client.use {
val out = DataOutputStream(client.outputStream)
try {
val peer = client.peerCredentials
if (peer.uid != Process.myUid()) {
Log.w(TAG, "refused a sandbox for uid ${peer.uid}")
return
}
val input = DataInputStream(client.inputStream)
val kind = input.readUnsignedByte()
val body = ByteArray(input.readInt()).also { input.readFully(it) }
if (kind != 'R'.code) return
val request = JSONObject(String(body, Charsets.UTF_8))
val plan = plan(context, request, dir)
run(context, plan, client, out, id)
} catch (e: Exception) {
Log.e(TAG, "sandbox $id", e)
runCatching {
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
frame(out, 'X', "1".toByteArray())
}
} finally {
FileUtils.delete(dir)
}
}
}
/** What proot is started with: the sandbox root, its binds (host to guest), and the program. */
class Plan(val root: File, val binds: List<Bind>, val cwd: String, val argv: List<String>, val env: Map<String, String>)
data class Bind(val host: String, val guest: String) {
val spec: String get() = if (host == guest) host else "$host:$guest"
companion object {
fun parse(spec: String): Bind {
val colon = spec.indexOf(':')
return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1))
}
}
}
/** True when [path] is [dir] or inside it. */
internal fun under(path: String, dir: String): Boolean =
dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/")
/**
* Where a path the session's programs see lives on the host: through the session's own binds
* (the most specific one), or else inside the rootfs.
*/
internal fun hostPath(sessionBinds: List<Bind>, rootfs: String, guest: String): String {
val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length }
?: return rootfs.trimEnd('/') + guest
val rest = guest.substring(bind.guest.length).trimStart('/')
return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest
}
/**
* Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved
* under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what
* Android keeps apps from reading.
*/
internal fun bindsFor(sessionBinds: List<Bind>, rootfs: String, src: String, dest: String): List<Bind> {
val out = ArrayList<Bind>()
out.add(Bind(hostPath(sessionBinds, rootfs, src), dest))
for (b in sessionBinds.sortedBy { it.guest.length }) {
if (b.guest != src && under(b.guest, src)) {
out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/'))))
}
}
return out
}
internal fun plan(context: Context, request: JSONObject, dir: File): Plan {
val rootfs = LinuxRuntime.rootDir(context).path
val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) }
val root = File(dir, "root").apply { mkdirs() }
val binds = ArrayList<Bind>()
var scratch = 0
fun addBind(b: Bind) {
binds.removeAll { it.guest == b.guest }
binds.add(b)
}
fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length }
fun newScratch(): File = File(dir, "x${scratch++}")
/**
* Where [dest] can be created on the host: inside the sandbox root, or inside a directory
* of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the
* GL extension's links). Null inside anything else bound in - the runtime itself, a host
* directory - which must not be written to.
*/
fun writable(dest: String): File? {
val cover = coveringBind(dest) ?: return inRoot(root, dest)
if (!under(cover.host, dir.path)) return null
return inRoot(File(cover.host), dest.substring(cover.guest.length))
}
val ops = request.getJSONArray("ops")
for (i in 0 until ops.length()) {
val op = ops.getJSONObject(i)
val dest = op.optString("dest")
when (op.getString("t")) {
"bind" -> {
val src = op.getString("src")
val all = bindsFor(session, rootfs, src, dest)
if (!File(all.first().host).exists()) {
if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out")
continue
}
all.forEach { addBind(it) }
}
"proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) }
"dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) }
"dir" -> {
val perms = op.optInt("perms", 0x1ed)
val here = writable(dest)
when {
op.optBoolean("empty") && coveringBind(dest) != null ->
addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms)
File(hostPath(binds, root.path, dest)).isDirectory -> {}
else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
}
}
"file" -> {
val data = Base64.decode(op.getString("data"), Base64.DEFAULT)
val here = writable(dest)
val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here
else newScratch().also { addBind(Bind(it.path, dest)) }
f.writeBytes(data)
chmod(f, op.optInt("perms", 0x1b6))
}
"symlink" -> {
val target = op.getString("target")
val link = writable(dest)
if (link != null) {
link.parentFile?.mkdirs()
if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target))
} else {
// A link inside the runtime's own tree cannot be made; what it points at is
// bound in its place.
val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target)
val host = hostPath(binds, root.path, abs)
if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out")
}
}
}
}
if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) }
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
val env = LinkedHashMap<String, String>()
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
}
/** Where a sandbox finds the runtime's own GPU driver. */
private const val GPU_DIR = "/run/droiddeck-gpu"
/**
* The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM
* kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The
* runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks
* but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's
* own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone.
*/
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
val lib = File(rootfs, "usr/lib")
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
}
// Beside the sandbox root, in the sandbox's own directory: removed with it.
val icd = File(root.parentFile, "freedreno_icd.json")
icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n")
binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json"))
env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json"
env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json"
if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) {
env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink"
env["GALLIUM_DRIVER"] = "zink"
}
// The driver's own libraries, found beside it; nothing else is in that directory.
env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":")
return binds
}
/**
* What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the
* session shim - SysV shared memory for X11, the network calls Android refuses - and the
* controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through
* LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app
* saw no gamepad. Both are built against an older glibc than any Flathub runtime has.
*/
private fun preload(session: List<Bind>, rootfs: String, env: MutableMap<String, String>): List<Bind> {
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList()
if (libs.isEmpty()) return emptyList()
val binds = ArrayList<Bind>()
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
// Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then
// hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start.
// The controllers: FAKE_EVDEV_DIR is <session>/dev/input, the rings beside it in <session>/dev.
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
binds.addAll(bindsFor(session, rootfs, dev, dev))
}
return binds
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
/**
* Browsers sandbox their own children, and neither way works in here. Firefox wants
* namespaces and seccomp from the kernel; its content processes died without them (the
* desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start
* their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses:
* it knows a Flatpak by /proc/<pid>/root/.flatpak-info, and a proot sandbox's root is the
* host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead.
*/
private fun browserSandboxes(env: MutableMap<String, String>) {
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
// Chromium on Wayland asks the render node the compositor names for its DRM version;
// KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't
// usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
env.putIfAbsent(name, "1")
}
}
/**
* /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have
* SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw
* with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first
* shader compiled killed the app with SIGILL. Without a part number LLVM targets generic
* ARMv8 plus the "Features" line, which lists what the kernel really enabled.
*/
private fun genericCpuinfo(context: Context): File? = try {
val out = File(context.cacheDir, "cpuinfo-generic")
val text = File("/proc/cpuinfo").readLines()
.filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") }
.joinToString("\n", postfix = "\n")
// Sandboxes start side by side; one must never read another's half-written copy.
if (!out.isFile || out.readText() != text) {
val staged = File.createTempFile("cpuinfo", null, context.cacheDir)
staged.writeText(text)
if (!staged.renameTo(out)) staged.delete()
}
out
} catch (e: Exception) {
Log.w(TAG, "cpuinfo", e); null
}
/** [path] with "." and ".." resolved lexically. */
internal fun normalize(path: String): String {
val out = ArrayList<String>()
for (part in path.split('/')) when (part) {
"", "." -> {}
".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1)
else -> out.add(part)
}
return "/" + out.joinToString("/")
}
/**
* [guest] inside the sandbox root, or null when the way there crosses a symlink: one of
* Flatpak's (var/run -> ../run) may point anywhere once followed on the host.
*/
private fun inRoot(root: File, guest: String): File? {
var f = root
for (part in guest.split('/').filter { it.isNotEmpty() }) {
if (part == "..") return null
f = File(f, part)
if (Files.isSymbolicLink(f.toPath())) return null
}
return f
}
private fun chmod(f: File, mode: Int) {
try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ }
}
private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) {
val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd)
// DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot
// say what it does, to logcat in full: for a sandbox that dies without a word.
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
// proot hands its own environment on; the sandbox gets exactly what Flatpak asked for.
cmd.add("/usr/bin/env")
cmd.add("-i")
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
cmd.addAll(plan.argv)
val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root)
builder.environment().apply {
clear()
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
Thread({
try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } }
catch (e: IOException) { /* closed */ }
if (process.isAlive) {
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
process.destroy()
}
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
val buffer = ByteArray(16384)
// The start of what the sandbox says goes to logcat too: a sandbox that fails at once
// (proot refusing a bind, a missing library) says why there even when nothing reads it.
var logged = 0
process.inputStream.use { stream ->
while (true) {
val n = try { stream.read(buffer) } catch (e: IOException) { -1 }
if (n < 0) break
if (logged < 4096 || verbose != null) {
String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() }
.forEach { Log.i(TAG, "sandbox $id: $it") }
logged += n
}
try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break }
}
}
val status = process.waitFor()
com.droiddeck.launcher.session.OrphanReaper.release(pid)
Log.i(TAG, "sandbox $id exited $status")
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
}
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
out.writeByte(kind.code)
out.writeInt(payload.size)
out.write(payload)
out.flush()
}
}
@@ -1,12 +1,11 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
import org.json.JSONArray
/** Optional Decky Loader installation for the ARM64 Linux guest. */
@@ -84,7 +83,7 @@ object DeckyManager {
val n = it.optString("name")
(n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://")
}?.optString("browser_download_url")?.takeIf { it.startsWith("https://") }
val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) }
val digest = binary.optString("digest").takeIf { Hashes.isGithubSha256(it) }
if (digest == null && checksum == null) continue
val size = binary.optLong("size", 0)
if (size <= 0) continue
@@ -109,7 +108,7 @@ object DeckyManager {
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
@@ -148,15 +147,6 @@ object DeckyManager {
if (wipeData) File(root, "root/homebrew").deleteRecursively()
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).buffered().use { input ->
val buffer = ByteArray(1024 * 1024)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun validElf(file: File, machine: Int): Boolean = runCatching {
file.inputStream().use { input ->
val h = ByteArray(20)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
@@ -18,6 +19,12 @@ import java.io.File
object DesktopCatalog {
private const val TAG = "DesktopCatalog"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
/**
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
* In a catalog of its own so it never shows among the packages a user picks from.
*/
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
const val PROTON_SEED_ID = "proton-arm64"
class Entry(
val id: String, val name: String, val tier: Int, val version: String, val kind: String,
@@ -26,8 +33,8 @@ object DesktopCatalog {
val icon: String, val category: String,
)
fun fetch(): List<Entry>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
fun fetch(url: String = CATALOG_URL): List<Entry>? {
val body = Downloader.downloadString(url) ?: return null
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
@@ -57,6 +64,15 @@ object DesktopCatalog {
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
/**
* True until the ARM64 Proton has been placed once. Its appmanifest counts too, so an install
* the client fetched itself is never overwritten; after the first placement the client owns it
* and updates it like any other app, and a user who removes it is not given it back.
*/
fun protonSeedNeeded(context: Context): Boolean =
installed(context, PROTON_SEED_ID) == null &&
!File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/appmanifest_4427310.acf").isFile
// labwc comes with the hosted desktop package itself (its launcher is staged by the app at
// every session, so it cannot tell whether the package is there); SessionFiles uses the same test.
fun desktopInstalled(context: Context): Boolean =
@@ -66,6 +82,8 @@ object DesktopCatalog {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
// Every catalog row carries a sha256; one without is refused rather than trusted.
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
listener?.onProgress("Downloading ${entry.name}", 0)
@@ -73,11 +91,9 @@ object DesktopCatalog {
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return "Download failed"
if (entry.sha256.isNotEmpty()) {
listener?.onProgress("Verifying", -1)
val actual = LinuxRuntimeInstaller.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
}
listener?.onProgress("Verifying", -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
listener?.onProgress("Installing ${entry.name}", -1)
when (entry.kind) {
"appimage" -> {
@@ -0,0 +1,204 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
/**
* Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed
* from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user
* installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit.
*
* Every command runs in a proot of its own, outside any session: the store works whether or not
* Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper).
*/
object FlatpakManager {
private const val TAG = "FlatpakManager"
const val FLATPAK = "/usr/bin/flatpak"
const val BWRAP = "/usr/local/bin/bannerlator-bwrap"
/** The program the front end starts a Flatpak app through, in a session of its own. */
const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run"
private const val HELPER = "/usr/local/bin/bannerlator-flatpak"
private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup"
private const val USER_DIR = "root/.local/share/flatpak"
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
@Volatile var busy: String? = null
private set
class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?)
/** Flatpak is in the runtime, with Flathub to install from. */
fun ready(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
return File(root, FLATPAK.substring(1)).isFile &&
File(root, "var/cache/droiddeck-flatpak/installed").isFile &&
(FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true)
}
private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) {
"x86_64" -> "x86_64"
else -> "aarch64"
}
/**
* The installed apps, read from the installation itself: each app's deployed files carry the
* desktop entry and icons it exports, so listing them needs no process in the runtime.
*/
fun installedApps(context: Context): List<App> {
val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app")
val arch = arch()
return apps.listFiles()?.mapNotNull { dir ->
val id = dir.name
val archDir = File(dir, arch)
val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null
val active = File(branch, "active")
val export = File(active, "export/share")
val desktop = FileUtils.readString(File(export, "applications/$id.desktop"))
val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.')
val summary = desktop?.let { entry(it, "Comment") }
App(id, name, icon(export, id), branch.name, summary)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
}
fun isInstalled(context: Context, id: String): Boolean =
File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true
/** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */
private fun entry(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
/** The largest PNG the app exports (the front end draws no SVG). */
private fun icon(export: File, id: String): File? {
val hicolor = File(export, "icons/hicolor")
return hicolor.listFiles()
?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } }
?.filter { it.second.isFile }
?.maxByOrNull { it.first }?.second
}
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
synchronized(this) {
if (busy != null) return null
busy = what
}
try { return block() } finally { busy = null }
}
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
return exclusive("setup") {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
Log.i(TAG, "setup: $line")
when {
line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1)
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
}
}
when {
failure != null -> failure
status != 0 -> "Flatpak setup failed (exit $status)"
!ready(context) -> "Flatpak setup did not finish"
else -> null
}
} ?: "Another store task is running"
}
/**
* One store operation - install, uninstall or update - with its progress as a stage line and
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
if (!ready(context)) return "Set up Flatpak first"
return exclusive("$verb:${id ?: "all"}") {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
val status = runGuest(context, argv, fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull()
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
when (o.optString("e")) {
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
}
}
error ?: if (status != 0) "Flatpak exited with status $status" else null
} ?: "Another store task is running"
}
private fun stage(o: JSONObject): String {
val ref = o.optString("ref")
val name = refLabel(ref)
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
return when (o.optString("kind")) {
"uninstall" -> "Removing $name$step"
"update" -> "Updating $name$step"
else -> "Installing $name$step"
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
else -> id.substringAfterLast('.')
}
}
/** The whole transaction's percentage: steps done, plus this one's share. */
private fun overall(o: JSONObject, percent: Int): Int {
val n = o.optInt("n").coerceAtLeast(1)
val i = o.optInt("i").coerceIn(1, n)
return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n)
}
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
/** Installed apps with an update on Flathub, or null when that could not be checked. */
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = null
exclusive("updates") {
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
if (o.optString("e") == "updates") {
val a = o.optJSONArray("apps")
apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet()
}
}
}
return apps
}
}
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
/**
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
*/
object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
// The resolver otherwise names the DNS of whatever network the last session was on.
LinuxNetworkLinkComponent(context, root).publish()
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
OrphanReaper.release(pid)
}
}
}
@@ -6,6 +6,7 @@ import android.system.ErrnoException;
import android.system.Os;
import android.system.StructStat;
import android.system.StructUtsname;
import com.droiddeck.launcher.session.SessionPrefs;
import java.io.File;
@@ -33,7 +34,6 @@ public final class LinuxRuntime {
/** Shortcut extra naming which of the modes above a Linux entry launches. */
public static final String EXTRA_LINUX_MODE = "linux_mode";
private static final String KGSL_DEVICE = "/dev/kgsl-3d0";
private static final String GUEST_HOSTNAME = "DroidDeck";
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
public static final String DEBUG_LOG_DIR = "DroidDeck";
@@ -115,22 +115,65 @@ public final class LinuxRuntime {
File externalStorage, List<String> extraBinds,
List<String> guestCommand) {
File root = rootDir(context);
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
// A session's binds are the view its programs have; a one-off command's are not recorded.
if (sessionRoot != null) lastBinds = binds;
List<String> cmd = prootPrefix(context, root, "/root");
for (String spec : binds) bind(cmd, spec);
cmd.addAll(guestCommand);
return cmd;
}
/**
* The binds of the last command built: the guest's view of the host, which the Flatpak
* sandboxes started beside a session (BwrapSpawner) translate their paths through.
*/
private static volatile List<String> lastBinds;
public static List<String> lastBinds(Context context) {
List<String> binds = lastBinds;
if (binds != null) return binds;
return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null);
}
/** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */
public static List<String> prootPrefix(Context context, File root, String cwd) {
return prootPrefix(context, root, cwd, false);
}
/**
* As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that
* refuse any other uid. Files it writes still belong to the app.
*/
public static List<String> prootPrefix(Context context, File root, String cwd, boolean fakeRoot) {
List<String> cmd = new ArrayList<>();
cmd.add(prootBinary(context).getPath());
cmd.add("--kill-on-exit");
// Preserve the host kernel identity while giving the guest the app's branded host name.
cmd.add("--kernel-release=" + guestUtsname());
cmd.add("--kernel-release=" + guestUtsname(SessionPrefs.guestHostname(context)));
// Android's app seccomp policy traps the whole set*id family. Xwayland's Popen() calls
// setgid()/setuid() before it execs xkbcomp and _exit(127)s when they fail, so without
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
// itself while still reporting our real ids, so nothing inside sees a different user.
int uid = Process.myUid();
cmd.add("-i");
cmd.add(uid + ":" + uid);
if (fakeRoot) {
cmd.add("-0");
} else {
cmd.add("-i");
cmd.add(uid + ":" + uid);
}
cmd.add("-r");
cmd.add(root.getPath());
cmd.add("-w");
cmd.add("/root");
cmd.add(cwd);
return cmd;
}
/** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
File root = rootDir(context);
List<String> cmd = new ArrayList<>();
bind(cmd, "/dev");
bind(cmd, "/proc");
bind(cmd, "/sys");
@@ -142,7 +185,7 @@ public final class LinuxRuntime {
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
bind(cmd, context.getFilesDir().getPath());
bind(cmd, context.getCacheDir().getPath());
bind(cmd, runtimeDir.getPath());
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
if (externalStorage != null && externalStorage.isDirectory()) {
bind(cmd, externalStorage.getPath());
@@ -193,11 +236,14 @@ public final class LinuxRuntime {
}
}
bindGpuNode(context, cmd);
bindAdrenoStats(cmd);
bindCpuTemps(cmd, root);
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
cmd.addAll(guestCommand);
return cmd;
List<String> specs = new ArrayList<>();
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
return specs;
}
/**
@@ -244,15 +290,91 @@ public final class LinuxRuntime {
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
}
/**
* Valve's mangoapp (Deck mode's performance overlay) reads an Adreno GPU's load, clock and
* temperatures from where they are on Valve's own hardware; every Adreno under Android keeps
* them in KGSL's sysfs, readable by the app, though which files a kernel has differs between
* Snapdragon generations - so each value takes the first source that exists, the order other
* Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left
* alone.
*/
private static void bindAdrenoStats(List<String> cmd) {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
String[][] stats = {
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"},
{firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"),
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
};
for (String[] stat : stats) {
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
}
}
/**
* mangoapp's CPU temperature is the mean of the thermal zones named cpuN-thermal or
* cpuN-top-thermal, as mainline kernels name them; Android kernels name the same sensors
* cpuss-0, cpu-1-0, cpu0-silver-usr, apc1-cpu0-usr and the like, so those zones are shown
* under the mainline name.
*/
private static void bindCpuTemps(List<String> cmd, File root) {
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
try {
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
return;
}
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
}
}
}
private static String firstReadable(String... paths) {
for (String path : paths) {
if (path != null && new File(path).canRead()) return path;
}
return null;
}
/** The temp file of the thermal zone for a sensor: the one named so, else the first whose name has it. */
private static String thermalZone(String sensor) {
java.util.Map<String, File> zones = thermalZones();
File zone = zones.get(sensor);
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(zones).entrySet()) {
if (zone == null && e.getKey().contains(sensor)) zone = e.getValue();
}
return zone != null ? new File(zone, "temp").getPath() : null;
}
/** The device's thermal zones by their sensor's name, in lower case. */
private static java.util.Map<String, File> thermalZones() {
java.util.Map<String, File> byType = new java.util.HashMap<>();
File[] zones = new File("/sys/class/thermal").listFiles((dir, n) -> n.startsWith("thermal_zone"));
if (zones == null) return byType;
for (File zone : zones) {
try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.FileReader(new File(zone, "type")))) {
byType.putIfAbsent(String.valueOf(r.readLine()).trim().toLowerCase(java.util.Locale.ROOT), zone);
} catch (IOException ignored) {
}
}
return byType;
}
private static void bind(List<String> cmd, String spec) {
cmd.add("-b");
cmd.add(spec);
}
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
private static String guestUtsname() {
static String guestUtsname(String hostname) {
StructUtsname host = Os.uname();
return "\\" + host.sysname + "\\" + GUEST_HOSTNAME + "\\" + host.release
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
}
@@ -4,7 +4,9 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.Hashes;
import com.droiddeck.launcher.core.FileUtils;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@@ -23,7 +25,8 @@ import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
/**
* Downloads and unpacks the Linux runtime rootfs into {@code files/linuxfs}. It is opt-in: nothing
@@ -52,7 +55,7 @@ public final class LinuxRuntimeInstaller {
public final String sha256;
public final long size;
Release(String version, String url, String sha256, long size) {
public Release(String version, String url, String sha256, long size) {
this.version = version;
this.url = url;
this.sha256 = sha256;
@@ -60,6 +63,23 @@ public final class LinuxRuntimeInstaller {
}
}
/**
* One install per process. The launcher and the session screen can both start one, and a
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
* install would share the first one's archive and staging directory and wreck both.
*/
private static final class Job {
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
final CountDownLatch done = new CountDownLatch(1);
volatile String stage = "Starting\u2026";
volatile int percent = -1;
volatile boolean ok;
}
private static final Object JOB_LOCK = new Object();
private static Job running;
private static boolean removing;
private LinuxRuntimeInstaller() {}
/** The build currently unpacked, or null when the runtime is not installed. */
@@ -104,6 +124,66 @@ public final class LinuxRuntimeInstaller {
* not. An update therefore keeps Steam, the login and the installed games.
*/
public static boolean install(Context context, Release release, ProgressListener listener) {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (removing) return false;
owner = running == null;
if (owner) running = new Job();
job = running;
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
try {
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
});
return job.ok;
} finally {
synchronized (JOB_LOCK) {
running = null;
}
job.done.countDown();
}
}
/** True while an install is running in this process, whoever started it. */
public static boolean isInstalling() {
synchronized (JOB_LOCK) {
return running != null;
}
}
/**
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
* it ends and returns its result, or returns null at once when nothing is installing.
*/
public static Boolean attach(ProgressListener listener) {
Job job;
synchronized (JOB_LOCK) {
job = running;
if (job == null) return null;
if (listener != null) job.listeners.add(listener);
}
return join(job, listener);
}
private static boolean join(Job job, ProgressListener listener) {
try {
if (listener != null) listener.onProgress(job.stage, job.percent);
job.done.await();
return job.ok;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (listener != null) job.listeners.remove(listener);
}
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
try {
if (listener != null) listener.onProgress("Downloading", 0);
@@ -120,7 +200,7 @@ public final class LinuxRuntimeInstaller {
}
if (listener != null) listener.onProgress("Verifying", -1);
String actual = sha256(archive);
String actual = Hashes.sha256(archive);
if (!release.sha256.equalsIgnoreCase(actual)) {
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
return false;
@@ -130,6 +210,8 @@ public final class LinuxRuntimeInstaller {
// runtime that isInstalled() would happily launch.
File root = LinuxRuntime.rootDir(context);
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
FileUtils.delete(staging);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
@@ -139,7 +221,6 @@ public final class LinuxRuntimeInstaller {
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
FileUtils.delete(old);
if (root.isDirectory() && !root.renameTo(old)) {
FileUtils.delete(staging);
@@ -164,6 +245,8 @@ public final class LinuxRuntimeInstaller {
}
if (!staging.renameTo(root)) {
File keptTo = new File(staging, USER_DATA);
if (keptTo.isDirectory() && old.isDirectory()) keptTo.renameTo(new File(old, USER_DATA));
if (old.isDirectory()) old.renameTo(root);
return false;
}
@@ -177,8 +260,38 @@ public final class LinuxRuntimeInstaller {
}
}
public static void uninstall(Context context) {
FileUtils.delete(LinuxRuntime.rootDir(context));
private static void recoverInterruptedSwap(File root, File staging, File old) {
File target = root.isDirectory() ? root : old;
File stagedHome = new File(staging, USER_DATA);
File home = new File(target, USER_DATA);
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
FileUtils.delete(home);
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
}
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
Log.w(TAG, "restored the previous runtime after an interrupted update");
}
}
private static boolean isEmptyDir(File dir) {
String[] names = dir.list();
return dir.isDirectory() && names != null && names.length == 0;
}
/** Removes the runtime; returns false without touching it while an install is running. */
public static boolean uninstall(Context context) {
synchronized (JOB_LOCK) {
if (running != null || removing) return false;
removing = true;
}
try {
FileUtils.delete(LinuxRuntime.rootDir(context));
return true;
} finally {
synchronized (JOB_LOCK) {
removing = false;
}
}
}
/**
@@ -193,12 +306,10 @@ public final class LinuxRuntimeInstaller {
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
TarArchiveEntry entry;
String base = destination.getCanonicalPath() + File.separator;
while ((entry = tar.getNextTarEntry()) != null) {
File file = new File(destination, entry.getName());
// Refuse anything that would land outside the runtime directory.
if (!(file.getCanonicalPath() + (entry.isDirectory() ? File.separator : "")).startsWith(base)
&& !file.getCanonicalPath().equals(destination.getCanonicalPath())) {
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) {
Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName());
continue;
}
@@ -215,7 +326,13 @@ public final class LinuxRuntimeInstaller {
} else if (entry.isLink()) {
// A hard link to an earlier entry. Link where the filesystem allows it and
// fall back to a copy, which costs space but always works.
File target = new File(destination, entry.getLinkName());
// The target is checked too: a link to "../../shared_prefs/x" would pull a
// file from outside the rootfs into it.
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
try {
Files.createLink(file.toPath(), target.toPath());
@@ -244,16 +361,4 @@ public final class LinuxRuntimeInstaller {
return false;
}
}
public static String sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new BufferedInputStream(new FileInputStream(file), 1 << 16)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = in.read(buffer)) != -1) digest.update(buffer, 0, read);
}
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) sb.append(String.format("%02x", b));
return sb.toString();
}
}
@@ -0,0 +1,100 @@
package com.droiddeck.launcher.runtime
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
/**
* Keeps the process in the foreground while the Linux runtime downloads and unpacks. The runtime
* is several hundred megabytes; without a foreground service Android may kill the app the moment
* the user switches away, and the install starts over from the resumable part file.
*
* The install itself is [LinuxRuntimeInstaller.install], which runs one job per process: the
* screen that asked for it joins the same job to show progress, so it does not matter which of the
* two gets there first.
*/
class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification("Starting…", -1))
if (release == null) {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = Int.MIN_VALUE
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
// Every percent at most once: a notification per read would flood the system.
if (percent != shown) {
shown = percent
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
}
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "runtime-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime downloads and installs"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle("Installing the Linux runtime")
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
companion object {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
private const val EXTRA_SIZE = "size"
/** Starts the install of [release] in the foreground; a running install is joined instead. */
fun start(context: Context, release: LinuxRuntimeInstaller.Release) {
val intent = Intent(context, RuntimeInstallService::class.java)
.putExtra(EXTRA_VERSION, release.version)
.putExtra(EXTRA_URL, release.url)
.putExtra(EXTRA_SHA256, release.sha256)
.putExtra(EXTRA_SIZE, release.size)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
val sha256 = intent.getStringExtra(EXTRA_SHA256) ?: return null
return LinuxRuntimeInstaller.Release(version, url, sha256, intent.getLongExtra(EXTRA_SIZE, 0L))
}
}
}
@@ -0,0 +1,656 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
/**
* FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client.
*
* Every Proton the client runs games with keeps its own copy of these three inside its tree. This
* lists them, stores packages (.wcp: tar + zstd with a profile.json) downloaded from the Nightlies
* "-Linux" releases or imported, swaps one into a Proton, keeps the Proton's shipped files as
* "original bundles" - one per Proton build, so a Steam update adds a new one instead of replacing
* the last - restores those, and deletes stored packages.
*
* Where a Proton reads each component (its own `proton` script, ARM64 host):
* DXVK files/lib/wine/dxvk/{aarch64,i386}-windows -> the prefix's system32 / syswow64
* VKD3D files/lib/wine/vkd3d-proton/{aarch64,i386}-windows -> the same, on every launch
* FEX files/lib/wine/aarch64-windows/lib{arm64ec,wow64}fex.dll
* files/lib/wine/aarch64-unix/lib{arm64ec,wow64}fex.so (FEX 2607 and newer)
* Proton copies DXVK and VKD3D into the prefix on every normal launch, so a swap applies the next
* time a game starts. A swap into a Proton a running game is using waits until that game exits.
*
* Same formats and data layout as the Decky "Components Manager" plugin, whose data is taken over
* once on first use.
*/
object ComponentsManager {
private const val TAG = "Components"
private const val NIGHTLIES = "The412Banner/Nightlies"
/** Release tag -> component. Only the Linux repacks: the Android releases carry bionic unixlibs. */
private val RELEASES = linkedMapOf(
"FexCore-Linux" to "fex",
"Dxvk-Linux" to "dxvk",
"Dxvk-arm64ec-Linux" to "dxvk",
"Dxvk-gplasync-Linux" to "dxvk",
"Vkd3d-proton-Linux" to "vkd3d",
)
private val TYPES = mapOf("FEXCore-Linux" to "fex", "DXVK-Linux" to "dxvk", "VKD3D-Linux" to "vkd3d")
private val TYPE_OF = mapOf("fex" to "FEXCore-Linux", "dxvk" to "DXVK-Linux", "vkd3d" to "VKD3D-Linux")
val COMPONENTS = listOf("fex", "dxvk", "vkd3d")
val LABEL = mapOf("fex" to "FEX", "dxvk" to "DXVK", "vkd3d" to "VKD3D-Proton")
private const val WINE = "files/lib/wine"
private val FEX_FILES = listOf(
"$WINE/aarch64-windows/libarm64ecfex.dll",
"$WINE/aarch64-windows/libwow64fex.dll",
"$WINE/aarch64-unix/libarm64ecfex.so",
"$WINE/aarch64-unix/libwow64fex.so",
)
private val COMP_DIR = mapOf("dxvk" to "$WINE/dxvk", "vkd3d" to "$WINE/vkd3d-proton")
/** The folders a Proton reads on ARM64. */
private val PE_ARCHES = listOf("aarch64-windows", "i386-windows")
/**
* The DLLs that ARE the component. A swap clears exactly these before unpacking, so no file of
* the previous version is left mixed in; anything else a Proton keeps beside them - GE's
* openvr_api_dxvk.dll, which its launcher copies into every prefix and aborts without - stays.
*/
private val COMP_DLLS = mapOf(
"dxvk" to setOf("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"),
"vkd3d" to setOf("d3d12.dll", "d3d12core.dll"),
)
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
data class ProtonView(
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
val reappliedAt: Long = 0,
)
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
private val lock = Any()
// ------------------------------------------------------------------ paths
private fun root(context: Context) = LinuxRuntime.rootDir(context)
private fun steam(context: Context) = File(root(context), "root/.local/share/Steam")
private fun host(context: Context, guest: String) = File(root(context), guest.removePrefix("/"))
private fun dataDir(context: Context) = File(context.filesDir, "components").apply { mkdirs() }
private fun packagesDir(context: Context) = File(dataDir(context), "packages").apply { mkdirs() }
private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() }
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
/**
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
*/
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
// ------------------------------------------------------------------ state
private fun loadState(context: Context): JSONObject =
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
if (!has("active")) put("active", JSONObject())
if (!has("queued")) put("queued", JSONObject())
}
private fun saveState(context: Context, state: JSONObject) {
val f = stateFile(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(state.toString(2))
tmp.renameTo(f)
// Every change of choice reaches the launch wrappers at once.
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
/** Takes over the Decky plugin's packages, originals and swap records, once. */
private fun migratePlugin(context: Context) {
val marker = File(dataDir(context), ".plugin-imported")
if (marker.isFile) return
val plugin = File(root(context), PLUGIN_DATA)
if (plugin.isDirectory) {
runCatching {
File(plugin, "packages").listFiles()?.forEach { f ->
if (f.isFile && f.name.endsWith(".wcp")) {
val dst = File(packagesDir(context), f.name)
if (!dst.exists()) f.copyTo(dst)
}
}
val originals = File(plugin, "originals")
if (originals.isDirectory) originals.copyRecursively(originalsDir(context), overwrite = false)
val ps = File(plugin, "state.json")
if (ps.isFile && !stateFile(context).isFile) ps.copyTo(stateFile(context))
Log.i(TAG, "took over the Decky plugin's data from $plugin")
}.onFailure { Log.w(TAG, "plugin data import", it) }
}
marker.writeText("1\n")
}
// ------------------------------------------------------------------ Protons
private fun libraryRoots(context: Context): List<File> {
val roots = mutableListOf(steam(context))
val vdf = File(steam(context), "steamapps/libraryfolders.vdf")
runCatching {
Regex("\"path\"\\s+\"([^\"]+)\"").findAll(vdf.readText()).forEach {
val h = host(context, it.groupValues[1])
if (roots.none { r -> r.canonicalPath == h.canonicalPath }) roots.add(h)
}
}
return roots
}
private fun displayName(dir: File): String = runCatching {
Regex("\"display_name\"\\s+\"([^\"]+)\"").find(File(dir, "compatibilitytool.vdf").readText())?.groupValues?.get(1)
}.getOrNull() ?: dir.name
private fun protonVersion(dir: File): String = runCatching {
val parts = File(dir, "version").useLines { it.firstOrNull() ?: "" }.trim().split(Regex("\\s+"))
if (parts.size > 1) parts[1] else parts.firstOrNull()?.ifEmpty { null } ?: "unknown"
}.getOrDefault("unknown")
/** Every Proton tree with a Wine inside. Launch wrappers pointing at another Proton are left out. */
fun protons(context: Context): List<Proton> {
val found = LinkedHashMap<String, Proton>()
val bases = listOf(File(steam(context), "compatibilitytools.d") to false) +
libraryRoots(context).map { File(it, "steamapps/common") to true }
// Not canonical: /data/user/0 is a link to /data/data on many devices, and the guest path is
// what is left after removing the rootfs prefix exactly as the paths were built.
val rootPath = root(context).absolutePath.trimEnd('/')
for ((base, valve) in bases) {
val entries = base.listFiles()?.sortedBy { it.name } ?: continue
for (dir in entries) {
if (!File(dir, WINE).isDirectory || !File(dir, "proton").isFile) continue
val real = runCatching { dir.canonicalPath }.getOrDefault(dir.path)
if (found.containsKey(real)) continue
val guest = if (dir.absolutePath.startsWith(rootPath)) dir.absolutePath.removePrefix(rootPath) else dir.absolutePath
found[real] = Proton(safeName(dir.name), displayName(dir), dir, guest, protonVersion(dir), valve)
}
}
return found.values.toList()
}
private fun proton(context: Context, id: String) = protons(context).firstOrNull { it.id == id }
?: throw IllegalStateException("No Proton with id $id")
/**
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
*/
fun inUse(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
/**
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
*/
fun gameRunning(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
private fun anyProcess(proton: Proton, match: (String, String) -> Boolean): Boolean {
val needle = proton.guestPath.trimEnd('/') + "/"
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
if (match(needle, String(cmd).replace('\u0000', ' '))) return true
}
return false
}
// ------------------------------------------------------------------ component files
private fun readVersionFile(f: File): String = runCatching {
val text = f.readText().trim()
Regex("\\(([^)]+)\\)").find(text)?.groupValues?.get(1) ?: text.split(Regex("\\s+")).last()
}.getOrDefault("")
private fun fexVersion(dir: File): String {
val dll = File(dir, FEX_FILES[0])
if (!dll.isFile) return ""
val text = runCatching { String(dll.readBytes(), Charsets.ISO_8859_1) }.getOrDefault("")
return Regex("FEX-\\d{4}(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]{6,})?").findAll(text).map { it.value }.maxByOrNull { it.length }
?: "FEX (build without a version name)"
}
private fun compFiles(dir: File, comp: String): List<String> {
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
val base = File(dir, COMP_DIR.getValue(comp))
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
}
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
private fun fingerprint(dir: File, comp: String): Map<String, String> =
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { Hashes.sha256(File(dir, it)) }
// ------------------------------------------------------------------ packages
private fun normalize(name: String) = name.removePrefix("./").trimStart('/')
private inline fun <T> readWcp(f: File, block: (TarArchiveInputStream) -> T): T =
TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(f), 1 shl 16))).use(block)
private fun readProfile(f: File): JSONObject = readWcp(f) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
if (!e.isDirectory && normalize(e.name) == "profile.json") return@readWcp JSONObject(tar.readBytes().decodeToString())
}
throw IllegalArgumentException("no profile.json in ${f.name}")
}
private fun packageInfo(f: File): Package {
val prof = readProfile(f)
val comp = TYPES[prof.optString("type")] ?: throw IllegalArgumentException("${f.name} is not a Linux component package (type ${prof.optString("type")})")
return Package(f.name, comp, prof.optString("versionName", f.nameWithoutExtension), prof.optString("description"), f.length())
}
fun packages(context: Context): List<Package> = packagesDir(context).listFiles()
?.filter { it.isFile && it.name.endsWith(".wcp") }?.sortedBy { it.name }
?.mapNotNull { f -> runCatching { packageInfo(f) }.onFailure { Log.w(TAG, "skipping ${f.name}", it) }.getOrNull() }
?: emptyList()
/** Unpacks a package's files/ into a Proton; for DXVK/VKD3D the component's own DLLs are replaced as a set. */
private fun installFiles(context: Context, dir: File, comp: String, wcp: File): Map<String, String> {
val staged = File(dataDir(context), "staging-${System.nanoTime()}")
try {
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/")) continue
require(rel.split('/').none { it == ".." }) { "unsafe path $rel" }
val out = File(staged, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
COMP_DIR[comp]?.let { compDir ->
for (arch in PE_ARCHES) {
val target = File(dir, "$compDir/$arch")
if (!File(staged, "$compDir/$arch").isDirectory || !target.isDirectory) continue
target.listFiles()?.filter { it.name.lowercase() in COMP_DLLS.getValue(comp) }?.forEach { it.delete() }
}
}
val hashes = LinkedHashMap<String, String>()
File(staged, "files").walkTopDown().filter { it.isFile }.forEach { src ->
val rel = src.relativeTo(staged).path
val dst = File(dir, rel)
dst.parentFile?.mkdirs()
val tmp = File(dst.parentFile, dst.name + ".dcm-new")
src.copyTo(tmp, overwrite = true)
if (rel.endsWith(".so")) tmp.setExecutable(true, false)
tmp.setReadable(true, false)
if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } }
hashes[rel] = Hashes.sha256(dst)
}
return hashes
} finally {
staged.deleteRecursively()
}
}
private fun writeWcp(dest: File, profile: JSONObject, dir: File, rels: List<String>) {
val tmp = File(dest.parentFile, dest.name + ".part")
TarArchiveOutputStream(ZstdOutputStream(BufferedOutputStream(FileOutputStream(tmp), 1 shl 16), 9)).use { tar ->
tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX)
val bytes = profile.toString(2).toByteArray()
tar.putArchiveEntry(TarArchiveEntry("./profile.json").apply { size = bytes.size.toLong() })
tar.write(bytes); tar.closeArchiveEntry()
for (rel in rels) {
val f = File(dir, rel)
tar.putArchiveEntry(TarArchiveEntry(f, "./$rel"))
FileInputStream(f).use { it.copyTo(tar) }
tar.closeArchiveEntry()
}
}
tmp.renameTo(dest)
}
// ------------------------------------------------------------------ originals
private fun originalFile(context: Context, p: Proton, comp: String) =
File(originalsDir(context), "${p.id}/${safeName(p.version)}/$comp.wcp").apply { parentFile?.mkdirs() }
fun originals(context: Context, protonId: String): List<Original> {
val base = File(originalsDir(context), protonId)
val versions = base.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.lastModified() } ?: return emptyList()
return versions.flatMap { v ->
COMPONENTS.mapNotNull { comp ->
val f = File(v, "$comp.wcp")
if (!f.isFile) null else Original(comp, v.name, runCatching { readProfile(f).optString("description") }.getOrDefault(""), f.length())
}
}
}
/**
* Saves a Proton's shipped files for this component before they are first replaced in this
* Proton build. A build already swapped into is not saved again: its files are ours.
*/
private fun ensureOriginal(context: Context, p: Proton, comp: String, state: JSONObject) {
val dest = originalFile(context, p, comp)
if (dest.isFile) return
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
if (active != null && active.optString("protonVersion") == p.version) return
val rels = compFiles(p.dir, comp)
if (rels.isEmpty()) return
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val profile = JSONObject()
.put("type", TYPE_OF.getValue(comp))
.put("versionName", "original-${p.version}")
.put("versionCode", 0)
.put("description", "Original · ${p.name} · ${p.version} · ${LABEL.getValue(comp)} $current".trim())
.put("files", JSONArray(rels.map { JSONObject().put("source", it).put("target", "\${proton}/$it") }))
writeWcp(dest, profile, p.dir, rels)
Log.i(TAG, "saved original $comp of ${p.name} ${p.version}")
}
/** Saves the originals of every Proton build not seen yet (first open, and after a Steam update). */
fun snapshotAll(context: Context) = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
for (p in protons(context)) for (comp in COMPONENTS) {
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
}
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
// ------------------------------------------------------------------ view
fun snapshot(context: Context): Snapshot = synchronized(lock) {
migratePlugin(context)
val state = loadState(context)
val reapplied = HashMap<String, Long>()
runCatching {
File(launchDir(context), "reapplied.log").forEachLine { line ->
val parts = line.split('\t')
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
}
}
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val detail = if (comp == "fex") (if (File(p.dir, FEX_FILES[2]).isFile) "with Linux helpers" else "DLLs only") else ""
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
val inUseLabel = when {
active == null -> "Original"
active.optString("protonVersion") != p.version -> "Original (Proton updated since the last swap)"
else -> {
val want = active.optJSONObject("files") ?: JSONObject()
val fp = fingerprint(p.dir, comp)
if (want.keys().asSequence().all { fp[it] == want.optString(it) }) active.optString("label", active.optString("file"))
else "Changed outside Components"
}
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { "not present" }, detail, inUseLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.optString("label"),
)
}
ProtonView(p, comps, originals(context, p.id), gameRunning(p), reapplied[p.dir.name] ?: 0)
}
Snapshot(views, packages(context))
}
// ------------------------------------------------------------------ launch-time enforcement
/**
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
* what overwrote files in the first place.
*/
private fun syncLaunchState(context: Context, state: JSONObject) {
val dir = launchDir(context)
val store = File(dir, "store").apply { mkdirs() }
val all = protons(context)
val lines = StringBuilder()
val keep = HashSet<String>()
val active = state.sub("active")
for (pid in active.keys()) {
val p = all.firstOrNull { it.id == pid } ?: continue
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val a = comps.optJSONObject(comp) ?: continue
if (a.optString("protonVersion") != p.version) continue
val file = a.optString("file")
val (key, wcp) = if (file.startsWith("original ")) {
val v = file.removePrefix("original ")
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
if (!wcp.isFile) continue
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
unpacked.deleteRecursively()
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
val src = File(unpacked, rel)
if (!src.isFile) continue
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
}
}
}
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
val list = File(dir, "desired.tsv")
val tmp = File(dir, "desired.tsv.tmp")
tmp.writeText(lines.toString())
tmp.renameTo(list)
}
// ------------------------------------------------------------------ actions
/** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */
fun swap(context: Context, protonId: String, file: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
}
applyPackage(context, p, info, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(info.comp)
saveState(context, state)
"${LABEL[info.comp]} ${info.version} is now in ${p.name}. It applies the next time a game starts."
}
private fun applyPackage(context: Context, p: Proton, info: Package, wcp: File, state: JSONObject) {
ensureOriginal(context, p, info.comp, state)
val hashes = installFiles(context, p.dir, info.comp, wcp)
state.sub("active").sub(p.id).put(info.comp, JSONObject()
.put("file", wcp.name).put("label", info.version).put("protonVersion", p.version)
.put("files", JSONObject(hashes.filterKeys { owned(info.comp, it) } as Map<*, *>))
.put("at", System.currentTimeMillis() / 1000))
Log.i(TAG, "swapped ${info.comp} ${info.version} into ${p.name} ${p.version}")
}
fun restore(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
val state = loadState(context)
if (inUse(p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
}
applyOriginal(context, p, comp, protonVersion, wcp, state)
state.sub("queued").optJSONObject(p.id)?.remove(comp)
saveState(context, state)
"${LABEL[comp]} of ${p.name} restored from its $protonVersion original."
}
private fun applyOriginal(context: Context, p: Proton, comp: String, protonVersion: String, wcp: File, state: JSONObject) {
val current = protonVersion == safeName(p.version)
if (!current) ensureOriginal(context, p, comp, state)
installFiles(context, p.dir, comp, wcp)
val active = state.sub("active").sub(p.id)
if (current) active.remove(comp)
else active.put(comp, JSONObject().put("file", "original $protonVersion").put("label", "Original from $protonVersion")
.put("protonVersion", p.version).put("files", JSONObject(fingerprint(p.dir, comp) as Map<*, *>)).put("at", System.currentTimeMillis() / 1000))
}
/** Runs the swaps that waited for a game to close. Cheap when nothing is queued. */
fun applyQueued(context: Context): List<String> = synchronized(lock) {
val state = loadState(context)
val queued = state.sub("queued")
if (queued.length() == 0) return emptyList()
val done = mutableListOf<String>()
val all = protons(context)
for (pid in queued.keys().asSequence().toList()) {
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
if (inUse(p)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val q = comps.optJSONObject(comp) ?: continue
runCatching {
if (q.optString("kind") == "original") {
val v = q.optString("protonVersion")
applyOriginal(context, p, comp, v, File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp"), state)
} else {
val wcp = File(packagesDir(context), q.optString("file"))
applyPackage(context, p, packageInfo(wcp), wcp, state)
}
done += "${LABEL[comp]} ${q.optString("label")} → ${p.name}"
}.onFailure { Log.w(TAG, "queued $comp for ${p.name}", it) }
comps.remove(comp)
}
if (comps.length() == 0) queued.remove(pid)
}
saveState(context, state)
done
}
fun cancelQueued(context: Context, protonId: String, comp: String) = synchronized(lock) {
val state = loadState(context)
state.sub("queued").optJSONObject(protonId)?.remove(comp)
saveState(context, state)
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { "$name is in use in $pid. Swap that Proton to something else first." }
}
val f = File(packagesDir(context), name)
check(f.isFile) { "$name is not stored" }
f.delete()
"Deleted $name."
}
fun deleteOriginal(context: Context, protonId: String, comp: String, protonVersion: String): String = synchronized(lock) {
val p = proton(context, protonId)
check(safeName(p.version) != protonVersion) { "The installed build's original is kept: it is the way back." }
val f = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(f.isFile) { "Not stored" }
f.delete()
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
"Deleted the $protonVersion original of ${LABEL[comp]}."
}
/** Imports a -linux .wcp from anywhere the app can read (the file picker hands over a copy). */
fun importPackage(context: Context, source: File, name: String): Package = synchronized(lock) {
val info = packageInfo(source)
val dest = File(packagesDir(context), safeName(name).let { if (it.endsWith(".wcp")) it else "$it.wcp" })
source.copyTo(File(dest.parentFile, dest.name + ".part"), overwrite = true).renameTo(dest)
info.copy(file = dest.name)
}
// ------------------------------------------------------------------ catalog
/** The Nightlies listing: cached on disk, fetched again only when [force] (the refresh button). */
fun catalog(context: Context, force: Boolean): Catalog {
val cache = catalogFile(context)
if (!force && cache.isFile) runCatching { return parseCatalog(JSONObject(cache.readText())) }
if (!force) return Catalog(emptyList(), 0)
val items = JSONArray()
for ((tag, comp) in RELEASES) {
val body = Downloader.downloadString("https://api.github.com/repos/$NIGHTLIES/releases/tags/$tag") ?: continue
val assets = runCatching { JSONObject(body).optJSONArray("assets") }.getOrNull() ?: continue
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
val name = a.optString("name")
if (!name.endsWith(".wcp")) continue
// Only packages GitHub has a sha256 for are offered: the download is checked against it.
if (!Hashes.isGithubSha256(a.optString("digest"))) continue
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
}
val out = JSONObject().put("items", items).put("fetchedAt", System.currentTimeMillis() / 1000)
val tmp = File(cache.parentFile, cache.name + ".tmp")
tmp.writeText(out.toString()); tmp.renameTo(cache)
return parseCatalog(out)
}
private fun parseCatalog(o: JSONObject): Catalog {
val arr = o.optJSONArray("items") ?: JSONArray()
val items = (0 until arr.length()).map { arr.getJSONObject(it) }.map {
CatalogItem(it.optString("file"), it.optString("comp"), it.optString("release"), it.optString("url"), it.optLong("size"), it.optString("digest"))
}.sortedWith(compareBy({ it.comp }, { it.release }, { it.file }))
return Catalog(items, o.optLong("fetchedAt"))
}
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
}
}
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
object GameEnvironmentStore {
private const val SETTINGS = "game-environment.json"
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
val file = AtomicFile(File(context.filesDir, SETTINGS))
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
}
fun decode(json: JSONObject): GameEnvironment.Config {
require(json.getInt("version") == 1)
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
value?.keys()?.forEach { name ->
require(GameEnvironment.validName(name))
val entry = value.get(name)
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
put(name, if (entry == JSONObject.NULL) null else entry as String)
}
}
val games = json.optJSONObject("games")
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
games?.keys()?.forEach { id ->
require(id.isNotEmpty() && GameEnvironment.validScope(id))
put(id, entries(games.getJSONObject(id)))
}
})
}
fun encode(config: GameEnvironment.Config): JSONObject {
fun entries(values: Map<String, String?>) = JSONObject().apply {
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
}
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
config.games.forEach { (id, values) -> put(id, entries(values)) }
})
}
@Synchronized
fun save(context: Context, config: GameEnvironment.Config) {
val json = encode(config)
decode(json)
write(File(context.filesDir, SETTINGS), json.toString())
publish(context, config)
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
}
private fun write(file: File, text: String) {
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
val atomic = AtomicFile(file)
val output = atomic.startWrite()
try {
output.write(text.toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
throw error
}
}
}
@@ -0,0 +1,238 @@
package com.droiddeck.launcher.session
import android.os.Environment
import com.droiddeck.launcher.frontend.Library
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's
* drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at
* "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but
* Public becomes steamuser, and on the way out the chosen layout decides the user segment.
*
* Saves belong to the game's prefix (compatdata/<appid>/pfx), whichever Proton it runs with.
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
*/
object GameSaves {
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
WINLATOR("Winlator zip", "drive_c/", "xuser"),
}
/** A game, its prefix, and the save folders found in it. */
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true
}
/** A folder of saves, relative to the steamuser profile. */
data class SaveDir(val relPath: String, val files: Int, val bytes: Long)
private const val USER = "steamuser"
fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves")
private fun backupsDir(): File = File(savesDir(), "backups")
/** [game] with its prefix and the saves found there; reads files, so not on the main thread. */
fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList())
// ------------------------------------------------------------------ finding saves
/** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */
private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow")
private const val KEEP_THRESHOLD = 50
private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER")
/**
* Folders under the save roots, one and two deep, whose names match the game's name or its folder's.
* Nested matches keep the outer folder only.
*/
fun locate(prefix: File, game: Library.SteamGame): List<SaveDir> {
val profile = profile(prefix)
if (!profile.isDirectory) return emptyList()
val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() }
if (ids.isEmpty()) return emptyList()
val hits = LinkedHashMap<String, Int>()
fun consider(dir: File) {
val d = ident(dir.name)
if (d.norm.isEmpty()) return
val s = ids.maxOf { score(it, d) }
if (s < KEEP_THRESHOLD) return
val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/')
if ((hits[rel] ?: -1) < s) hits[rel] = s
}
for (root in SAVE_ROOTS) {
File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 ->
consider(d1)
d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider)
}
}
val kept = ArrayList<String>()
for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) {
if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel)
}
return kept.map { rel ->
var files = 0
var bytes = 0L
File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() }
SaveDir(rel, files, bytes)
}.filter { it.files > 0 }
}
private data class Ident(val norm: String, val tokens: Set<String>)
private fun ident(raw: String): Ident {
val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "")
return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet())
}
/** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */
private fun score(a: Ident, b: Ident): Int {
if (a.norm.isEmpty() || b.norm.isEmpty()) return 0
if (a.norm == b.norm) return 100
if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70
if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() &&
a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50
return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0
}
private fun levenshtein(a: String, b: String): Int {
val prev = IntArray(b.length + 1) { it }
val cur = IntArray(b.length + 1)
for (i in 1..a.length) {
cur[0] = i
for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1)
System.arraycopy(cur, 0, prev, 0, cur.size)
}
return prev[b.length]
}
// ------------------------------------------------------------------ export
private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date())
private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" }
/**
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
*/
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
dir.mkdirs()
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
return out to count
}
private fun zipProfile(profile: File, roots: List<String>?, layout: Layout, out: File): Int {
if (!profile.isDirectory) return 0
val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile)
var count = 0
ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos ->
for (top in scope) top.walkTopDown()
.onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } }
.filter { it.isFile }
.forEach { f ->
val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/')
zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel"))
f.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
count++
}
}
return count
}
// ------------------------------------------------------------------ import
/**
* Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
*/
fun import(game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
val driveC = File(prefix, "drive_c")
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
backupsDir().mkdirs()
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup)
if (backedUp == 0) backup.delete()
val base = driveC.canonicalFile
var written = 0
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) {
remap(e.name)?.let { rel ->
val out = File(driveC, rel).canonicalFile
if (out.path.startsWith(base.path + File.separator)) {
if (e.isDirectory) out.mkdirs()
else {
out.parentFile?.mkdirs()
Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING)
written++
}
}
}
zis.closeEntry()
e = zis.nextEntry
}
}
return written to backup.takeIf { it.exists() }
}
/** Which layout a zip was written in, from its first user folder; null when it has none. */
fun layoutOf(zip: File): Layout? = runCatching {
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) {
val segs = driveCSegments(e.name)
if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true))
return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB
e = zis.nextEntry
}
null
}
}.getOrNull()
private fun looksLikeSaveZip(zip: File): Boolean = runCatching {
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry }
false
}
}.getOrDefault(false)
/** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */
private fun remap(raw: String): String? {
val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null
if (segs.any { it == ".." }) return null
// GameHub also packs its own launcher shortcuts; they are never saves.
if (segs[0].equals("proton_shortcuts", true)) return null
val last = segs.last().lowercase()
if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null
if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER
return segs.joinToString("/")
}
private fun driveCSegments(raw: String): MutableList<String>? {
val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." }
if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null
return segs.drop(1).toMutableList()
}
}
@@ -17,15 +17,20 @@ import java.nio.charset.StandardCharsets
* progress, and lifting that out is the difference between "starting the Steam client" for three
* minutes and a percentage that moves.
*/
class LoadingState(context: Context) {
class LoadingState(context: Context, steam: Boolean = true) {
var visible by mutableStateOf(true)
var step by mutableStateOf("Starting the session…")
var percent by mutableIntStateOf(-1)
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
var ended by mutableStateOf(false)
/** The technical side of an ended session (exit status, log path), under the advice in [step]. */
var endedDetail by mutableStateOf<String?>(null)
private val hints = context.resources.getStringArray(com.droiddeck.launcher.R.array.loading_hints)
// The desktop and the emulators get their own: the Steam ones talk about Steam.
private val hints = context.resources.getStringArray(
if (steam) com.droiddeck.launcher.R.array.loading_hints else com.droiddeck.launcher.R.array.loading_hints_desktop,
)
private val startedAt = SystemClock.elapsedRealtime()
/** Once a second: the clock and the hint. */
@@ -35,10 +40,11 @@ class LoadingState(context: Context) {
hint = hints[((seconds / 8) % hints.size).toInt()]
}
fun showEnded(message: String) {
fun showEnded(message: String, detail: String? = null) {
visible = true
ended = true
step = message
endedDetail = detail
}
/** Re-reads the end of the log and updates the line and the bar. */
@@ -13,17 +13,28 @@ import java.io.File
* force-stop the app before every launch. Everything of ours that should be alive between
* sessions is inside the app process, so at session start anything else under our uid is a
* leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name.
*
* The one exception is work the app started outside any session and is still waiting on - a
* Flatpak install from the store - registered with [keep]: that process and everything under it
* are left to finish.
*/
object OrphanReaper {
private const val TAG = "OrphanReaper"
private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>()
/** Spares [pid] and its descendants until [release]. */
fun keep(pid: Int) { if (pid > 0) kept.add(pid) }
fun release(pid: Int) { kept.remove(pid) }
fun reap(reason: String): Int {
val me = android.os.Process.myPid()
val uid = android.os.Process.myUid()
val spared = descendants(kept)
var killed = 0
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
if (pid == me || pid <= 1) return@forEach
if (pid == me || pid <= 1 || pid in spared) return@forEach
if (uidOf(pid) != uid) return@forEach
val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" }
try {
@@ -38,6 +49,25 @@ object OrphanReaper {
return killed
}
/** [roots] and every process below them, by each process's parent in /proc/<pid>/stat. */
private fun descendants(roots: Set<Int>): Set<Int> {
if (roots.isEmpty()) return emptySet()
val parent = HashMap<Int, Int>()
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach }
// "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it.
stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it }
}
val out = HashSet(roots)
var grew = true
while (grew) {
grew = false
for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true
}
return out
}
private fun uidOf(pid: Int): Int {
return try {
File("/proc/$pid/status").useLines { lines ->
@@ -1,87 +0,0 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PerformanceHintManager
import android.util.Log
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.util.concurrent.Executors
/**
* ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the
* panel's frame period as its target and each presented frame's interval as the actual. When a
* frame runs long the power HAL raises CPU clocks for the next one at once, instead of the
* governor waiting for the load average to climb - the difference between a menu that stutters
* on the first scroll and one that does not.
*
* The session may only carry this process's threads, so the guest (gamescope, the Steam client
* under FEX) cannot join it; they are covered by the app-wide game category and sustained mode.
* Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns
* null and the log says so.
*/
object PerfHints {
private const val TAG = "PerfHints"
@Volatile private var session: PerformanceHintManager.Session? = null
@Volatile private var target = 0L
/** Compositor thread only. */
private var last = 0L
private val main = Handler(Looper.getMainLooper())
/** Reports leave the compositor thread: each one is a binder call to the power HAL. */
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } }
/** Opens the session once the compositor thread exists (it is started just before this). */
fun arm(context: Context, refreshHz: Float) {
if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return }
val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong()
val s = session
if (s != null) {
if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } }
return
}
target = wanted
val app = context.applicationContext
var tries = 0
main.post(object : Runnable {
override fun run() {
if (session != null) return
val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 }
if (tid == 0) {
if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid")
return
}
open(app, tid)
}
})
}
@RequiresApi(31)
private fun open(app: Context, tid: Int) {
val pm = app.getSystemService(PerformanceHintManager::class.java)
if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return }
val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null }
if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return }
last = 0L
session = s
Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs")
}
/** Compositor thread, once per presented game frame; the interval since the last is the actual. */
@JvmStatic
fun onFrame() {
val s = session ?: return
val now = System.nanoTime()
val prev = last
last = now
if (prev == 0L) return
val dt = now - prev
// Under half a millisecond is a double present; over half a second is a pause, not a frame.
if (dt < 500_000L || dt > 500_000_000L) return
worker.execute {
try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null }
}
}
}
@@ -81,7 +81,7 @@ class PerfHud(context: Context) {
fun stop() {
running = false
WaylandCompositor.setGameListener(null)
WaylandCompositor.clearGameListener(listener)
text = ""
}
@@ -4,7 +4,6 @@ import android.app.Activity
import android.app.GameManager
import android.app.GameState
import android.os.Build
import android.os.PowerManager
import android.util.Log
/**
@@ -13,8 +12,10 @@ import android.util.Log
* The manifest already makes the app a game (appCategory + game_mode_config); this is the
* per-window half, applied when the session activity is created:
*
* - sustained performance mode (Android 7+): a clock floor that does not throttle away after
* two minutes, which suits an hour in Big Picture better than a boost that fades;
* - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at
* a level the device can hold indefinitely rather than holding a floor, which cost games and
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
* the game;
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
* 120 Hz for the menu, not only when a game votes for it;
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
@@ -27,15 +28,7 @@ object PerfMode {
private const val TAG = "PerfMode"
fun apply(a: Activity): String {
val parts = ArrayList<String>(3)
// Sustained performance mode.
parts += if (Build.VERSION.SDK_INT >= 24) {
val pm = a.getSystemService(PowerManager::class.java)
if (pm?.isSustainedPerformanceModeSupported == true) {
try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" }
} else "sustained mode unsupported"
} else "sustained mode needs Android 7"
val parts = ArrayList<String>(2)
// The fastest display mode at the panel's current size.
parts += try {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.os.StatFs
import android.util.Log
@@ -7,7 +8,6 @@ import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.security.MessageDigest
import org.json.JSONArray
/** Immediate download and installation of the optional ARM64 Proton builds. */
@@ -21,7 +21,9 @@ object ProtonExtras {
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long)
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
val sha256: String? = null)
val tools = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
@@ -99,14 +101,24 @@ object ProtonExtras {
return "Download was incomplete; the partial file is kept for a resumable retry"
}
asset.sha512?.let { checksumUrl ->
onProgress("Verifying download", -1)
val checksumText = Downloader.downloadString(checksumUrl)
val expected = checksumText?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
if (expected != null && !expected.equals(sha512(archive), ignoreCase = true)) {
archive.delete()
return "Checksum mismatch; the download was deleted"
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
// release's own sha512 file. A download neither vouches for is not installed.
onProgress("Verifying download", -1)
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return "Could not read the release checksum; try again"
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
else -> {
archive.delete()
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
}
}
if (!verified) {
archive.delete()
return "Checksum mismatch; the download was deleted"
}
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
@@ -170,6 +182,7 @@ object ProtonExtras {
release.optString("tag_name"), name,
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
archive.optLong("size", 0L),
Hashes.githubSha256(archive.optString("digest")),
)
}
null
@@ -179,19 +192,6 @@ object ProtonExtras {
}
}
private fun sha512(file: File): String {
val digest = MessageDigest.getInstance("SHA-512")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(4 * 1024 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun requestLines(context: Context): List<String> =
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
}
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.session
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import android.widget.Toast
/**
* Other apps on the device that sign into the user's Steam account with their own client. Steam
@@ -34,31 +33,21 @@ object RivalClients {
fun name(pkg: String): String = if (pkg == "com.xiaoji.egggame") "GameHub" else pkg
/**
* Stop the installed rivals' background processes before the client starts, and say so. A
* rival in the foreground survives this, so the wording covers that case too. Call on the main
* thread; the Toast is shown from here.
* Stop the installed rivals' background processes before the client starts. A rival in the
* foreground survives this. The activity service call and its log messages are unchanged.
*/
fun stopBeforeSession(context: Context) {
val rivals = installed(context)
if (rivals.isEmpty()) return
val who = name(rivals[0])
Log.w(TAG, "competing Steam client installed: $rivals")
var asked = false
try {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
if (am != null) {
for (pkg in rivals) am.killBackgroundProcesses(pkg)
asked = true
Log.i(TAG, "asked Android to stop background processes of $rivals")
}
} catch (t: Throwable) {
Log.w(TAG, "could not stop competing Steam clients", t)
}
Toast.makeText(
context,
if (asked) "Closed $who in the background - it signs into your Steam account."
else "If $who is open, close it first - it signs into your Steam account and will sign this client out.",
Toast.LENGTH_LONG,
).show()
}
}
@@ -24,7 +24,9 @@ object SessionFiles {
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
GameEnvironmentStore.publish(context)
val files = arrayOf(
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
@@ -38,7 +40,15 @@ object SessionFiles {
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
"usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games",
"usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
"usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap",
"usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak",
"usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup",
"usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run",
// The user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -148,8 +158,161 @@ object SessionFiles {
staged.delete()
Log.e(TAG, "could not write ld.so.preload")
}
val startupMovieDir = File(root, "root/.local/share/Steam/config/uioverrides/movies")
// Steam looks up these conventional names in its user override directory. Keep both
// variants populated because the startup movie name differs across Steam clients.
val bigPictureMovieInstalled = stageStartupMovie(
context,
startupMovieDir,
"bigpicture_startup.webm",
)
stageStartupMovie(context, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
}
}
private fun stageStartupMovie(context: Context, directory: File, name: String): Boolean {
val movie = File(directory, name)
val staged = File(directory, "$name.staged")
var installed = false
try {
directory.mkdirs()
context.assets.open("steam-startup/droiddeck-startup.webm").use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "Steam startup movie $name NOT staged")
return installed
}
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
private fun ensureStartupMovieDefault(config: File) {
val path = "/uioverrides/movies/bigpicture_startup.webm"
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config for startup movie", it)
return
} else ""
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val movie = findObject(text, "StartupMovie", 0, text.length)
if (movie != null) {
val block = text.substring(movie.keyStart, movie.close + 1)
val selectedId = scalar(block, "MovieID")
val selectedPath = scalar(block, "LocalPath")
if (selectedPath == path) {
Log.i(TAG, "Steam startup movie is set to DroidDeck")
return
}
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
val hasExplicitSelection =
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
if (!legacyDefault && hasExplicitSelection) {
Log.i(TAG, "Steam startup movie selection preserved")
return
}
val updated = setScalar(block, "MovieID", "0", newline)
.let { setScalar(it, "LocalPath", path, newline) }
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
} else {
val steam = findSteamBlock(text)
if (steam == null) {
if (text.isNotBlank()) {
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
return
}
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
} else {
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
val section = startupMovieSection(indent + "\t", path, newline)
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
}
}
val staged = File(config.parentFile, config.name + ".staged")
try {
config.parentFile?.mkdirs()
staged.writeText(text)
if (!staged.renameTo(config)) {
staged.delete()
Log.e(TAG, "could not write Steam startup movie selection")
} else {
Log.i(TAG, "Steam startup movie default selected")
}
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write Steam startup movie selection", e)
}
}
private fun findSteamBlock(text: String): VdfBlock? {
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
val valve = findObject(text, "Valve", software.open + 1, software.close) ?: return null
return findObject(text, "Steam", valve.open + 1, valve.close)
}
private fun findObject(text: String, key: String, start: Int, end: Int): VdfBlock? {
val pattern = Regex("(?m)^[\\t ]*\"${Regex.escape(key)}\"[\\t ]*(?:\\r?\\n[\\t ]*)?\\{")
val match = pattern.find(text, start)?.takeIf { it.range.first < end && it.range.last < end } ?: return null
val open = text.indexOf('{', match.range.first).takeIf { it < end } ?: return null
var depth = 0
var quoted = false
var escaped = false
for (index in open until end) {
val char = text[index]
if (quoted) {
if (escaped) escaped = false
else if (char == '\\') escaped = true
else if (char == '\"') quoted = false
} else {
when (char) {
'\"' -> quoted = true
'{' -> depth++
'}' -> {
depth--
if (depth == 0) return VdfBlock(match.range.first, open, index)
}
}
}
}
return null
}
private fun scalar(block: String, key: String): String? {
val pattern = Regex("(?m)^[\\t ]*\"${Regex.escape(key)}\"[\\t ]+\"([^\"\\r\\n]*)\"")
return pattern.find(block)?.groupValues?.get(1)
}
private fun setScalar(block: String, key: String, value: String, newline: String): String {
val pattern = Regex("(?m)^([\\t ]*\"${Regex.escape(key)}\"[\\t ]+)\"[^\"\\r\\n]*\"([\\t ]*)$")
val match = pattern.find(block)
if (match != null) return block.replaceRange(match.range, match.groupValues[1] + "\"" + value + "\"" + match.groupValues[2])
val close = block.lastIndexOf('}')
if (close < 0) return block
val lineStart = block.lastIndexOf('\n', close).let { if (it < 0) 0 else it + 1 }
val indent = block.substring(lineStart, close).takeWhile { it == '\t' || it == ' ' } + "\t"
val entry = indent + "\"" + key + "\"\t\t\"" + value + "\"" + newline
return block.substring(0, lineStart) + entry + block.substring(lineStart)
}
private fun startupMovieSection(indent: String, path: String, newline: String): String = listOf(
indent + "\"StartupMovie\"",
indent + "{",
indent + "\t\"MovieID\"\t\t\"0\"",
indent + "\t\"LocalPath\"\t\t\"" + path + "\"",
indent + "}",
).joinToString(newline)
/**
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
* folder rather than dug out of app-private storage - but the session script redirects its own
@@ -8,6 +8,8 @@ object SessionPrefs {
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -21,6 +23,27 @@ object SessionPrefs {
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
/** Whether the launcher hides Android's status and navigation bars. */
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
fun setLauncherFullscreen(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
}
/** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
fun setStoreEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */
fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false)
fun setAppImagesEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
fun setHudEnabled(context: Context, on: Boolean) {
@@ -136,8 +159,8 @@ object SessionPrefs {
prefs(context).edit().putBoolean("directAudio", on).apply()
}
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
/** Whether the app has already asked for the microphone once at start-up. */
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
@@ -181,14 +204,28 @@ object SessionPrefs {
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
* it storms, and the session dies seconds after Big Picture appears.
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
* does run, it sits beside every game under FEX for nothing a controller-first session needs
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
*/
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
fun setNoXalia(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
/**
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
* whose GPU is waiting on the CPU.
*/
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
fun setGamescopeRealtime(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
/**
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
*
@@ -204,8 +241,29 @@ object SessionPrefs {
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
}
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
@JvmStatic
fun guestHostname(context: Context): String =
validGuestHostname(prefs(context).getString("guestHostname", null)) ?: DEFAULT_GUEST_HOSTNAME
fun setGuestHostname(context: Context, name: String) {
val valid = validGuestHostname(name)
prefs(context).edit().apply { if (valid == null) remove("guestHostname") else putString("guestHostname", valid) }.apply()
}
fun validGuestHostname(name: String?): String? {
val trimmed = name?.trim() ?: return null
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
}
/**
* Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling.
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
* client's interface as many small render passes, each paying GMEM's load/store and binning,
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
*/
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
fun setTuSysmem(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("tuSysmem", on).apply()
@@ -225,11 +283,23 @@ object SessionPrefs {
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
/** `steam -steamdeck -steamos3`: the client as SteamOS runs it. Expects Deck hardware; untested. */
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
/** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */
/**
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
*/
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
fun setZinkLazy(context: Context, on: Boolean) {
@@ -286,11 +356,20 @@ object SessionPrefs {
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
* given another preset.
*/
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
@@ -306,6 +385,13 @@ object SessionPrefs {
prefs(context).edit().putString("steamChannel", id).apply()
}
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
fun setRunSteamAtStartup(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("runSteamAtStartup", on).apply()
}
/**
* The folders of the user's own Windows games (one game per subfolder), any number of them
* from anywhere on the device. The single folder an earlier build kept is carried in.
@@ -336,8 +422,8 @@ object SessionPrefs {
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
}
/** The app's colour theme (ui/Themes ids); Paper on black unless chosen otherwise. */
fun theme(context: Context): String = prefs(context).getString("theme", "paper") ?: "paper"
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
fun theme(context: Context): String = prefs(context).getString("theme", "graphite") ?: "graphite"
fun setTheme(context: Context, id: String) {
prefs(context).edit().putString("theme", id).apply()
@@ -57,6 +57,9 @@ import java.util.Locale
*/
class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
private var sessionPid = -1
@@ -84,7 +87,7 @@ class SessionService : Service() {
}
}
/** Counts sessions this service has started; a process exit from an earlier one is ignored. */
private var sessionGen = 0
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
@@ -263,150 +266,17 @@ class SessionService : Service() {
killStragglers()
SessionFiles.stage(this, root)
// One folder per session, claimed by whoever started first - the activity starts the
// compositor before this service runs - so the compositor's log lands in the same place.
val sessionDir = SessionPaths.beginOrCurrent(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
SessionState.logFile = sessionLog
SessionState.logDirectory = sessionDir
SessionState.sessionId = sessionDir.name
SessionState.eventsFile = File(sessionDir, "events.jsonl")
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
// Written first, so a session that dies in its first second still says what it ran on.
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
NetworkReport.write(this, File(sessionDir, "network.txt"))
// Everything the app decides from here on - the driver it chose, the audio line, a rival
// client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it.
SessionLogCapture.start(File(sessionDir, "app.log"))
val size = SessionState.outputSize
val guest = ArrayList<String>()
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
// session bundle sit hours off the device's. Bannerlator carries the same line.
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
// Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
// its authors advise for those GPUs and what nothing else in the list needs.
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
// same set-up as a Steam session: it gets what the client and its games are started with.
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (steamHere) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
// the client's - whatever the experiment needs, without a build per attempt.
extraEnv().forEach { guest.add(it) }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
// sent only when it is a real restriction - a game has no pin of its own to undo.
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
// The game cores also pin the desktop and a program from the rail; without a choice the
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
addClientEnvironment(guest, runtimeDir, steamHere)
// PulseAudio always: the client is a native Linux program and has no other way to make a
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
// an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it
// changes what games do and leaves the client alone. The microphone is its own opt-in on
// top, and the helper only opens an input stream when asked - so a user who wants game
// sound but no recording gets exactly that, and Android's recording indicator stays off.
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
// Both paths sit under the app's files directory, which the session binds at its own path,
// so the same string is valid on both sides and nothing has to be translated.
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
val relaySocket = File(audioDir, "relay.sock")
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
relay.attach(this)
components.add(relay)
}
if (wantsDirectAudio) {
// Read by the Proton wrappers, which point Wine at the driver and name it in the
// prefix. Absent, they take an early return and the game uses Proton's own audio - so
// this variable is the whole of the selection.
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
val pulse = startAudio(guest, sessionDir)
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
@@ -424,28 +294,9 @@ class SessionService : Service() {
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
if (controllersOn) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
SessionState.fakeInputDir = fakeInputDir
}
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
@@ -462,6 +313,7 @@ class SessionService : Service() {
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
@@ -506,8 +358,309 @@ class SessionService : Service() {
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = sessionBinds(controllersOn, fakeInputDir)
val command = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = HostEnvironment()
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(this).path
hostEnv["PROOT_TMP_DIR"] = cacheDir.path
// proot links against a libtalloc beside it, and Android's linker does not search an
// executable's own directory: unnamed, the process dies before it starts and says so only
// in `logcat -b crash`.
// proot reads this itself, so it belongs in proot's own environment rather than the guest's.
if (SessionPrefs.prootNoSeccomp(this)) {
hostEnv["PROOT_NO_SECCOMP"] = "1"
Log.i(TAG, "proot: seccomp acceleration off by request")
}
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
val terminalCommand = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, shellGuest,
)
val terminalHostEnvironment = LinkedHashMap(System.getenv())
hostEnv.asArray().forEach { entry ->
val separator = entry.indexOf('=')
if (separator > 0) terminalHostEnvironment[entry.substring(0, separator)] = entry.substring(separator + 1)
}
if (gen == sessionGen && SessionState.running) {
SessionTerminal.prepare(terminalCommand, terminalHostEnvironment.map { (key, value) -> "$key=$value" }.toTypedArray(), root, gen)
}
// Whether the client signs in to Valve or starts offline: read once, while it starts, and
// rewritten by the client when it exits, so it is set again here at every session start.
if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root)
val networkLink = LinuxNetworkLinkComponent(this, root)
networkLink.attach(this)
networkLink.publish()
components.add(networkLink)
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while it was starting; not launching it")
if (gen == sessionGen) components.clear()
return
}
components.forEach { it.start() }
val line = command.joinToString(" ") {
it.replace("\\", "\\\\").replace(" ", "\\ ")
}
watchLaunchRequests(sessionRoot)
// One session replacing another (the desktop's Steam launchers): the old proot is killed
// by the teardown a second after the new one has started, and its exit used to arrive
// here as "session ended: 137" and end the NEW session. An exit belongs to the session
// that started it.
val pid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
if (gen != sessionGen) {
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
return@start
}
Log.i(TAG, "session ended: $status")
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $pid, log ${sessionLog.path}")
if (gen != sessionGen || !SessionState.running) {
Log.i(TAG, "session stopped while its guest was starting; taking it down")
if (gen == sessionGen) {
launchWatcher?.stopWatching()
launchWatcher = null
components.reversed().forEach { runCatching { it.stop() } }
components.clear()
}
if (pid > 1) Thread({ teardown(pid) }, "session-teardown").start()
return
}
sessionPid = pid
if (pid > 1) {
SessionEvents.guestStarted(pid)
} else {
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", pid)
stopSession(-1)
return
}
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
},
resumeAudio = {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
},
)
mainHandler.post { updateSuspendPolicy() }
}
}
/** The session's own log folder, opened and filled with what is known before anything starts. */
private fun openSessionFolder(): File {
// One folder per session, claimed by whoever started first - the activity starts the
// compositor before this service runs - so the compositor's log lands in the same place.
val sessionDir = SessionPaths.beginOrCurrent(this)
val sessionLog = File(sessionDir, "session.log")
SessionState.logFile = sessionLog
SessionState.logDirectory = sessionDir
SessionState.sessionId = sessionDir.name
SessionState.eventsFile = File(sessionDir, "events.jsonl")
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
// Written first, so a session that dies in its first second still says what it ran on.
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
NetworkReport.write(this, File(sessionDir, "network.txt"))
// Everything the app decides from here on - the driver it chose, the audio line, a rival
// client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it.
SessionLogCapture.start(File(sessionDir, "app.log"))
return sessionDir
}
/** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */
private fun addClientEnvironment(guest: MutableList<String>, runtimeDir: File, steamHere: Boolean) {
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
// session bundle sit hours off the device's. Bannerlator carries the same line.
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
// Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
// its authors advise for those GPUs and what nothing else in the list needs.
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
// compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default).
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
guest.add("ZINK_DEBUG=compact")
}
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
// a slice of a core for gamepad navigation the session already has.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// gamescope's realtime Vulkan queues (the session script turns this into
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
// the client's - whatever the experiment needs, without a build per attempt.
extraEnv().forEach { guest.add(it) }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
// sent only when it is a real restriction - a game has no pin of its own to undo.
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
// The game cores also pin the desktop and a program from the rail; without a choice the
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
}
/** PulseAudio, and the DirectAudio relay when a path needs it; returns the daemon for suspend and resume. */
private fun startAudio(guest: MutableList<String>, sessionDir: File): PulseAudioComponent {
// PulseAudio always: the client is a native Linux program and has no other way to make a
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
// an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it
// changes what games do and leaves the client alone. The microphone is its own opt-in on
// top, and the helper only opens an input stream when asked - so a user who wants game
// sound but no recording gets exactly that, and Android's recording indicator stays off.
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
// Both paths sit under the app's files directory, which the session binds at its own path,
// so the same string is valid on both sides and nothing has to be translated.
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
val relaySocket = File(audioDir, "relay.sock")
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
relay.attach(this)
components.add(relay)
}
if (wantsDirectAudio) {
// Read by the Proton wrappers, which point Wine at the driver and name it in the
// prefix. Absent, they take an early return and the game uses Proton's own audio - so
// this variable is the whole of the selection.
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
return pulse
}
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
// not there any more; a client that crashed never took its own down.
fakeInputDir.listFiles()?.forEach { file ->
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
}
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
// In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
// Decided only once its sysfs is in place: without it the client would find no Deck, and
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
if (uinput) {
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
} else if (SessionState.mode == MODE_STEAM) {
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
// for games the client starts only. Everywhere else (the desktop, a program from the
// rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it
// runs under Steam, so every SDL emulator came up with no controller.
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
}
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
SessionState.fakeInputDir = fakeInputDir
}
/** What proot binds into the guest: the pads, the battery, storage, the game library, added games and ROMs. */
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
@@ -563,85 +716,7 @@ class SessionService : Service() {
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
val command = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = HostEnvironment()
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(this).path
hostEnv["PROOT_TMP_DIR"] = cacheDir.path
// proot links against a libtalloc beside it, and Android's linker does not search an
// executable's own directory: unnamed, the process dies before it starts and says so only
// in `logcat -b crash`.
// proot reads this itself, so it belongs in proot's own environment rather than the guest's.
if (SessionPrefs.prootNoSeccomp(this)) {
hostEnv["PROOT_NO_SECCOMP"] = "1"
Log.i(TAG, "proot: seccomp acceleration off by request")
}
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
val terminalCommand = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, shellGuest,
)
val terminalHostEnvironment = LinkedHashMap(System.getenv())
hostEnv.asArray().forEach { entry ->
val separator = entry.indexOf('=')
if (separator > 0) terminalHostEnvironment[entry.substring(0, separator)] = entry.substring(separator + 1)
}
if (gen == sessionGen && SessionState.running) {
SessionTerminal.prepare(terminalCommand, terminalHostEnvironment.map { (key, value) -> "$key=$value" }.toTypedArray(), root, gen)
}
// Whether the client signs in to Valve or starts offline: read once, while it starts, and
// rewritten by the client when it exits, so it is set again here at every session start.
if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root)
val networkLink = LinuxNetworkLinkComponent(this, root)
networkLink.attach(this)
networkLink.publish()
components.add(networkLink)
components.forEach { it.start() }
val line = command.joinToString(" ") {
it.replace("\\", "\\\\").replace(" ", "\\ ")
}
watchLaunchRequests(sessionRoot)
// One session replacing another (the desktop's Steam launchers): the old proot is killed
// by the teardown a second after the new one has started, and its exit used to arrive
// here as "session ended: 137" and end the NEW session. An exit belongs to the session
// that started it.
sessionPid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
if (gen != sessionGen) {
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
return@start
}
Log.i(TAG, "session ended: $status")
stopSession(status ?: -1)
}, null)
Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}")
if (gen == sessionGen && SessionState.running && sessionPid > 1) {
SessionEvents.guestStarted(sessionPid)
} else if (gen == sessionGen && SessionState.running) {
SessionEvents.record("guest.start_failed", mapOf("pid" to sessionPid))
SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", sessionPid)
stopSession(-1)
return
}
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
},
resumeAudio = {
if (!pulse.setSinkSuspended(false)) Log.w(TAG, "could not resume audio sink")
},
)
mainHandler.post { updateSuspendPolicy() }
}
return binds
}
private fun teardown(prootPid: Int, expectedStartTime: Long? = null) {
@@ -699,6 +774,24 @@ class SessionService : Service() {
return try { Pair(fields[1].toInt(), fields[19].toLong()) } catch (e: NumberFormatException) { null }
}
private fun askSteamToExit(prootPid: Int) {
if (!File("/proc/$prootPid").exists()) return
val request = File(LinuxRuntime.sessionRoot(this), "steam-stop")
try {
request.writeText("")
} catch (e: Exception) {
Log.w(TAG, "could not ask the Steam client to exit", e)
return
}
val started = System.currentTimeMillis()
if (waitForExit(prootPid, STEAM_PICKUP_MS) || request.exists()) {
request.delete()
return
}
val exited = waitForExit(prootPid, STEAM_EXIT_MS)
Log.i(TAG, "Steam client ${if (exited) "exited" else "did not exit"} after ${System.currentTimeMillis() - started} ms")
}
private fun waitForExit(pid: Int, timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
@@ -714,6 +807,30 @@ class SessionService : Service() {
* behind, still holding the Wayland socket and the audio server the next session needs. They
* are our uid, so they are ours to kill.
*/
/**
* Every process of ours still running a program out of the Linux runtime once proot is gone.
* proot's --kill-on-exit and the tree sweep only reach what proot still traces; a tracee it
* lost - an Xwayland that aborted from one thread and then looped on a syscall proot's own
* seccomp filter, with no tracer left, answers ENOSYS - survives both, reparented to init,
* and wrote 9 GB of one line into the session log before anything killed it.
*/
private fun killGuestLeftovers() {
val me = android.os.Process.myPid()
val rootfs = try { File(filesDir, "linuxfs").canonicalPath } catch (e: Exception) { return } + "/"
val procs = File("/proc").listFiles { f -> f.name.all { it.isDigit() } } ?: return
var killed = 0
for (proc in procs) {
val pid = proc.name.toIntOrNull() ?: continue
if (pid == me) continue
// Another uid's exe is not ours to read; a process already gone has none.
val exe = try { File(proc, "exe").canonicalPath } catch (e: Exception) { continue }
if (!exe.startsWith(rootfs)) continue
android.os.Process.killProcess(pid)
killed++
}
if (killed > 0) Log.w(TAG, "killed $killed guest process(es) proot no longer tracked")
}
private fun killStragglers() {
val me = android.os.Process.myPid()
val procs = File("/proc").listFiles { f -> f.name.all { it.isDigit() } } ?: return
@@ -826,8 +943,12 @@ class SessionService : Service() {
}
}
private fun finishSessionStop(status: Int) {
private fun finishSessionStop(status: Int, stoppedGen: Int) {
mainHandler.post {
if (stoppedGen != sessionGen || SessionState.running) {
Log.i(TAG, "session $stoppedGen finished stopping after a new one started")
return@post
}
SessionState.suspended = false
SessionState.guestPid = -1
releaseLocks()
@@ -846,8 +967,11 @@ class SessionService : Service() {
}
private fun stopSession(status: Int) {
if (!SessionState.running) return
SessionState.running = false
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
}
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
@@ -885,15 +1009,18 @@ class SessionService : Service() {
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
val finishAfterTeardown: () -> Unit = {
if (prootPid > 1 || auxiliary.isNotEmpty()) {
Thread({
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
finishSessionStop(status)
killGuestLeftovers()
finishSessionStop(status, stoppedGen)
}, "session-teardown").start()
} else {
finishSessionStop(status)
finishSessionStop(status, stoppedGen)
}
}
if (prootPid > 1) acquireLocks()
@@ -1045,8 +1172,14 @@ class SessionService : Service() {
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
/** How long proot gets to run its own cleanup before it is killed outright. */
private const val GRACE_MS = 1200L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
private const val FIRST_VIRTUAL_PAD = 16
const val EXTRA_MODE = "mode"
const val MODE_STEAM = "steam"
@@ -1075,6 +1208,10 @@ class SessionService : Service() {
}
fun stop(context: Context) {
if (!SessionState.running) {
SessionState.stopRequested = true
return
}
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
}
@@ -63,6 +63,27 @@ object SessionState {
* gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */
@JvmStatic var hdr = false
/** The pad is presented to the Steam client as a Steam Deck controller this session
* (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by
* the service as the session starts, which can be after the activity has resumed - hence
* [deckPadListener]. */
@Volatile private var deckPadValue = false
@JvmStatic var deckPad: Boolean
get() = deckPadValue
set(value) {
if (deckPadValue == value) return
deckPadValue = value
deckPadListener?.invoke()
}
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@Volatile var secondScreenDisplay = -1
/** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */
@Volatile
var mode = "steam"
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* The session's pad, described to the Steam client as a Steam Deck controller.
*
* On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller
* through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it
* itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring);
* what the client also needs is the sysfs it walks to find and identify that node, which a sandbox
* shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and
* returns the binds that put it in place:
*
* - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID
* device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`;
* - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists;
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
* runtime already binds there for the GPU);
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
* which libudev's enumeration leaves a device node out.
*
* Symlinks are absolute guest paths; proot resolves them inside the session.
*/
object SteamDeckPad {
private const val TAG = "SessionService"
/** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */
private const val MAJOR = 240
private const val MINOR = 16
private const val NODE = "hidraw$MINOR"
private const val GUEST_DEVICES = "/sys/devices/droiddeck"
private const val USB = "$GUEST_DEVICES/usb1"
private const val INTERFACE = "$USB/1-1:1.2"
private const val HID = "$INTERFACE/0003:28DE:1205.0001"
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
private val REPORT_DESCRIPTOR = intArrayOf(
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
return try {
FileUtils.clear(base)
fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() }
fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
fun link(file: File, target: String) {
file.parentFile?.mkdirs()
file.delete()
Os.symlink(target, file.path)
}
write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n")
write(USB, "idVendor", "28de\n")
write(USB, "idProduct", "1205\n")
write(USB, "bcdDevice", "0100\n")
write(USB, "manufacturer", "Valve Software\n")
write(USB, "product", "Steam Deck Controller\n")
write(USB, "serial", "$SERIAL\n")
link(File(dir(USB), "subsystem"), "/sys/bus/usb")
write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n")
write(INTERFACE, "bInterfaceNumber", "02\n")
write(INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb")
write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" +
"HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" +
"HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n")
File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
link(File(dir(HID), "subsystem"), "/sys/bus/hid")
write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n")
write(HIDRAW, "dev", "$MAJOR:$MINOR\n")
link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e")
emptyList()
}
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.AppImageManager
import java.io.File
/** The imported AppImages and the import in progress, for the Desktop page. */
object AppImageState {
private val main = Handler(Looper.getMainLooper())
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
private set
/** The file being imported and what the import is doing; null when idle. */
var importing by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
/** Why the last import was refused, shown on the page until the next one. */
var lastError by mutableStateOf<String?>(null)
private set
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-refresh").start()
}
fun import(context: Context, file: File) {
if (importing != null) return
val app = context.applicationContext
importing = file.name; stage = "Starting…"; lastError = null
Thread({
val problem = try {
AppImageManager.import(app, file) { s -> main.post { stage = s } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val list = AppImageManager.list(app)
main.post {
importing = null; stage = null; items = list
lastError = problem?.let { "${file.name}: $it" }
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
}
}, "appimage-import").start()
}
fun remove(context: Context, id: String) {
val app = context.applicationContext
Thread({
AppImageManager.remove(app, id)
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-remove").start()
}
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.store
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an
* app's AppStream data. Only what this device can install is kept - apps built for aarch64.
*/
object FlathubApi {
private const val TAG = "FlathubApi"
private const val BASE = "https://flathub.org/api/v2"
private const val ARCH = "aarch64"
private const val TIMEOUT_MS = 20_000
class AppSummary(
val id: String, val name: String, val summary: String, val icon: String?,
val developer: String?, val verified: Boolean, val installs: Int,
)
class Screenshot(val thumb: String, val full: String)
class AppDetails(
val id: String, val name: String, val summary: String, val description: String,
val developer: String?, val icon: String?, val version: String?, val license: String?,
val homepage: String?, val screenshots: List<Screenshot>, val categories: List<String>,
val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List<String>,
)
/** A category as Flathub's search filters it, with the name shown for it. */
class Category(val id: String, val label: String)
val categories = listOf(
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
Category("Utility", "Utilities"),
)
/** Front-page collections: popular, trending, recently-updated, recently-added. */
fun collection(name: String, count: Int = 30): List<AppSummary>? {
// The collections are not filtered by architecture; ask for more and keep what fits.
val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null
return hits(body)?.take(count)
}
/** Search, optionally within one category (Flathub's main_categories facet). */
fun search(query: String, category: String? = null, count: Int = 48): List<AppSummary>? {
val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH))
if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category))
val payload = JSONObject().put("query", query).put("filters", filters)
.put("hits_per_page", count).put("page", 1)
val body = post("$BASE/search", payload.toString()) ?: return null
return hits(body)
}
/** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */
fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png"
fun details(id: String): AppDetails? {
val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() }
val shots = app.optJSONArray("screenshots")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null
val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) }
.map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") }
.filter { it.second.startsWith("http") }.sortedBy { it.first }
if (all.isEmpty()) null
else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second,
all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second)
}
} ?: emptyList()
val release = app.optJSONArray("releases")?.optJSONObject(0)
val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
return AppDetails(
id = app.optString("id", id),
name = app.optString("name", id),
summary = app.optString("summary"),
description = plainText(app.optString("description")),
developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
icon = app.optString("icon").takeIf { it.startsWith("http") },
version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" },
license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" },
homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") },
screenshots = shots,
categories = cats,
runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() },
downloadSize = summary?.optLong("download_size") ?: 0L,
installedSize = summary?.optLong("installed_size") ?: 0L,
arches = arches,
)
}
private fun hits(body: String): List<AppSummary>? = try {
val arr = JSONObject(body).getJSONArray("hits")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val arches = o.optJSONArray("arches")
if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null
if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null
AppSummary(
id = o.optString("app_id"),
name = o.optString("name"),
summary = o.optString("summary"),
icon = o.optString("icon").takeIf { it.startsWith("http") },
developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
verified = o.optBoolean("verification_verified"),
installs = o.optInt("installs_last_month"),
)
}.filter { it.id.isNotEmpty() }
} catch (e: Exception) {
Log.w(TAG, "hits: $e"); null
}
/** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */
internal fun plainText(html: String): String = html
.replace(Regex("(?i)<li>\\s*"), "• ")
.replace(Regex("(?i)</(p|li|ul|ol)>"), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.lines().map { it.trim().replace(Regex("\\s+"), " ") }
.joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim()
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
private fun get(url: String): String? = request(url, null)
private fun post(url: String, json: String): String? = request(url, json)
private fun request(url: String, json: String?): String? {
var c: HttpURLConnection? = null
return try {
c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("Accept", "application/json")
if (json != null) {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
outputStream.use { it.write(json.toByteArray()) }
}
}
if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null }
else c.inputStream.bufferedReader().use { it.readText() }
} catch (e: Exception) {
Log.w(TAG, "$url: $e"); null
} finally {
c?.disconnect()
}
}
}
@@ -0,0 +1,142 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.FlatpakManager
/**
* The store's state for the life of the process, so an install carries on - and its progress
* shows - while the user moves around the launcher or plays something meanwhile.
*/
object StoreState {
private val main = Handler(Looper.getMainLooper())
var ready by mutableStateOf(false)
private set
var installed by mutableStateOf<List<FlatpakManager.App>>(emptyList())
private set
var updates by mutableStateOf<Set<String>>(emptySet())
private set
var checkingUpdates by mutableStateOf(false)
private set
/** Flathub has been asked this session, so an empty [updates] means up to date. */
var updatesChecked by mutableStateOf(false)
private set
/** What is running: "setup", an app id, or "update-all"; null when idle. */
var busy by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
var percent by mutableStateOf(-1)
private set
/** The front page's sections, by collection name; absent until loaded, empty list on failure. */
val sections = mutableStateMapOf<String, List<FlathubApi.AppSummary>>()
var sectionsFailed by mutableStateOf(false)
private set
val details = mutableStateMapOf<String, FlathubApi.AppDetails>()
val detailsFailed = mutableStateMapOf<String, Boolean>()
var searchResults by mutableStateOf<List<FlathubApi.AppSummary>?>(null)
private set
var searching by mutableStateOf(false)
private set
private var searchSeq = 0
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val r = FlatpakManager.ready(app)
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() }
}, "store-refresh").start()
}
fun loadSections(force: Boolean = false) {
if (!force && sections.isNotEmpty()) return
sectionsFailed = false
Thread({
var failed = false
for ((key, _) in SECTIONS) {
val apps = FlathubApi.collection(key, 18)
if (apps == null) failed = true
main.post { sections[key] = apps ?: emptyList() }
}
main.post { sectionsFailed = failed }
}, "store-sections").start()
}
fun loadDetails(id: String) {
if (details.containsKey(id)) return
detailsFailed.remove(id)
Thread({
val d = FlathubApi.details(id)
main.post { if (d != null) details[id] = d else detailsFailed[id] = true }
}, "store-details").start()
}
fun search(query: String, category: String?) {
val seq = ++searchSeq
searching = true
Thread({
val r = FlathubApi.search(query, category)
main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } }
}, "store-search").start()
}
fun clearSearch() { searchSeq++; searchResults = null; searching = false }
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
if (busy != null) return
val app = context.applicationContext
busy = what; stage = "Starting…"; percent = -1
Thread({
val problem = try {
work(app) { s, p -> main.post { stage = s; percent = p } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val r = FlatpakManager.ready(app)
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
main.post {
busy = null; stage = null; percent = -1
ready = r; installed = list
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
}
}, "store-$what").start()
}
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
fun install(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
fun uninstall(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } }
fun update(context: Context, id: String?, name: String) =
run(context, id ?: "update-all", name) { c, p ->
FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } }
}
fun checkUpdates(context: Context) {
if (checkingUpdates || busy != null) return
val app = context.applicationContext
checkingUpdates = true
Thread({
val u = FlatpakManager.updates(app)
main.post {
checkingUpdates = false
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
}
}, "store-updates").start()
}
}
@@ -0,0 +1,434 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
import java.text.DateFormat
import java.util.Date
/** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */
private class InstalledItem(
val name: String, val detail: String, val tag: String,
val selected: Boolean, val removable: Boolean,
/** Stored package file, or null for an original. */
val file: String?,
/** The Proton build an original belongs to. */
val protonVersion: String?,
)
private val GOLD = Color(0xFFF2C66D)
private val ROW_SHAPE = RoundedCornerShape(10.dp)
/**
* FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry.
* A title row with the Nightlies refresh, then the Proton and the component (LB / RB turn it), what
* is in use, and the installed packages beside the ones on the Nightlies - stacked when the page is
* narrow. Tapping an installed row swaps it in; the refresh button is the only thing that goes online.
*/
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
fun ComponentsPage(
snapshot: Snapshot?,
catalog: List<CatalogItem>,
catalogAt: Long,
protonId: String?,
comp: String,
checking: Boolean,
busy: String?,
downloads: Map<String, Int>,
onProton: (String) -> Unit,
onComp: (String) -> Unit,
onSwap: (String) -> Unit,
onRestore: (String) -> Unit,
onCancelQueued: () -> Unit,
onDeletePackage: (String) -> Unit,
onDeleteOriginal: (String) -> Unit,
onDownload: (CatalogItem) -> Unit,
onRefresh: () -> Unit,
onImport: () -> Unit,
onBack: () -> Unit,
requestInitialFocus: Boolean = true,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
BackHandler(onBack = onBack)
var confirm by remember { mutableStateOf<Pair<String, () -> Unit>?>(null) }
var confirmTitle by remember { mutableStateOf("") }
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
val label = ComponentsManager.LABEL[comp] ?: comp
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
// ---- title, and the one thing that goes online -----------------------------------------
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Text(
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 1, modifier = Modifier.weight(1f),
)
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
// ---- which Proton, which component ------------------------------------------------------
val comps = ComponentsManager.COMPONENTS
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (view != null) Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
views.forEachIndexed { i, v ->
val swaps = v.components.values.count { it.activeFile != null }
MenuItem(
v.proton.name, checked = v.proton.id == view.proton.id,
detail = v.proton.version + (if (swaps > 0) " · $swaps swapped" else " · all original") + (if (v.inUseByGame) " · game running" else ""),
focusRequester = if (i == 0) first else null,
) { onProton(v.proton.id); protonMenu = false }
}
}
}
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
}
// ---- what is in use -----------------------------------------------------------------------
if (view != null) {
val st = view.components.getValue(comp)
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface)
.border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp),
) {
Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape))
Text(
(if (st.queued != null) "Next: ${st.queued} · after the game closes" else "$label in use: ${st.inUse}") + fixedAt,
fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
if (st.queued != null) FocusText("Cancel", pal.signal, onClick = onCancelQueued)
else if (!narrow) Text(
if (view.inUseByGame) "Waits until the game closes" else "Changes apply the next time a game starts",
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1,
)
}
}
// ---- the lists ------------------------------------------------------------------------------
when {
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
Text("Reading the Protons…", fontSize = 14.sp, color = colors.onSurfaceVariant)
}
view == null -> Text(
"No Proton is installed in the Linux runtime yet. Start the Steam client once so it downloads its ARM64 Proton, or add GE-Proton / CachyOS from Setup.",
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp),
)
else -> {
val p = view.proton
val st = view.components.getValue(comp)
val build = ComponentsManager.safeName(p.version)
val originals = view.originals.filter { it.comp == comp }
val stored = snapshot.packages.filter { it.comp == comp }
val activeIsOriginal = st.activeFile == null
val rows = originals.map { o ->
val isCurrent = o.protonVersion == build
InstalledItem(
"Original · ${o.protonVersion}",
o.label.substringAfterLast(" · ", "").ifEmpty { o.label } + if (isCurrent) " · this build" else " · earlier build",
"ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion,
)
} + stored.map { s ->
InstalledItem(s.version, String.format("%.1f MB", s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null)
}
val storedNames = snapshot.packages.map { it.file }.toSet()
val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames }
val first = remember { FocusRequester() }
LaunchedEffect(p.id, comp, requestInitialFocus) {
if (requestInitialFocus) runCatching { first.requestFocus() }
}
val installed: @Composable () -> Unit = {
SettingsGroup("Installed · ${rows.size}") {
if (rows.isEmpty()) Text(
"Nothing stored for $label yet. This Proton's own files are saved as its original when the page opens.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
)
rows.forEachIndexed { i, item ->
InstalledLine(
item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier,
onSelect = {
if (item.selected) return@InstalledLine
val waits = if (view.inUseByGame) " A game is running on this Proton, so it waits until the game closes." else " It applies the next time a game starts."
if (item.file != null) ask("Swap $label?", "Put ${item.name} into ${p.name}? Its shipped files stay saved as an original bundle.$waits") { onSwap(item.file) }
else ask("Restore $label?", "Put the ${item.protonVersion} original back into ${p.name}?$waits") { onRestore(item.protonVersion!!) }
},
onDelete = {
if (item.file != null) ask("Delete ${item.name}?", "Its package file is removed from the app. You can download it again any time.") { onDeletePackage(item.file) }
else ask("Delete this original?", "The ${item.protonVersion} original of $label belongs to an earlier build of ${p.name}. The installed build's original is always kept.") { onDeleteOriginal(item.protonVersion!!) }
},
)
}
}
}
val nightly: @Composable () -> Unit = {
SettingsGroup("On the Nightlies · ${available.size}") {
if (available.isEmpty()) Text(
if (catalogAt == 0L) "Refresh to list the packages on the Nightlies." else "Nothing new since the last check.",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
)
available.forEach { d -> AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } }
}
}
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
if (narrow) { installed(); nightly() }
else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) { installed() }
Column(Modifier.weight(1f)) { nightly() }
}
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) {
SmallButton("Import .wcp", onClick = onImport)
if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText("Restore this Proton's original", pal.signal) {
ask("Restore $label?", "Put ${p.name}'s original $label back?") { onRestore(build) }
}
}
}
}
}
}
if (about) AlertDialog(
onDismissRequest = { about = false },
title = { Text("Components") },
text = {
Text(
"FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" +
"Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" +
"Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\n\n" +
"Each Proton build's own files are kept as its original, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.",
fontSize = 14.sp,
)
},
confirmButton = { FocusText("OK", pal.signal) { about = false } },
)
confirm?.let { (body, action) ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(confirmTitle) },
text = { Text(body, fontSize = 14.sp) },
// Opens on Cancel, so a stray A press on a controller never swaps or deletes anything.
confirmButton = {
val del = confirmTitle.startsWith("Delete")
FocusText(if (del) "Delete" else if (confirmTitle.startsWith("Restore")) "Restore" else "Swap", if (del) colors.error else pal.signal) { confirm = null; action() }
},
dismissButton = {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
FocusText("Cancel", colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
},
)
}
}
@Composable
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
if (busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
else Icon(icon, contentDescription = description, tint = colors.onBackground, modifier = Modifier.size(20.dp))
}
}
/**
* A small button. Resting: grey outline (blue text when [accent]). Focused or hovered: a solid blue
* fill with an outline in the text colour - unmistakable in a column of identical buttons.
*/
@Composable
private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = if (!enabled) colors.onSurfaceVariant else if (hot) pal.onSignal else if (accent) pal.signal else colors.onBackground,
)
}
}
@Composable
private fun Tag(text: String) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val (fg, bg) = when (text) {
"ORIGINAL" -> GOLD to Color(0x22F2C66D)
"IN USE" -> pal.onSignal to pal.signal
"NEW" -> pal.good to Color(0x224CD37F)
else -> colors.onSurfaceVariant to Color.White.copy(alpha = 0.07f)
}
Text(
text.lowercase().replaceFirstChar { it.uppercase() }, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(bg).padding(horizontal = 7.dp, vertical = 1.dp),
)
}
@Composable
private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent),
) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) {
if (item.selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape))
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
Tag(item.tag)
}
Text(item.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp))
}
if (item.selected) Tag("IN USE")
}
// The trash column is always there (empty when the row can't be deleted) so rows line up.
if (!item.removable) Spacer(Modifier.padding(end = 6.dp).size(40.dp))
else {
val delSrc = remember { MutableInteractionSource() }
val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
.controllerConfirm(onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@Composable
private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp),
) {
Column(Modifier.weight(1f)) {
Text(d.file.removeSuffix(".wcp"), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
"${d.release} · " + String.format("%.1f MB", d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp),
)
}
if (progress == null) SmallButton("Download", enabled = enabled, accent = true, onClick = onDownload)
else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth())
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@@ -41,12 +41,12 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
SettingsRow("Colour", "Tint of the on-screen buttons", highlighted = tintOpen) {
SettingsRow("Color", "Tint of the on-screen buttons", highlighted = tintOpen) {
Box {
ValueChip(ControllerPrefs.tints.firstOrNull { it.first == c.tint }?.second ?: "Custom", tintOpen) {
host.open = if (tintOpen) null else "controller-tint"
}
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Colour") { firstItemFocus ->
AnchoredMenu(tintOpen, onDismiss = { if (host.open == "controller-tint") host.open = null }, title = "Color") { firstItemFocus ->
ControllerPrefs.tints.forEachIndexed { index, (color, name) ->
MenuItem(name, checked = c.tint == color, leading = { Swatch(color) }, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTint(color)
@@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centres where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "Sticks appear when touched near their saved positions and hide when released", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton("Edit") { a.onEditLayout() }
@@ -68,7 +68,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
}
val remapped = c.mapping.count { (id, target) -> id != target }
ActionRow("Button mapping", if (remapped == 0) "Every button sends its own input" else "$remapped of ${c.mapping.size} buttons remapped", "Configure", a.onMapping)
ActionRow("Reset controller", "Restore the default colour, opacity, size, stick behaviour, mapping and layout", "Reset", a.onResetAll)
ActionRow("Reset controller", "Restore the default color, opacity, size, stick behavior, mapping and layout", "Reset", a.onResetAll)
}
@Composable
@@ -0,0 +1,351 @@
package com.droiddeck.launcher.ui
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.RectF
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
import kotlin.math.min
/**
* The Steam Deck controller's back grips and trackpads, on the second screen.
*
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
* where something is being touched.
*/
@SuppressLint("ViewConstructor")
class DeckControlsPanel(
context: Context,
private val onClose: () -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
) : LinearLayout(context) {
private enum class Tab(val label: String) { GRIPS("Grips"), LEFT("Left pad"), RIGHT("Right pad"), BOTH("Both") }
private val content = FrameLayout(context)
private val tabViews = HashMap<Tab, TextView>()
init {
orientation = VERTICAL
setBackgroundColor(Color.BLACK)
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
select(selected)
}
override fun onDetachedFromWindow() {
DeckControls.releaseAll()
super.onDetachedFromWindow()
}
private fun topBar(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(12), dp(10), dp(12), dp(6))
addView(roundButton("×", "Close second-screen controls", onClose))
val tabs = LinearLayout(context).apply {
orientation = HORIZONTAL
background = outline(CORNER_PILL, SURFACE)
setPadding(dp(4), dp(4), dp(4), dp(4))
for (tab in Tab.values()) {
val view = TextView(context).apply {
text = tab.label
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
isSingleLine = true
setPadding(dp(14), 0, dp(14), 0)
setOnClickListener { select(tab) }
}
tabViews[tab] = view
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
}
}
val centre = FrameLayout(context).apply {
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER))
}
addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
addView(roundButton("STEAM", "Open Steam menu", onSteamMenu, wide = true))
addView(space(dp(8)))
addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true))
}
private fun select(tab: Tab) {
selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
content.removeAllViews()
content.addView(
when (tab) {
Tab.GRIPS -> gripsGrid()
Tab.LEFT -> singlePad(right = false)
Tab.RIGHT -> singlePad(right = true)
Tab.BOTH -> bothPads()
},
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
)
}
private fun gripsGrid(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
setPadding(dp(12), dp(6), dp(12), dp(12))
fun column(upper: View, lower: View) = LinearLayout(context).apply {
orientation = VERTICAL
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
}
addView(column(GripButton(context, "L4", "Left · upper", DeckControls.L4), GripButton(context, "L5", "Left · lower", DeckControls.L5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(column(GripButton(context, "R4", "Right · upper", DeckControls.R4), GripButton(context, "R5", "Right · lower", DeckControls.R5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
}
private fun bothPads(): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
val pads = LinearLayout(context).apply {
orientation = HORIZONTAL
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
// Mirrored, as hands hold them: the upper grips outside, the lower ones in the middle.
val grips = LinearLayout(context).apply {
orientation = HORIZONTAL
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
row.forEachIndexed { index, (label, bit) ->
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
if (index > 0) leftMargin = dp(6)
if (index < row.size - 1) rightMargin = dp(6)
})
}
}
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
}
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
TextView(context).apply {
text = label
contentDescription = description
textSize = if (wide) 13f else 20f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = if (wide) 0.08f else 0f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(CORNER_PILL, SURFACE)
setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0)
minWidth = dp(44)
layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44))
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) }
private fun outline(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
setStroke(dp(1), LINE)
}
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()
/** A surface drawn the same way everywhere on the panel; pressed, it lights up in the accent. */
private abstract class Surface(context: Context) : View(context) {
protected val density = resources.displayMetrics.density
protected val rect = RectF()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
color = TEXT_DIM
}
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
val inset = density
rect.set(inset, inset, width - inset, height - inset)
val radius = CORNER * density
fill.color = if (lit) ACCENT_WASH else SURFACE
canvas.drawRoundRect(rect, radius, radius, fill)
stroke.color = if (lit) ACCENT else LINE
stroke.strokeWidth = (if (lit) 2f else 1f) * density
canvas.drawRoundRect(rect, radius, radius, stroke)
}
}
/** A held button: down while a finger is on it. */
private open class HoldButton(
context: Context,
private val label: String,
private val caption: String?,
private val onChange: (Boolean) -> Unit,
) : Surface(context) {
private var down = false
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, down)
val cx = width / 2f
labelPaint.textSize = min(height * 0.3f, 30 * density)
labelPaint.color = if (down) ACCENT else TEXT
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
canvas.drawText(label, cx, labelY, labelPaint)
if (caption != null) {
captionPaint.textSize = 12 * density
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> set(true)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
}
return true
}
private fun set(pressed: Boolean) {
if (down == pressed) return
down = pressed
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onChange(pressed)
invalidate()
}
}
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
private class ClickBar(context: Context, right: Boolean) :
HoldButton(context, "Click", if (right) "Right trackpad" else "Left trackpad", { DeckControls.setClick(right, it) })
/**
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
* while a second finger is down.
*/
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
private var fingerY = 0f
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicked)
val cx = width / 2f
val cy = height / 2f
guide.strokeWidth = density
val reach = min(width, height) / 2f - 24 * density
canvas.drawCircle(cx, cy, reach, guide)
canvas.drawCircle(cx, cy, reach / 2f, guide)
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
canvas.drawText(if (right) "RIGHT TRACKPAD" else "LEFT TRACKPAD", cx, 28 * density, captionPaint)
if (touching) {
val radius = 56 * density
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
canvas.drawCircle(fingerX, fingerY, radius, glow)
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
touch(event.getX(0), event.getY(0))
click(event.pointerCount > 1)
}
MotionEvent.ACTION_POINTER_DOWN -> click(true)
MotionEvent.ACTION_POINTER_UP -> {
// The finger that lifted may be the first; the pad follows whichever stays.
val stays = if (event.actionIndex == 0) 1 else 0
touch(event.getX(stays), event.getY(stays))
click(event.pointerCount - 1 > 1)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
click(false)
touching = false
DeckControls.setPad(right, touching = false)
invalidate()
}
}
return true
}
private fun touch(x: Float, y: Float) {
fingerX = x.coerceIn(0f, width.toFloat())
fingerY = y.coerceIn(0f, height.toFloat())
touching = true
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
invalidate()
}
private fun click(down: Boolean) {
if (clicked == down) return
clicked = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
DeckControls.setClick(right, down)
invalidate()
}
}
companion object {
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
private var selected = Tab.BOTH
private const val CORNER = 20f
private const val CORNER_PILL = 22f
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
}
}
@@ -194,12 +194,12 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false),
)
if (row.tag.isNotEmpty()) Text(
row.tag, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
row.tag, fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).clip(RoundedCornerShape(5.dp)).background(Color.White.copy(alpha = 0.07f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
}
}
if (row.removable) {
@@ -221,7 +221,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit
/** A text button that shows where controller focus is: an outline in its own colour. */
@Composable
private fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
@@ -0,0 +1,22 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import com.droiddeck.launcher.gpu.FrameGen
/**
* The frame generation picker, anchored to whatever opened it (host key "fg"). One copy for Setup,
* a game's launch settings and the session drawer, so they say the same thing.
*/
@Composable
internal fun FrameGenMenu(
host: MenuHost, engine: String, multiplier: Int, lsfgReady: Boolean,
onPick: (engine: String, multiplier: Int) -> Unit,
) {
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (lsfgReady) null else "Needs Lossless Scaling installed in Steam"
fun pick(e: String, m: Int) { onPick(e, m); host.open = null }
MenuItem("Off", checked = engine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { pick(FrameGen.ENGINE_OFF, 2) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_WINFG, m), checked = engine == FrameGen.ENGINE_WINFG && multiplier == m) { pick(FrameGen.ENGINE_WINFG, m) }
for (m in 2..4) MenuItem(FrameGen.label(FrameGen.ENGINE_LSFG, m), checked = engine == FrameGen.ENGINE_LSFG && multiplier == m, enabled = lsfgReady, detail = need) { pick(FrameGen.ENGINE_LSFG, m) }
}
}
@@ -0,0 +1,186 @@
package com.droiddeck.launcher.ui
import android.graphics.Bitmap
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import java.io.File
// Cover art: posters, the art grid and its tiles.
internal class Tile(
val title: String, val sub: String?, val art: File?, val key: String,
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
val showFooter: Boolean = true,
val onClick: () -> Unit,
)
@Composable
internal fun Poster(art: File?, name: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) }
Box(
modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f)
.graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false }
.clip(Shape12).background(artBrush(hueOf(name))),
) {
if (art != null) CoverImage(art, Modifier.fillMaxSize())
else Text(name, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) }
// Big enough to recognise a game by its art and read its name under it; icon tiles are squares.
val minSize = if (square) 76.dp else if (wide) 120.dp else 96.dp
val gap = 12.dp
// Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy
// grid composes only the rows on screen, so a press towards the next row bounced back among
// the visible tiles. A few hundred tiles lay out fine; the page's scroll follows the focused one.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val avail = maxWidth - 8.dp
val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1)
val tileWidth = (avail - gap * (cols - 1)) / cols
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) {
for (row in tiles.chunked(cols)) {
Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) {
for (t in row) key(t.key) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
}
}
}
}
}
}
@Composable
private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableInteractionSource, hot: Boolean, track: Modifier) {
val colors = MaterialTheme.colorScheme
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "tileScale")
val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift")
val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev")
val pal = LocalPalette.current
val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing")
Column(
modifier = track
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) }
.clip(Shape12)
.background(colors.surface)
.border(2.5.dp, ring, Shape12)
.alpha(if (t.dim && !hot) 0.55f else 1f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
) {
Box(modifier = Modifier.fillMaxWidth().shine(hot)) {
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap)
}
if (t.showFooter) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (t.sub != null) Text(t.sub, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
/**
* Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header -
* is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop.
*/
@Composable
private fun CoverImage(art: File, modifier: Modifier) {
var wideArt by remember(art) { mutableStateOf(false) }
Box(modifier) {
if (wideArt) {
AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp),
)
Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)))
}
AsyncImage(
model = art, contentDescription = null,
contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop,
onSuccess = { state ->
val size = state.painter.intrinsicSize
if (size.width > size.height * 1.1f) wideArt = true
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
val colors = MaterialTheme.colorScheme
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
when {
art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
else CoverImage(art, Modifier.fillMaxSize())
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
else -> {
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f))))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(6.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
}
@@ -0,0 +1,416 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.CompositionLocalProvider
import android.os.Build
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import java.io.File
// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators.
@Composable
internal fun Pane(
s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier,
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) {
val backdropArt: File? = when {
s.pageKey != null && page != null -> null
selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }?.art
selected == "games" -> s.steamGames.maxByOrNull { it.lastPlayed }?.art
selected.startsWith("rom:") -> romFor(s, selected)?.second?.art
else -> null
}
Backdrop(backdropArt)
AnimatedContent(
// Picking another game changes the detail beside the list, not the whole page.
targetState = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected,
transitionSpec = {
(fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
.apply { targetContentZIndex = 1f }
},
label = "pane",
) { key ->
if (page != null && key == s.pageKey) page()
else Content(s, if (key == "games") selected else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
}
}
}
}
@Composable
private fun Backdrop(art: File?) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Crossfade(targetState = art, animationSpec = Motion.tw(700), label = "backdrop") { a ->
// A game's own art, blurred, where it has some; otherwise the plain ground with one quiet
// glow of the theme's signal colour - a colour per title hash read as noise.
if (a != null && Build.VERSION.SDK_INT >= 31) {
Box(modifier = Modifier.fillMaxSize().alpha(0.26f).blur(70.dp)) {
AsyncImage(model = a, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f })
}
} else {
Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(pal.signal.copy(alpha = 0.07f), Color.Transparent), center = Offset.Zero, radius = 1400f)))
}
}
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f)))))
}
private fun romFor(s: FrontEndState, selected: String): Pair<Library.Emulator, Library.Rom>? {
val parts = selected.split(":")
val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } ?: return null
val g = e.games.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) ?: return null
return e to g
}
@Composable
private fun Content(
s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier,
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
// Setup scrolls inside itself, under its tabs.
if (selected == "setup") {
Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) {
SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
}
return
}
// Steam is a full-bleed wall of the library with Play over it.
if (selected == "steam") {
SteamHome(s, a, modifier)
return
}
// The Store scrolls and navigates inside itself (its tabs and an app's page).
if (selected == "store" && s.storeEnabled) {
StorePage(s, a, modifier)
return
}
// The Games tab lays out its own list and detail.
if (selected == "games" || selected.startsWith("app:")) {
GamesPage(s, a, selected, onSelect, modifier)
return
}
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
when {
selected == "android-apps" && s.isHomeApp -> {
Rise(0) { PageHeader("Android apps") { Chip("${s.androidApps.size} apps", ok = false) } }
if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") }
else Rise(3, Modifier.fillMaxWidth()) {
ArtGrid(s.androidApps.map { app ->
Tile(
app.label,
null,
null,
"android:${app.packageName}",
onClick = { onAndroidAppClick(app) },
iconBitmap = app.icon,
)
})
}
}
selected == "desktop" -> {
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
Rise(0) {
PageHeader("Desktop") {
if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false)
}
}
Rise(2) { DesktopCard(s, a) }
if (installed.isNotEmpty()) {
Rise(3) { SectionTitle("Emulators", "${installed.size} installed") }
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
}
if (s.storeEnabled) Rise(5) { InstalledAppsGrid(a) }
if (s.appImagesEnabled) Rise(5) { AppImagesSection(a, s.ready) }
if (available.isNotEmpty()) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
}
}
selected.startsWith("emu:") -> {
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
if (e == null) Note("Not installed.") else {
val pkgId = Library.packageId(e.id)
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
Rise(0) {
BackLink("Desktop") { onSelect("desktop") }
}
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
) {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(
e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed",
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
}
}
if (e.installed) {
Rise(3) {
Actions {
PrimaryButton("Open ${e.name}", enabled = !s.busy, main = true) { a.onEmulator(e) }
BusyChip(s)
SecondaryButton("ROMs folder", onClick = a.onRoms)
if (pkg != null) SecondaryButton(
if (pkg.kind == "appimage") "Remove" else "Hide",
enabled = s.packageBusyId == null && !s.sessionRunning,
) { a.onRemovePackage(pkg.id) }
}
}
Rise(4) { SectionTitle("Games", e.games.size.toString()) }
if (e.games.isEmpty()) Rise(5) {
Note(
if (s.romsDir == null) "Choose a ROMs folder."
else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch."
else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.",
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
}
} else {
if (pkg != null) Rise(2) {
Actions {
PrimaryButton(
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}",
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
) { a.onInstallPackage(pkg.id) }
if (s.sessionRunning) ActionChip("Stop session to install", ok = false)
else if (!s.ready) ActionChip("Runtime required", ok = false)
}
}
Rise(3) {
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
Note(when {
s.packageCatalogLoading -> "Loading install details…"
pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable."
!s.ready -> "Install the Linux runtime from Setup before installing desktop apps."
s.sessionRunning -> "Stop the active session before installing desktop apps."
pkg.notes.isNotBlank() -> pkg.notes
else -> "Install ${e.name} into the Linux desktop runtime."
})
}
}
if (s.packageBusyId == pkg?.id) Rise(4) {
val stage = s.packageStage
Text(
if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp),
)
if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
if (pkg?.kind == "tar") Rise(5) { Note("Hiding this package takes it off Desktop; its files stay in the Linux runtime.") }
}
}
}
selected.startsWith("rom:") -> {
val pair = romFor(s, selected)
if (pair == null) Note("That game is gone from the ROMs folder.") else {
val (e, g) = pair
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow("Desktop · ${e.system}")
}
}
Rise(1) { Title(g.name) }
Rise(2) {
Row {
Column(modifier = Modifier.weight(1f)) {
Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp))
Spacer(Modifier.height(14.dp))
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
PrimaryButton("Launch in ${e.name}", enabled = !s.busy, main = true) { a.onRom(g) }
if (s.busy) BusyChip(s) else ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
}
}
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
}
}
val others = e.games.filter { it !== g }
if (others.isNotEmpty()) {
Rise(3) { SectionTitle("Also in ${e.name}", null) }
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
}, wide = others.none { it.art != null })
}
}
}
}
else -> Note("That page is gone. Pick a section on the left.")
}
}
}
/** The Linux desktop itself, above the emulators that run in it. */
@Composable
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
BusyChip(s)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
if (narrow) actions()
}
}
/** Emulators as wide tiles - three across, or a list on a narrow page. */
@Composable
private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSelect: (String) -> Unit) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
emulators.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, e ->
key(e.id) {
EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") }
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
/**
* One emulator: its icon, system and games. One not yet installed says so in words and keeps full
* contrast - dimming it read as disabled rather than one press from installing.
*/
@Composable
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "emuScale")
val system = e.system.replaceFirstChar { it.uppercase() }
val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:emu:${e.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (!e.installed) Text(
"Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@@ -0,0 +1,316 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.frontend.Library
// The Games page: the list, the hero for the selected game and its labels.
/**
* The Games tab: installed Steam games down the left, most recently played first, and the one
* picked beside them - its banner, Launch and the launch settings. One game skips the list.
*/
@Composable
internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val narrow = LocalNarrowPane.current
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
if (current == null) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader("Games") }
Rise(1) { Note("Games you install in Steam show up here. Open Steam, install one, and it appears after the session.") }
Rise(2) {
Actions { PrimaryButton("Play Steam", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
}
return
}
val host = rememberMenuHost()
if (games.size == 1) {
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
GameActions(current, s, a)
}
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
}
Rise(1) { SectionTitle("Launch settings", null) }
Rise(2) { LaunchSettings(s, a, host) }
}
return
}
val pal = LocalPalette.current
Row(modifier = modifier) {
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) },
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(
modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState())
.padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp),
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a)
}
SectionTitle("Launch settings", null)
LaunchSettings(s, a, host)
}
}
}
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
Actions {
PrimaryButton("Launch", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Game files", compact = true) { a.onBrowseFiles(dir) }
}
g.protonPrefix?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton("Proton prefix", compact = true) { a.onBrowseFiles(dir) }
// Only games added to the library; Steam titles keep their saves with Steam Cloud.
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
}
BusyChip(s)
}
}
/**
* Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as
* either, or open the save folder found for it. The saves are looked for when the menu opens.
*/
@Composable
private fun ManageSaves(g: Library.SteamGame, prefix: java.io.File, a: FrontEndActions) {
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
val saves by androidx.compose.runtime.produceState<List<com.droiddeck.launcher.session.GameSaves.SaveDir>?>(null, g.gameId, open) {
if (open) value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
runCatching { com.droiddeck.launcher.session.GameSaves.locate(prefix, g) }.getOrDefault(emptyList())
}
}
val found = saves
val summary = when {
found == null -> "Looking for this game's saves…"
found.isEmpty() -> "No save folder found by name: an export takes the whole user folder."
else -> {
val mb = found.sumOf { it.bytes } / 1048576.0
(if (found.size == 1) found[0].relPath else "${found.size} save folders") + " · ${found.sumOf { it.files }} files · " +
(if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb))
}
}
Box {
SecondaryButton("Manage saves", compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = "Game saves", note = summary) { first ->
MenuItem("Import saves…", checked = false, detail = "A GameHub or Winlator zip · the saves there now are backed up first", focusRequester = first) {
open = false; a.onSaveImport(g)
}
MenuItem("Export as GameHub zip…", checked = false, detail = "Saves under steamuser, for GameHub and BannerHub") {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.GAMEHUB)
}
MenuItem("Export as Winlator zip…", checked = false, detail = "Saves under xuser, for Winlator, WinNative and Bannerlator") {
open = false; a.onSaveExport(g, com.droiddeck.launcher.session.GameSaves.Layout.WINLATOR)
}
found?.firstOrNull()?.let { d ->
MenuItem("Open save folder", checked = false, detail = d.relPath) {
open = false; a.onBrowseFiles(java.io.File(prefix, "drive_c/users/steamuser/" + d.relPath))
}
}
}
}
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
Text(
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
@Composable
private fun GameList(
games: List<Library.SteamGame>, current: Library.SteamGame,
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
// Laid out whole, as the art grid is: the pad's focus search only finds rows that exist.
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
}
for (g in games) key(g.appId) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
}
}
}
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
@Composable
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
LaunchedEffect(focused) { if (focused && !selected) onSelect() }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
.clip(Shape12)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
.controllerConfirm(onClick = onLaunch)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) {
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
}
Column(modifier = Modifier.weight(1f)) {
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played",
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
/** A page's title, with room at its right for a status chip. */
@Composable
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
) {
Text(
title, fontSize = if (LocalNarrowPane.current) 22.sp else 26.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 1,
)
trailing()
}
}
/** Why a launch button is greyed out: the runtime is being worked on. Nothing when it is not. */
@Composable
internal fun BusyChip(s: FrontEndState) {
if (s.busy) ActionChip(if (s.percent >= 0) "Runtime busy · ${s.percent}%" else "Runtime busy", ok = false)
}
/** Whether Steam can start, said where Play is rather than only in Setup. */
@Composable
internal fun RuntimeChip(s: FrontEndState) = when {
s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false)
!s.ready -> Chip("Runtime installs on first Play", ok = false)
s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false)
else -> Chip("● Runtime ready", ok = true)
}
/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */
private fun lastPlayedText(lastPlayed: Long): String? {
if (lastPlayed <= 0L) return null
val span = android.text.format.DateUtils.getRelativeTimeSpanString(
lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString()
return "Last played " + span.replaceFirstChar { it.lowercase() }
}
private fun libraryLabel(library: String): String = when (library) {
"internal" -> "Internal storage"
Library.ADDED -> "Added game"
else -> library
}
/**
* A game's wide banner: Steam's hero art where the client cached one, else its capsule blurred to
* fill the width. The copy sits bottom-left over a scrim of the ground colour so it always reads;
* the art takes the banner's size, so a banner given a minimum height grows to fit its copy.
*/
@Composable
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
val image = g.hero ?: g.art
if (image != null) AsyncImage(
model = image, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
.then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier),
)
Spacer(
Modifier.matchParentSize().background(
Brush.horizontalGradient(
0f to colors.background.copy(alpha = 0.92f),
0.55f to colors.background.copy(alpha = 0.6f),
1f to Color.Transparent,
),
),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
content = content,
)
}
}
@@ -0,0 +1,263 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.PhantomProcessLimit
// The front end's side rail: the page list, the resume entry and the controller hints under it.
/** What the pad's face buttons do here, along the bottom edge as on a console. */
@Composable
internal fun ControllerHints(select: String = "Select", tabs: Boolean = false) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(modifier = Modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End),
modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp),
) {
if (tabs) HintGlyph("LB RB", "Tabs")
HintGlyph("A", select)
HintGlyph("B", "Back")
}
}
}
@Composable
private fun HintGlyph(button: String, action: String) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
// Face buttons are round; bumpers are a wider keycap.
val shape = if (button.length == 1) CircleShape else RoundedCornerShape(6.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(20.dp).widthIn(min = 20.dp).clip(shape).background(colors.onBackground).padding(horizontal = if (button.length == 1) 0.dp else 6.dp),
) {
Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
internal val AttentionAmber = Color(0xFFFFB547)
/**
* The launcher's sections, always on screen down the left edge: the app is landscape-only, so the
* width a hamburger menu would save is better spent keeping every section one press away.
*/
@Composable
internal fun SideRail(
s: FrontEndState, selected: String,
onSelect: (String) -> Unit, onFocusSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
val compact = LocalConfiguration.current.screenHeightDp < 420
// Under 600dp wide (4:3 and square screens) the labels would cost a fifth of the width.
val iconOnly = isNarrowScreen()
val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) ||
PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
Image(painterResource(R.drawable.logo), contentDescription = "DroidDeck", modifier = Modifier.size(if (compact) 28.dp else 34.dp))
Spacer(Modifier.height(if (compact) 6.dp else 12.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
) {
RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, onFocus = { onFocusSelect("store") }) { onSelect("store") }
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
}
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) {
val barWidth = if (iconOnly) 44.dp else 60.dp
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth))
else LinearProgressIndicator(modifier = Modifier.width(barWidth))
Text(if (s.percent >= 0) "${s.percent}%" else if (iconOnly) "…" else "Working", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
}
}
var lastRunning by remember { mutableStateOf("") }
if (s.running != null) lastRunning = s.running
AnimatedVisibility(
s.running != null,
enter = expandVertically(Motion.sp(0.75f)) + fadeIn(Motion.tw(300)),
exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)),
) { ResumeRailItem(lastRunning, compact, iconOnly, a.onResume) }
}
}
@Composable
private fun RailItem(
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean,
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val frontFocus = LocalFrontFocus.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val pressed by src.collectIsPressedAsState()
if (frontFocus != null) LaunchedEffect(focused) {
if (focused) {
frontFocus.focusedRail = key
onFocus()
}
else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null
}
val fg by animateColorAsState(
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
Motion.tw(220), label = "railFg",
)
val fill by animateColorAsState(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing")
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
.clip(Shape14)
.background(fill)
.border(2.dp, ring, Shape14)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
) {
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (compact) 20.dp else 22.dp))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
}
if (badge) Box(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
.size(8.dp).clip(CircleShape).background(AttentionAmber),
)
}
}
/** The running session, one press from anywhere in the launcher. */
@Composable
private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val pulse = rememberInfiniteTransition(label = "pulse")
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(Motion.ms(1600).coerceAtLeast(1), easing = Motion.Ease), RepeatMode.Restart), label = "ring")
// Animations off: the live dot alone, no pulsing ring.
val still = Motion.scale == 0f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier
.padding(top = 6.dp)
.width(if (iconOnly) 52.dp else 80.dp)
.clip(Shape14)
.background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f))
.border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
.semantics { contentDescription = "Resume $name" }
.padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp),
) {
Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) {
if (!still) Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape))
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good))
}
// Icons only: the live dot alone says something is running; its description says what.
if (!iconOnly) {
Text("Resume", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false)
Text(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,448 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.VideogameAsset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
private enum class CheckState { OK, WARN, BUSY }
/** One requirement in Setup's system check: a status mark, what it is, and at most one action. */
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when (state) {
CheckState.OK -> pal.good
CheckState.WARN -> AttentionAmber
CheckState.BUSY -> pal.signal
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
.background(if (state == CheckState.WARN) AttentionAmber.copy(alpha = 0.07f) else Color.Transparent)
.heightIn(min = 60.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(tint.copy(alpha = 0.16f))) {
Icon(
when (state) {
CheckState.OK -> Icons.Filled.Check
CheckState.WARN -> Icons.Filled.PriorityHigh
CheckState.BUSY -> Icons.Filled.Refresh
},
contentDescription = when (state) {
CheckState.OK -> "Done"
CheckState.WARN -> "Needs attention"
CheckState.BUSY -> "Working"
},
tint = tint, modifier = Modifier.size(16.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (detail != null) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (action != null) action()
}
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@Composable
internal fun SetupPanel(
s: FrontEndState,
a: FrontEndActions,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
) {
val host = rememberMenuHost()
var processLimitBusy by remember { mutableStateOf(false) }
var processLimitMessage by remember { mutableStateOf<String?>(null) }
val setProcessLimit: (Boolean) -> Unit = { enabled ->
processLimitBusy = true
processLimitMessage = null
a.onSetPhantomProcessLimit(enabled) { error ->
processLimitBusy = false
if (error == null) {
a.onRefreshPhantomStatus()
} else {
processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device."
onRequestWirelessAdb(enabled)
}
}
}
val runtime = when {
s.busy -> "Working…"
!s.ready -> "Install"
s.available != null && s.available != s.installed -> "Update"
else -> "Manage"
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val gpuOk = remember { DeviceSupport.adreno() }
val gpuName = remember { DeviceSupport.gpuName() }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
// Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page.
val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About")
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
// The control a controller was on went with the old tab: it lands on the new tab itself.
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
runCatching { tabFocus[tab].requestFocus() }
}
}
Rise(0, Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().bumpers(
onPrevious = { pick((tab + tabs.size - 1) % tabs.size) },
onNext = { pick((tab + 1) % tabs.size) },
),
) {
PageHeader("Setup") {
Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", ok = readyCount == checks)
}
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
SectionTitle("System check", null)
// What Steam needs, one row each: green when done, one button when not. The
// process-limit controls only open under their row.
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
if (gpuOk) "Device supported" else "GPU not supported",
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
)
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
"Linux runtime",
when {
s.busy -> if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage
!s.ready -> "Not installed · about 3 GB, installed on the first Play"
s.available != null && s.available != s.installed -> "${s.installed ?: "Installed"} · update available"
else -> "${s.installed ?: "Installed"} · up to date"
},
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
"Child-process limit",
when (s.phantomProcessStatus) {
PhantomProcessStatus.ENABLED -> "On · Android may close Steam. Turning it off takes a minute"
PhantomProcessStatus.UNSET -> "Not set · the ROM default may close Steam. Turning it off takes a minute"
PhantomProcessStatus.UNREADABLE -> "Could not be checked · turning it off takes a minute"
else -> PhantomProcessLimit.title(s.phantomProcessStatus)
},
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails }
}
}
AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
// One sentence and at most three buttons. The computer route and its
// raw command live on the full page Wireless debugging opens.
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(
if (limitBlocks) PhantomProcessLimit.gateInstructions(s.phantomProcessStatus)
else PhantomProcessLimit.instructions(s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
Actions {
if (limitBlocks) {
PrimaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Use Wireless debugging", compact = true, enabled = !processLimitBusy) { setProcessLimit(false) }
SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus)
} else if (s.phantomProcessStatus == PhantomProcessStatus.DISABLED) {
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy) { setProcessLimit(true) }
}
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
"Steam account",
s.offlineAccount?.let { if (s.offline) "Signed in as $it · offline mode" else "Signed in as $it" } ?: "Press Play and sign in to Steam",
divider = false,
)
}
SectionTitle("Tools", null)
ToolGrid(s, a)
}
1 -> {
val controller = s.controller
if (controller != null && a.controller != null) SettingsGroup("Controller") {
ControllerRows(host, s.oscMode, controller, a.controller)
}
if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.")
}
2 -> {
SettingsGroup("Session") {
ChoiceRow(
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow("Frame generation", "Select the frame generation mode") {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
}
}
ToggleRow(host, "logs", "Session logs", "Saved after each session", s.logsEnabled) { a.onLogs() }
ActionRow("Latest session logs", "Send them with a bug report", "Share logs", a.onShareLogs)
ToggleRow(
host, "offline", "Offline mode",
s.offlineAccount?.let { "Signed in as $it" } ?: "Sign in to Steam first",
s.offline, enabled = s.offlineAccount != null,
) { a.onOffline() }
}
}
3 -> {
SettingsGroup("Launcher") {
SettingsRow("Theme", "Choose the launcher appearance") {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = "Theme") { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ToggleRow(
host, "home-screen", "Use as a Home screen",
if (s.homeScreenEnabled) "DroidDeck can be the phone's Home app" else "Off: DroidDeck is never offered as a Home app",
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ToggleRow(
host, "launcher-fullscreen", "Fullscreen",
if (s.launcherFullscreen) "Hide the Android status and navigation bars" else "Show the Android status and navigation bars",
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
if (s.homeScreenEnabled) {
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
}
}
SettingsGroup("Linux apps (beta)") {
ToggleRow(
host, "store-enabled", "Flathub Store",
if (s.storeEnabled) "The Store is in the menu. Some apps may not start; logs are in Download/DroidDeck"
else "Off: install Linux apps and games from Flathub with Flatpak",
s.storeEnabled,
) { a.onStoreEnabled(it) }
ToggleRow(
host, "appimages-enabled", "AppImages",
if (s.appImagesEnabled) "Add AppImage is on the Desktop page. ARM64 (aarch64) AppImages only"
else "Off: import ARM64 AppImages from your storage",
s.appImagesEnabled,
) { a.onAppImagesEnabled(it) }
}
}
else -> {
SettingsGroup("About") {
ActionRow("Build", s.buildLabel, "Check for newer", a.onCheckLatestBuild)
ActionRow("Credits", "The people and projects DroidDeck builds on", "View", a.onCredits)
}
}
}
}
}
}
}
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
@Composable
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
val columns = if (LocalNarrowPane.current) 2 else 4
val tools = listOf(
ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles),
ToolSpec(Icons.Outlined.Extension, "Proton versions", "Install ARM64 Proton builds", a.onProtons),
ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance),
ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms),
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
for (row in tools.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
@Composable
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.paneItem("tool:${t.title}")
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
FrameGenMenu(host, s.frameGenEngine, s.frameGenMultiplier, s.lsfgReady, a.onFrameGenPick)
}
/**
* What shapes a launch, beside the game rather than three screens away in Setup. They are the
* app-wide settings - each card opens the same page or menu Setup does.
*/
@Composable
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
// Three across, two on a narrow page; each row's cards share one height.
val columns = if (LocalNarrowPane.current) 2 else 3
val controller = a.controller
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard("Components", "FEX, DXVK, VKD3D", "card:components", m) { a.onComponents(true) } }
add { m ->
Box(m) {
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
host.open = if (host.open == "fg") null else "fg"
}
FrameGenMenu(s, a, host)
}
}
if (controller != null) add { m -> SettingCard("Controls", "Button mapping", "card:controls", m, controller.onMapping) }
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (card in row) card(Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge")
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier.paneItem(id)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, edge, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more