Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx eb44931973 test: profile slow close and fstat on the SD library 2026-10-06 18:34:38 -04:00
xXJsonDeruloXx 59cac100e8 fix: drop cached listings after a relative create 2026-10-06 18:17:00 -04:00
xXJsonDeruloXx 371e87fa31 perf: cache the Steam client's SD library listings in the process 2026-10-06 18:15:06 -04:00
xXJsonDeruloXx e9a7ef5f44 test: log and optionally skip file extension on the SD library 2026-10-06 17:36:49 -04:00
The412Banner 40461caf4a perf(storage): opt-in skip of Steam's space reservation on the second library
Steam reserves every file's full size before downloading. On a portable SD card
the app reaches through Android's FUSE layer, fallocate is rejected and the
reservation can take tens of minutes for a large game, with nothing downloaded.

A new experimental setting (Steam > storage, off by default) makes the session
preload answer plain fallocate/posix_fallocate requests on the selected library
with success and leave the file untouched, so Steam goes straight to the
download. Hole punching and other fallocate modes still pass through, as does
every other device. A full card shows up as a disk write error during the
download instead of up front. Storage diagnostics behave as before and log the
skipped calls.
2026-10-06 15:35:35 -04:00
Kurt Himebauch 1e877fa602 fix: give each new session the current screen's output size (#357) 2026-10-06 19:17:52 +00:00
Kurt Himebauch 906009a33a fix: share session logs in about a second instead of re-scrubbing them (#352) 2026-10-06 14:51:43 -04:00
The412BannerandThe412Banner 08559a02f8 Windows components: install .msi packages (XNA, PhysX, GFWL, MSXML...) without running them (#347)
* Windows components: install .msi packages (XNA, PhysX, GFWL, MSXML...) without running them

droiddeck-msi-install reads a Windows Installer package's tables with msiinfo and unpacks its
cabinets with cabextract, then lays out what Wine's msiexec would have installed: files at their
64-bit Windows paths (Program Files (x86), syswow64...), .NET assemblies in the GAC, side-by-side
assemblies in winsxs, and the registry values in the view the component writes through. Conditions,
features and the folder-setting custom actions are evaluated as on Windows 10 64-bit; what needs
the package's own code (DXSETUP, self-registration) is listed in the component's notes.

droiddeck-wincomponents puts a component's drive_c tree into the prefix (keeping the prefix's own
spelling of each folder) and writes its registry values into system.reg/user.reg before launch,
following Wine's link keys (Wow6432Node\Classes, CurrentControlSet...). Turning it off puts every
value back and takes an added PATH entry out again.

The app installs these from the Windows components menu like the DLL ones: it downloads the .msi
into the runtime and runs the installer there (GuestCommand). msitools 0.106 and cabextract 1.11
come from Arch Linux ARM, pinned in tools/msitools/packages.txt and hosted on the msitools-0.106-r1
release. wine-gecko stays hidden (Proton ships it) and wine-mono keeps waiting (uninstall step).

* Windows components: open with the top switch focused, by touch or by pad

The dialog's window starts in touch mode, where a switch cannot take focus, and the first switch
was asked once, two frames after the list loaded, only when a pad opened it - racing the dialog's
own Done focus. Now it always puts the window in pad mode, holds Done while the list loads and
then asks the top switch every frame until it has focus.

* Windows components: give msitools its own libraries; show a failed install on its row

msiinfo and cabextract sit with their libraries in /usr/local/lib/droiddeck-msitools, which the
loader does not search: every .msi install stopped at "libmsi-1.0.so.0: cannot open shared
object file". droiddeck-msi-install now runs them with that folder on LD_LIBRARY_PATH.

A failed install also says so under the component itself ("Could not install: ..."), not
only at the foot of the dialog, past the list.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 14:14:28 -04:00
Kurt Himebauch 1d93626453 feat: remove Force game windows fullscreen now that games run on their own Xwayland (#349) 2026-10-06 18:04:22 +00:00
Kurt Himebauch 9d0989374b feat: run Steam sessions on two Xwayland servers as SteamOS does (#344) 2026-10-06 17:56:13 +00:00
The412BannerandThe412Banner 3278afaa1a Fast path: hold every bind, so added games start in their own folder (#343)
* Fast path: hold every bind, so added games start in their own folder

libblfastpath kept the first 64 binds and dropped the rest without a word. A session has 87 (two
Games folders are the last two), so /root/Games/<folder>/... was mapped into the rootfs - the
empty mount point - instead of the bound storage. Wine resolves a new process's working directory
by those paths, found no drive for it and started every added game in C:\windows ("could not
find DOS drive for current working directory"): DiRT 3 did not mind, FEZ looks for
Content\Essentials.pak there and quits.

The library now holds 256, and turns itself off for the process when a bind does not fit or the
list runs past that, leaving the call to proot, which never maps it wrong. The app keeps the fast
path off for a session with more binds than that.

Verified on an AYANEO Pocket FIT: FEZ (added game) reaches its title screen; the warning is gone.

* Fast path: say in the session log when it stays off

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 13:48:01 -04:00
Kurt Himebauch bf221ffbbd fix: resolution presets keep the panel shape at 720p, 900p and 1080p (#337) 2026-10-06 13:19:46 -04:00
Kurt Himebauch c636eea355 fix: hold flood edges at the page edge once reached (#330) 2026-10-06 12:07:24 -04:00
Kurt Himebauch ffaa906cd1 fix: catch KGSL GEM_CLOSE at ioctl instead of exporting drmIoctl (#305)
* fix: avoid reusing retired KGSL GEM handles

* fix: defer closing GEM_CLOSE'd KGSL dma-buf descriptors

* Revert "fix: defer closing GEM_CLOSE'd KGSL dma-buf descriptors"

This reverts commit 191bf4b6f2.

* test: interpose drmIoctl without releasing GEM_CLOSE handles

* fix: catch KGSL GEM_CLOSE at ioctl instead of exporting drmIoctl
2026-10-06 11:52:16 -04:00
The412BannerandThe412Banner d61af961c1 Windows components for Steam and added games; no more redist installer prompts (#287)
* Put a game's Windows components into its Proton prefix at launch

droiddeck-wincomponents copies the DLLs of the components picked for a game
into compatdata/<appid>/pfx before Proton starts and hands their overrides to
WINEDLLOVERRIDES, so the registry is never touched. It re-checks every launch
(an older Proton deletes the files it once placed), takes a component's files
out again when it is turned off, and forgets Proton's config_info then so
Proton puts its own builtins back.

* Windows components for added games: picker, packages and their catalog

Settings > Games lists a Windows components row under each added game. Its
dialog switches components on per game; the first switch-on for any game
downloads the package (a catalog of its own beside desktop.json, installed
with the desktop package installer to /opt/droiddeck/wincomponents/<id>).
The picks are kept in the app and published to the runtime at every session
start for droiddeck-wincomponents to apply.

tools/wincomponents/package.py builds the packages: Bannerlator's eight
wincomponents archives re-rooted, and OpenAL from OpenAL Soft's release
(soft_oal.dll placed as OpenAL32.dll, 32- and 64-bit).

* Windows components: list the installed ones when the catalog cannot be reached

Offline the dialog only looked up ids it already knew - the catalog's and
the game's picks - so a component downloaded earlier never showed.

* Windows components: Bannerlator's list with Recommended, on the game's launch settings

The picker moves from Settings to a Windows components card in an added
game's launch settings. It offers Bannerlator's whole catalog
(components.json on winlator-contents): what the game's folder points at
first (Bannerlator's DependencyDetector, mapped to the DLL-copy twin where
there is one, oalinst -> oalinst_dll), then every component that installs
here, then the ones that need a Windows installer, greyed until DroidDeck can
run one.

A component installs by running its catalog steps into the runtime once:
archives downloaded (one download per archive for a batch), DLLs found by
name as Bannerlator does and copied to /opt/droiddeck/wincomponents/<name>,
overrides and bundled components written to component.json. The launch
script now takes catalog names (xaudio2.7), expands bundles (directmusic),
places files one level down (drivers/gm.dls) and keeps an override's own
mode (dsound=n). Drops the hand-made packages and their script.

* Windows components: Steam games too, and a pad that can drive the list

The card shows on a Steam title's launch settings as well, keyed by its
appid - the compatdata folder droiddeck-game-env reads - and Recommended
scans the game's install folder.

With a pad: Done takes focus while the catalog loads and the first
component once it is there (the list was not drawn yet when the dialog
asked for focus, so nothing had it); switches stay enabled during a
download and a press then is ignored (a disabled switch dropped the
pad's focus); LB/RB jump between Recommended, All, the installers and
Done.

* Redist markers from games' own install scripts; VC++ runtime as installed

The client still started a game's own _CommonRedist installer - its
script sits in the game's folder, often with another version in the
value name, and only Steamworks Shared's were read. On ARM64 Proton the
x64 VC++ 2015-2022 bundle then stops with "Microsoft Visual C++
2015-2022 Redistributable (arm64) is required" (reported on an AYN Odin 2
Portal Max).

- Read installscript*.vdf from every installed game in each library as
  well; the list is cached until an appmanifest changes, since this runs
  every quarter minute.
- Record the VC++ 2015-2022 runtime as installed where its installers
  look (VisualStudio\14.0\VC\Runtimes\{x86,x64,arm64}, both registry
  views), so any bundle - the client's or a game's own - finds it and
  exits; Proton's msvcp140/vcruntime140 serve the game.
- Join the Wow6432Node path instead of sub(): mawk and gawk read the
  replacement's backslashes differently (gawk, the runtime's, was right).

* Mark a game's install-script processes as run just before the client evaluates them

An Odin 3 bundle (0.3.1, Proton Experimental ARM64) shows the client
running its install-script evaluator on the first launch of three new
titles (409710, 2161700, 3404260) while their Steamworks Shared
redistributables (VC++ 2010/2012/2019/2022, DirectX) were still
downloading - 2161700 at 22:03:22, the shared app finished at 22:03:29 -
so the periodic seeder had nothing to mark yet and the installers ran.

The client writes the exact steps to legacycompat/evaluatorscript_<id>.vdf
and starts iscriptevaluator.exe through our launcher. droiddeck-game-env
now hands that script to droiddeck-seed-redists first, which marks every
process in it in the template and the prefixes before the evaluator
reads them.

The seeder's reader no longer assumes a depth: a block with a HasRunKey
is a process named by the key it opened under, wherever it sits.

* Windows components: keep a file that was not Proton's, put it back when turned off

A component's DLL replaced whatever had that name in the prefix, and
turning it off deleted ours, so a file a real installer (or the user) had
put there was gone for good. Before replacing a file that is neither
Proton's own (a symlink into Proton, or a Wine builtin/placeholder DLL)
nor an earlier copy of ours, keep it under
compatdata/<id>/.droiddeck-wincomponents-backup and move it back when the
component is turned off. The launch note says how many went back.

* Windows components: recommend what Steam installs, say what the game already has

- Steam titles: the appmanifest's SharedDepots (Steamworks Shared, app
  228980) list the VC++ 2005-2022 and DirectX redists Steam installs with
  the game; they live outside the game folder, so the folder scan never
  saw them. They join Recommended as "Steam installs VC++ 2010 with this
  game" (SteamRedists).
- Bannerlator's PrefixInstalledDetector, on compatdata/<id>/pfx:
  OpenAL32, Mono, PhysX and Games for Windows Live that are already there
  from elsewhere show "Already in this game". Files droiddeck-wincomponents
  placed itself do not count.
- The game page passes the prefix and, for Steam titles, the appid.

* Windows components: friendly names

The list showed catalog keys (oalinst_dll, vcredist2010_dll, dotnet452,
d3dcompiler_47). Bannerlator's catalog has no display name and some of
its descriptions do not fit the entry, so WinComponentNames gives one:
families by pattern (Visual C++ <year>, .NET Framework x.y[.z], XAudio,
D3DCompiler, MSXML, Wine Mono, DirectMusic parts), the rest from a
table, the key itself for anything new. The key stays in the row's
detail for logs and bug reports; both lists sort by the name, and the
game page's card names the picks the same way.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 10:46:27 -04:00
The412BannerandThe412Banner bc01b77752 Force SSBS for Proton games (libssbs.so), switch under FEX preset (#328)
* Force SSBS for a game's Wine: libssbs.so, opt in per game

Wine's arm64 signal code resumes threads from a Windows CONTEXT whose
Cpsr never carries PSTATE.SSBS, so every signal return leaves the
thread running with speculative store bypass disabled - slower on cores
such as Oryon. Bannerlator fixes it in its own Wine builds
(force_ssbs.diff, Sonicadvance1) and with an LD_PRELOAD adapter for
layers it cannot rebuild. DroidDeck's Protons (Valve, GE, CachyOS ARM64)
come prebuilt from Steam but run the same aarch64 Wine signal path, so
the adapter applies here: tools/linuxfs/ssbs/ssbs_adapter.c, the same
source built for glibc (+ sysv_signal), staged to
/usr/local/lib/libssbs.so.

droiddeck-game-env preloads it when the game's (or the shared)
environment sets DROIDDECK_FORCE_SSBS=1, and only into an ARM64 Proton.
Off unless asked for: the gain is unproven on Snapdragon 8 Gen 3.

For testing: SSBS_ADAPTER_OBSERVE=1 counts without changing anything
(the baseline) and SSBS_ADAPTER_DEBUG=1 logs the fixes; both also count
signals that ARRIVE while a thread runs without SSBS, which shows
whether anything besides signal returns clears it. Checked on an ARM64
Linux kernel: a handler that clears SSBS left 4 of 5 later signals
arriving with it clear; with the adapter, 0 of 5.

* Force SSBS: a switch under the FEX preset in Steam settings

Steam settings > Games gets "Force SSBS" right under the FEX preset, off
unless turned on. Like the preset it goes into the game environment's
defaults (DROIDDECK_FORCE_SSBS=1), published at once and read by
droiddeck-game-env on the next game launch, so a game's own environment
can still set it to 0.

Measured on the device with the adapter's counters (AYANEO Pocket FIT,
DiRT 3 on GE-Proton11-7-aarch64): observing only, 8192 of 8277 signals
found the thread running without SSBS; with the adapter, none did - it
keeps the bit set everywhere Wine clears it, as a rebuilt ntdll would.
The speed-up itself is reported on Oryon cores and was not measurable on
the FIT's 8 Gen 3, hence off by default.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 09:27:15 -04:00
UnbelievableFlavour a1b01a04a8 Added support for Adreno 6xx devices (#320) 2026-10-06 09:08:04 -04:00
Kurt Himebauch b44235c495 Add Native Steam background sleep and automatic wake (#291) 2026-10-06 00:15:39 -04:00
Kurt Himebauch b80d5db176 ci: require isolated version bumps (#303) 2026-10-06 03:26:14 +00:00
Kurt Himebauch d3c0d258b7 Return frontend-launched sessions to Android Home (#300) 2026-10-05 21:39:40 -04:00
xXJsonDeruloXx 9dcdbf245a Merge remote-tracking branch 'origin/main' into frontend-quit-return 2026-10-05 21:34:49 -04:00
xXJsonDeruloXx 369e218afb Return frontend-launched sessions to Android Home 2026-10-05 21:34:36 -04:00
Magarto 119d24bf6b Keep the 16:9 stretch (#63) working in Steam Deck mode (#133) 2026-10-05 21:28:31 -04:00
The412BannerandThe412Banner 2a905510d5 Device details on the GPU card; Adreno model and SoC name when the kernel leaves them out (#295)
* Device details on the GPU card, and the Adreno model when the kernel leaves it out

The Components page called an AYANEO Pocket FIT (Adreno 750) "Adreno"
and warned it was "Below tested hardware (Adreno 725 and newer)": its
kernel names the GPU "Adreno33v2", GpuInfo wanted three digits, and the
SoC model is blank. When KGSL's name has no model, take it from the
phone's Vulkan driver (once this process has asked it) or from the
platform code name (pineapple -> 750, kalama -> 740, sun -> 830, ...).

The GPU card now opens with a tap or A onto the device's details,
gathered once off the main thread (DeviceInfo): SoC (maker, model,
platform, soc0 id), GPU (model and where it came from, the kernel's
name, clock, load, temperature, support), CPU clusters, memory, system,
the phone's own Vulkan driver - device, API, driver version and build,
conformance, limits, the extensions DXVK and VKD3D lean on, Android's
certified version and level - and the drivers DroidDeck uses. A button
copies it all for a bug report; each section takes pad focus so the
d-pad walks the list.

Vulkan is asked through a new small native library (deviceinfo,
vkinfo.c) that opens the system loader with dlopen and keeps nothing
open.

* Device details: the chip's model and name, CPU clusters, GPU clock range

Ideas from ClusterTune (GPL-2.0, its approach, not its code), which
tunes clocks on AYN, Retroid and other handhelds:
- The SoC model is read where vendors put it - ro.soc.model,
  ro.vendor.qti.soc_model, Android's SOC_MODEL, ro.fota.platform (unless
  it is a branch name like "MSM_13.0") - since handhelds often leave
  Android's blank (Pocket FIT: SG8350P).
- SocNames names it: model codes we are sure of (QCS8550 -> Snapdragon
  8 Gen 2, SM8750 -> Snapdragon 8 Elite, ...), else the platform as a
  family (pineapple -> "Snapdragon 8 Gen 3 family"). The GPU card's
  subtitle shows it: "Snapdragon 8 Gen 3 family (SG8350P)".
- CPU clusters come from the cpufreq policies (the cores each covers,
  min-max speed) rather than grouping cores by top speed.
- GPU clock range from KGSL's step list, or a devfreq GPU node on
  non-Adreno chips (Mali), not only the top clock.

* Device details: drop Qualcomm's own "Driver Build:" label from the driver build line

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-05 20:32:51 -04:00
Kurt Himebauch da29919b5a Fix legacy Steam sleep and missed wake handshakes (#289)
Fix legacy Steam sleep and recover missed wake handshakes
2026-10-05 16:49:55 -04:00
輕鈴andKurt Himebauch 05608ac4d4 Refine Simplified Chinese localization (#232)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-05 15:58:59 -04:00
starwishHiandKurt Himebauch 135b84a501 Add French and Russian translate, add fr,ru,jp,ko in locales_config.xml And overlap #234 and #259 (#286)
* Create values-zh-rCN

values language of Simplified Chinese

* Delete app/src/main/res/values-zh-rCN

* Add Korean,Japanese and Tranitional Chinese (zh-TW,zh-HK,zh-MO) translations

* Delete app/src/main/res/values/values-ko/strings.xml

* Create strings.xml

Add korean translate

* Add French translate

* Add Russian translate

* Fix French translate error

* Add the fr,ko,ja,ru in locales_config.xml
Fix French bulid error

* Overlap #234 and #259

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-05 15:52:39 -04:00
MaxsTechReview e7cbba37d1 sign-apk: rename the agent provider's authority for the other packages (#285) 2026-10-05 14:41:38 -04:00
Kurt Himebauch d9bb5e15b0 Use plain-ID .steam files for frontend exports (#277) 2026-10-05 13:48:18 -04:00
The412BannerandThe412Banner 46cdc32cf1 Firefox: skip shared-memory seals (tabs crash on Android 16) (#280)
On Android 16 phones (Galaxy S25 SM-S931B, SM-S948B, RedMagic NP05J)
every Firefox and LibreWolf tab dies at once: Firefox seals the size of
the shared memory it hands a tab, adding the seal fails there, Firefox
only warns, and the tab refuses the unsealed memory ("IPDL protocol
error: Shared memory PlatformHandle is not safe to map") and exits on
signal 11. The window opens; no page ever loads.

MOZ_SHM_NO_SEALS=1 is Firefox's own switch to skip the seals
(ipc/glue/SharedMemoryPlatform_posix.cpp). Set it beside the sandbox
switches in all three places a browser starts from: the desktop session,
AppImages, and Flatpak sandboxes.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 13:37:08 -04:00
Lobanokivan11 387572e733 merge bridge to main (second time) (#279) 2026-10-05 17:09:24 +00:00
The412BannerandThe412Banner 9f46c7a50b Force game windows fullscreen: off by default (#276)
A game whose resolution differs from the screen's (DiRT 3 at 1280x720)
fights --force-windows-fullscreen: every forced resize makes it rebuild
its swapchain, and the picture flickers between its own size and the
screen's - often from the first menu, before the player can reach the
game's resolution setting. It also drove the Xwayland dma-buf leak fixed
separately.

The switch stays in Settings > Games and the session drawer, now off
unless turned on; its hint says when to turn it on (a game coming back
small or in a corner after switching apps, e.g. FlatOut) and when to
leave it off (flicker). Users who already set it keep their choice. The
session script's fallback when the app passes nothing is off as well.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 12:44:45 -04:00
The412BannerandThe412Banner 0607bb0b3a Fix Xwayland dma-buf leak: close Zink's emulated GEM handles on KGSL (#275)
Close Zink's emulated GEM handles on KGSL (Xwayland dma-buf leak)

The session preload hands out GEM handles on the KGSL render node by
duplicating the dma-buf descriptor, and released them only through
drmCloseBufferHandle. Zink gives handles back with
drmIoctl(DRM_IOCTL_GEM_CLOSE) instead, which KGSL refuses, so every
duplicated descriptor stayed open and kept its buffer alive.

Xwayland asks for a handle for each buffer it imports through gbm. With
"Force game windows fullscreen" fighting a game's own resolution
(DiRT 3 at 1280x720), the game recreates its swapchain many times a
second and Xwayland piled up ~2,000 old swapchain images (6+ GB) in
seconds until Android killed the session. A preload trace on the device
put 2,090 of 2,118 open dma-buf descriptors in Xwayland on
drmPrimeFDToHandle called from Zink.

Handle GEM_CLOSE on the KGSL node in drmIoctl, and check the three DRM
symbols are exported in CI.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 12:44:16 -04:00
Kurt Himebauch b89d87cbde Add app UI scale presets to Setup (#272) 2026-10-05 11:25:51 -04:00
Kurt Himebauch 06e06b9673 Fix MangoHud double-counting battery power and remaining life (#270) 2026-10-05 15:07:40 +00:00
Kurt Himebauch b896e7a3b9 Compact Steam, Desktop, and Setup settings headers (#268) 2026-10-05 10:34:31 -04:00
Kurt Himebauch c91daa9147 Keep the game undimmed while the session menu is open (#267)
Remove background dimming from the session menu
2026-10-05 09:59:35 -04:00
Kurt Himebauch 31ea194f3d Prepare 0.3.1 release (#263)
* docs: draft 0.3.1 release notes

* chore: bump version to 0.3.1 (11)
2026-10-05 07:46:36 -04:00
MaxsTechReview 472bb28c88 Fix controller rings on linked data dirs, route rumble to the controller, capture the mouse for pointer locks (#242)
- #166: export the fake-input ring paths with the spelling the session tree is bound under
  (Context.getFilesDir(), /data/user/0/...) instead of the canonical path. Where /data/user/0
  resolves to /data/data (ColorOS 16) the guest has nothing bound at the canonical prefix, so
  libfakeinput could not open the rings and every pad, physical and on-screen, was dead. The ring
  path logic moves to FakeInputRingPaths so it can be unit-tested without the native library.
- #219: play a game's rumble on the physical controller that last drove the pad, when Android
  exposes its motors (strong and weak separately on Android 12+), and fall back to the device's
  vibrator for the on-screen pad or a controller without rumble. PadBridge records the active
  controller; the on-screen pad taking over moves rumble back to the phone.
- #153: when a program takes a pointer lock (a mouse-look game through gamescope), capture a
  physical mouse and send its raw deltas as relative motion, so the camera no longer stops where
  Android's pointer meets the screen edge. Capture is released for the drawer, the PC keyboard,
  picture in picture, focus loss and pause, and when the lock ends.
2026-10-05 07:33:33 -04:00
Kurt Himebauch eaa4a7a047 Clarify display settings and organize in-session controls (#247)
* Clarify display sizing, game fit and scaling controls

* Replace aspect and resolution controls with one dimension menu

* Organize Steam session settings into tabs

* Keep session resolution out of the live controls panel

* Group live session controls and add a conditional second-screen tab

* Refine session sliders, tab icons and contextual controls

* Simplify screen effects with presets and collapsed advanced controls

* Give in-session tab icons more spacing

* Center the loading cancel button glyph
2026-10-04 23:32:30 -04:00
MaxsTechReview 4fa467c800 Fetch the Steam client again when it never shows up (#225)
A client stopped part way through updating itself can be left unable to
draw anything. Its files and our install stamp are still on disk, so the
installer never looked at it again and every session sat on "Waiting for
Steam" with nothing retried.

The session now adds a line to package/droiddeck-unconfirmed each time it
starts the client, and the app removes the file on the client's first
frame. After two starts in a row with nothing shown, the installer:
- drops Steam's update manifests, the stamp, .crash and steam.pid
- deletes package zips whose sha1 no longer matches their name
- downloads the client again over the old one

steamapps, config and userdata are left alone. An automatic repair runs
once per failure, until a start is seen again.

The Steam page's Client settings get a "Repair Steam client" item that
queues the same repair for the next start. The request is only cleared
once the download completes, so an interrupted repair is retried.
2026-10-04 21:33:16 -04:00
Kurt Himebauch beb96e6f0c docs: record Thor Steam SD allocation findings (#240) 2026-10-04 20:15:24 -04:00
129 changed files with 12415 additions and 803 deletions

No files matched your search

+54 -2
View File
@@ -57,6 +57,22 @@ jobs:
exit 1
fi
- name: Version bumps are isolated
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
echo "::error::versionCode/versionName changes must be isolated in their own PR"
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
run: |
@@ -157,7 +173,7 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -179,6 +195,10 @@ jobs:
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
mkdir -p "$D/usr/local/bin"
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
@@ -230,6 +250,26 @@ jobs:
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
# The Windows components installer's tools (tools/msitools/packages.txt).
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
rm msi-pkgs/pkg.tar.xz
done
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
done
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
done
rm -rf msi-pkgs
ls -l "$T"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
@@ -343,11 +383,23 @@ jobs:
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 syscall shm_open shm_unlink mmap mmap64 munmap; do
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
if grep -qx drmIoctl <<<"$SESSION"; then
echo "::error::libblsession.so exports drmIoctl"; exit 1
fi
SSBS=$(syms "$D/libssbs.so")
for sym in sigaction signal bsd_signal sysv_signal; do
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/droiddeck-session"
test -f "$D/usr/local/bin/droiddeck-proton-extra"
test -f "$D/usr/local/bin/droiddeck-msi-install"
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
done
- name: Cache the CMake build tree
uses: actions/cache@v4
+1 -1
View File
@@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
## 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/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
+4 -2
View File
@@ -79,8 +79,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 10
versionName "0.3.0"
versionCode 11
versionName "0.3.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -190,6 +190,8 @@ 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.27.1'
// Bannerlator's Windows components come as .tar.xz (WinComponents).
implementation 'org.tukaani:xz:1.10'
// 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')
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/SessionTheme" />
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
android:exported="true"
android:permission="android.permission.DUMP"
android:grantUriPermissions="false" />
</application>
</manifest>
+18
View File
@@ -71,6 +71,10 @@
<action android:name="android.intent.action.CREATE_SHORTCUT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
@@ -123,6 +127,14 @@
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/SessionTheme" />
<activity
android:name=".SessionActivity"
android:exported="false"
@@ -173,5 +185,11 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
android:exported="true"
android:permission="android.permission.DUMP"
android:grantUriPermissions="false" />
</application>
</manifest>
+5
View File
@@ -44,6 +44,11 @@ add_subdirectory(waylandcomp)
add_library(sdnative SHARED sdnative/sdnative.c)
target_link_libraries(sdnative log)
# The device info window's Vulkan section: the phone's own driver, asked once through the system
# loader (opened with dlopen, so a phone without Vulkan still loads the app).
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
target_link_libraries(deviceinfo dl)
# Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its
# prebuilt libtermux.so with a redundant libstdc++.so dependency.
add_library(termux SHARED termux/termux.c)
+112
View File
@@ -0,0 +1,112 @@
// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one
// instance on the system loader, the first physical device's properties, a few limits and the
// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open.
#include <dlfcn.h>
#include <jni.h>
#include <stdio.h>
#include <string.h>
#define VK_NO_PROTOTYPES
#include <vulkan/vulkan.h>
#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0)
static const char *const EXTENSIONS[] = {
"VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color",
"VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing",
"VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query",
};
static const char *type_name(VkPhysicalDeviceType type) {
switch (type) {
case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU";
case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU";
case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU";
case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU";
default: return "other";
}
}
JNIEXPORT jstring JNICALL
Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) {
static char out[4096];
size_t used = 0;
out[0] = 0;
void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); }
PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr");
PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL;
PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL;
if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
uint32_t loader = VK_API_VERSION_1_0;
if (instanceVersion) instanceVersion(&loader);
LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader));
VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info",
.apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 };
VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app };
VkInstance instance = VK_NULL_HANDLE;
if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name)
GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties);
GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties);
GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures);
uint32_t count = 1;
VkPhysicalDevice device = VK_NULL_HANDLE;
VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED;
if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) {
LINE("error=no Vulkan device\n");
} else {
VkPhysicalDeviceProperties p;
vkGetPhysicalDeviceProperties(device, &p);
LINE("device=%s\n", p.deviceName);
LINE("type=%s\n", type_name(p.deviceType));
LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion));
// Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION.
LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff);
LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID);
LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D);
// Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties).
uint32_t extCount = 0;
if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL);
static VkExtensionProperties exts[512];
if (extCount > 512) extCount = 512;
if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts);
int driverProps = p.apiVersion >= VK_API_VERSION_1_2;
for (uint32_t i = 0; i < extCount; i++)
if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1;
if (driverProps && vkGetPhysicalDeviceProperties2) {
VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver };
vkGetPhysicalDeviceProperties2(device, &p2);
LINE("driver_name=%s\n", driver.driverName);
LINE("driver_info=%s\n", driver.driverInfo);
LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor,
driver.conformanceVersion.subminor, driver.conformanceVersion.patch);
}
LINE("extension_count=%u\n", extCount);
for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) {
int has = 0;
for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]);
LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no");
}
if (vkGetPhysicalDeviceFeatures) {
VkPhysicalDeviceFeatures f;
vkGetPhysicalDeviceFeatures(device, &f);
LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n",
f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no");
}
if (vkGetPhysicalDeviceMemoryProperties) {
VkPhysicalDeviceMemoryProperties m;
vkGetPhysicalDeviceMemoryProperties(device, &m);
VkDeviceSize largest = 0;
for (uint32_t i = 0; i < m.memoryHeapCount; i++)
if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size;
LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20));
}
}
if (vkDestroyInstance) vkDestroyInstance(instance, NULL);
dlclose(lib);
return (*env)->NewStringUTF(env, out);
}
@@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
@@ -64,6 +65,9 @@ import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.frontend.GameFiles
import com.droiddeck.launcher.frontend.GameFileSync
import com.droiddeck.launcher.frontend.GameLaunchIntent
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
import com.droiddeck.launcher.files.InAppFilePicker
@@ -110,6 +114,7 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
@@ -150,7 +155,6 @@ class MainActivity : ComponentActivity() {
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var clientDirectAudio by mutableStateOf(false)
private var forceFullscreen by mutableStateOf(true)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
@@ -304,13 +308,13 @@ class MainActivity : ComponentActivity() {
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var upscaler by mutableStateOf(0)
@@ -323,6 +327,7 @@ class MainActivity : ComponentActivity() {
private var renderer by mutableStateOf("vulkan")
private var gameStorage by mutableStateOf("")
private var storageDiagnostics by mutableStateOf(false)
private var skipPreallocation by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
@@ -398,10 +403,15 @@ class MainActivity : ComponentActivity() {
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
getString(R.string.game_file_exported, folder.path)
val saved = if (game != null) GameFiles.export(folder, game)
else {
GameFileSync.enable(this, folder)
folder
}
ui.post {
gameSyncFolder = GameFileSync.folder(this)
}
getString(R.string.game_file_exported, saved.path)
}
}
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
@@ -415,10 +425,10 @@ class MainActivity : ComponentActivity() {
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
if (request.action == Intent.ACTION_VIEW) {
if (GameLaunchIntent.accepts(request.action)) {
val copy = Intent(request)
Thread({
val id = com.droiddeck.launcher.frontend.GameFiles.readIntent(this, copy)
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
pendingGameLink = id
@@ -431,7 +441,7 @@ class MainActivity : ComponentActivity() {
}
}
private fun launchGame(game: Library.SteamGame): Boolean {
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
return false
@@ -446,10 +456,12 @@ class MainActivity : ComponentActivity() {
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
}
@@ -461,10 +473,14 @@ class MainActivity : ComponentActivity() {
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game)) {
} else if (launchGame(game, returnHome = true)) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
}
}
@@ -476,10 +492,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
readGameIntent(intent)
gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this)
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
@@ -489,7 +506,7 @@ class MainActivity : ComponentActivity() {
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme) {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
@@ -516,6 +533,7 @@ class MainActivity : ComponentActivity() {
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
defaultHomeLabel = defaultHomeLabel,
@@ -553,7 +571,7 @@ class MainActivity : ComponentActivity() {
onSyncGameFiles = { pickGameExport(null) },
onStopGameFileSync = {
Thread({
com.droiddeck.launcher.frontend.GameFileSync.disable(this)
GameFileSync.disable(this)
ui.post { gameSyncFolder = null }
}, "game-file-stop-sync").start()
},
@@ -621,13 +639,10 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
},
onShareLogs = {
Thread({
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
ui.post {
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
}, "share-logs").start()
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
},
onClearLogs = {
Thread({
@@ -645,6 +660,10 @@ class MainActivity : ComponentActivity() {
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
@@ -1117,7 +1136,8 @@ class MainActivity : ComponentActivity() {
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
@@ -1129,17 +1149,19 @@ class MainActivity : ComponentActivity() {
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
clientDirectAudio = clientDirectAudio,
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
skipPreallocation = mode == SessionService.MODE_STEAM && skipPreallocation,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
@@ -1172,9 +1194,7 @@ class MainActivity : ComponentActivity() {
}
},
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
@@ -1190,7 +1210,6 @@ class MainActivity : ComponentActivity() {
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1210,7 +1229,12 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
storageDiagnostics = on
},
onSkipPreallocation = { on ->
SessionPrefs.setSkipLibraryPreallocation(this, on)
skipPreallocation = on
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
@@ -1218,6 +1242,7 @@ class MainActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
@@ -1329,19 +1354,19 @@ class MainActivity : ComponentActivity() {
showProtons = false
showComponents = false
showMapping = false
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
@@ -1353,13 +1378,13 @@ class MainActivity : ComponentActivity() {
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
forceFullscreen = SessionPrefs.forceFullscreen(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
renderer = SessionPrefs.desktopRenderer(this)
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
skipPreallocation = SessionPrefs.skipLibraryPreallocation(this)
settingsMode = mode
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it animates in.
@@ -59,6 +59,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionEvents
@@ -82,7 +83,9 @@ 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 com.droiddeck.launcher.ui.SessionDrawerPage
import com.droiddeck.launcher.ui.sessionDrawerPages
import com.droiddeck.launcher.ui.step
import kotlin.math.abs
/**
@@ -158,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
private var cursorSerial = 0
private var clientHidesCursor = false
/**
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
* at the screen edge, and so did the camera (#153).
*/
private var pointerLocked = false
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
updatePointerCapture()
}
}
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
private var cursorHotX by mutableStateOf(0)
private var cursorHotY by mutableStateOf(0)
@@ -205,13 +223,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var closeAtOnce = 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 drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var fillScreen by mutableStateOf(true)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
@@ -225,7 +242,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var fexPreset by mutableStateOf("")
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 onScreenButtonsVisible by mutableStateOf(false)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
@@ -267,10 +284,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
@@ -321,7 +340,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// below) and the session starts when it is in.
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
val root = FrameLayout(this)
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
// the root sees every one of them whichever view that is.
val root = object : FrameLayout(this) {
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
super.dispatchPointerCaptureChanged(hasCapture)
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
}
}
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
@@ -384,6 +412,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener(firstFrameListener)
WaylandCompositor.setPointerLockListener(pointerLockListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
@@ -414,6 +443,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// this full-screen host and remain as a white veil after the drawer closes.
defaultFocusHighlightEnabled = false
setContent {
// The drawer, the PC keyboard and picture in picture take the mouse back.
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -469,19 +500,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
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 },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
@@ -528,7 +557,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
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,
@@ -713,28 +741,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
return
}
Thread({
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
.onFailure { Log.w(TAG, "could not package current session logs", it) }
.getOrNull()
uiHandler.post {
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
}
}
SessionLogShare.prepare(this, { folder }) { zip ->
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
}
}
}, "share-session-logs").start()
}
}
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
@@ -756,7 +778,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
@@ -1012,38 +1033,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun outputSize(): Pair<Int, Int> {
val bounds = panelBounds()
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
// square display draws for the frame it was made for and cuts the sides off itself.
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
// compositor letterboxes onto a squarer panel.
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
// fill it.
val aspect = when (SessionPrefs.shapeMode(this)) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
// the cap or lift it to the panel.
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
// A custom resolution is taken as given; the compositor fits it to the panel.
SessionPrefs.customResolution(this, mode)?.let { return it }
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
// unless the user chose a resolution for the mode.
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
else SessionPrefs.resolutionCap(this, mode)
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
val width = (height * aspect).toInt()
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
return Pair(width and 1.inv(), height and 1.inv())
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
else SessionPrefs.resolutionChoice(this, mode, panel)
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
}
/**
@@ -1138,6 +1136,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// the request landed in this instance's onNewIntent and went with its finish().
SessionState.relaunch?.let { next ->
SessionState.relaunch = null
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
setIntent(next)
recreate()
return@runOnUiThread
@@ -1261,7 +1260,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
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 DRAWER_PAGES - 1) % DRAWER_PAGES
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
releaseDrawerDirection()
}
return true
@@ -1348,6 +1348,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchGenericMotionEvent(event)
}
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
@@ -1698,6 +1702,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
val d = InputDevice.getDevice(id)
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
}
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
private fun updatePointerCapture() {
if (!::surfaceView.isInitialized) return
val view = window.decorView
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
if (want == view.hasPointerCapture()) return
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
/**
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
*/
private fun onCapturedPointer(event: MotionEvent): Boolean {
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
var dx = event.x
var dy = event.y
for (i in 0 until event.historySize) {
dx += event.getHistoricalX(i)
dy += event.getHistoricalY(i)
}
WaylandCompositor.sendPointerDelta(dx, dy)
}
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
val button = when (event.actionButton) {
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
else -> PointerGestures.BTN_LEFT
}
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
}
MotionEvent.ACTION_SCROLL -> {
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
}
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
else -> return false
}
return true
}
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
/** Which controller the session has, so a pad report says what was plugged in and when. */
private fun logInputDevice(what: String, deviceId: Int) {
@@ -1714,6 +1767,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
return
@@ -1729,6 +1783,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
@@ -1843,6 +1898,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
recreate()
return
}
// Home/notification resumes must retain the external frontend's return destination.
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
setIntent(intent)
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
@@ -1867,6 +1924,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
resumed = true
updatePointerCapture()
if (!pipUi) sessionClipboard.start()
updatePadMotion()
readPrefs()
@@ -1886,6 +1944,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePointerCapture()
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
@@ -1906,6 +1965,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
goFullscreen()
refreshClipboard()
}
// Android drops a capture with the focus; a lock still held takes it again on return.
updatePointerCapture()
}
/** The keyboard/trackpad presentation can own focus instead of the main display. */
@@ -1933,6 +1994,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
private val firstFrameListener = Runnable {
SessionEvents.firstFrame()
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
runOnUiThread {
loading.visible = false
hud.start()
@@ -1955,6 +2017,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
WaylandCompositor.clearPointerLockListener(pointerLockListener)
super.onDestroy()
}
@@ -1977,6 +2040,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* off, it just closes.
*/
override fun finish() {
if (isFinishing) return
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
if (!leaving) {
@@ -1987,6 +2051,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
super.finish()
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
com.droiddeck.launcher.ui.QuitFlood.take()
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
@@ -2002,6 +2072,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
/** How long the leaving flood may take before the session closes without it. */
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.core
import android.content.Context
object AppUiPrefs {
const val DEFAULT_SCALE = 100
val scales = listOf(75, 85, 100, 115, 125, 150)
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
fun scale(context: Context): Int = normalizeScale(
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
)
fun setScale(context: Context, percent: Int) {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
.putInt("scale", normalizeScale(percent)).apply()
}
}
@@ -108,9 +108,12 @@ object DeviceReport {
h("Display")
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
k("Shape setting", SessionPrefs.shapeMode(context))
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice)
k(label, "$choice (${size.first}x${size.second})")
}
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -163,7 +166,6 @@ object DeviceReport {
k("proot fast path", SessionPrefs.prootFastPath(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
@@ -175,6 +177,7 @@ object DeviceReport {
SessionPrefs.GAME_STORAGE_OFF -> "internal only"
else -> g
})
k("Skip space reservation", SessionPrefs.skipLibraryPreallocation(context))
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
@@ -25,7 +25,7 @@ object GameEnvironment {
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?>(
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
@@ -37,8 +37,12 @@ object GameEnvironment {
"DOTNET_EnableWriteXorExecute" to "0",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
// Steam settings' Force SSBS: droiddeck-game-env preloads libssbs.so into the game's Wine.
if (forceSsbs) put(FORCE_SSBS, "1")
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -190,6 +190,14 @@ object LogRedactor {
false
}
/**
* Bump whenever a pattern is added or tightened. A session folder records the version it was
* scrubbed under (SessionArtifacts.SCRUBBED_TREE_MARKER), and a share copies a file as it is
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
* out.
*/
const val RULES_VERSION = 1
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(initialDir = initialDir)
}
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
?.let { File(it) }?.takeIf { it.isDirectory }
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(
pickMode = true,
@@ -0,0 +1,253 @@
package com.droiddeck.launcher.frontend
import java.io.File
import java.util.Locale
/**
* Best-effort detection of the Windows redistributables a game bundles, by scanning its install
* folder for the redist installers Steam/Epic/GOG ship (`_CommonRedist/`, `_Redist/`,
* `Redistributables/`, loose `vcredist_x64.exe`, …) and mapping them to entries in our component
* catalog (WinComponents, Bannerlator's components.json `name` keys). The result drives the
* Recommended part of a game's Windows components; the user still switches each one on.
* Bannerlator's detector, unchanged but for this note and its package.
*
* Wine-aware: keeps the redists that actually matter under Wine/DXVK (VC++ runtimes, .NET, PhysX,
* OpenAL, XACT, XNA, GFWL) and SKIPS the ones DXVK/VKD3D already provide (DX12 Agility SDK) or that
* aren't our components (anti-cheat, Rockstar/Social-Club/launcher setups). Never throws; returns an
* empty list on anything unexpected. Pure filesystem — no network, no Android deps (unit-testable).
*/
object DependencyDetector {
/** How a recommendation was found — drives the UI's "recommended vs optional" split. */
enum class Kind {
/** The game bundles the redist installer (in _CommonRedist etc.) — meant to be installed. */
BUNDLED,
/** A runtime DLL the game ships loose — usually already works from the game folder; install
* only if the game misbehaves. */
SHIPPED,
/** Steam installs it with the game from Steamworks Shared ([SteamRedists]). */
STEAM,
}
/** A recommended component: the catalog [componentName] to install, a display [label], the on-disk
* redist that triggered it ([reason]), and [kind] (bundled installer vs shipped DLL). */
data class Recommendation(val componentName: String, val label: String, val reason: String, val kind: Kind = Kind.BUNDLED)
private const val MAX_DEPTH = 5
private const val MAX_ENTRIES = 8000 // cost guard against giant game trees
/** A loose VC++ installer with no year in its path — resolved to "latest" only if no specific
* vcredistYYYY was found anywhere (else it's just a duplicate of the versioned one). */
private const val VCREDIST_LOOSE = "__vcredist_loose"
/** Directory names that are redist containers worth descending into. */
private val REDIST_DIR_RE = Regex(
"""(?i)^(_?commonredist|_?redist(ributables)?|__?installer|vcredist|vc_redist|directx|direct_x|physx|openal|dotnet(fx)?|\.net|xna|xact|redist|support)$"""
)
/** Skip whole subtrees that are never our components. */
private val SKIP_DIR_RE = Regex("""(?i)^(d3d12.*|.*agility.*|dxvk|vkd3d|battleye|easyanticheat|eac|anticheat)$""")
private val YEAR_TO_VCREDIST = mapOf(
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010",
"2012" to "vcredist2012", "2013" to "vcredist2013", "2015" to "vcredist2015",
"2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
)
private val LABELS = mapOf(
"vcredist2005" to "Visual C++ 2005", "vcredist2008" to "Visual C++ 2008",
"vcredist2010" to "Visual C++ 2010", "vcredist2012" to "Visual C++ 2012",
"vcredist2013" to "Visual C++ 2013", "vcredist2015" to "Visual C++ 2015-2017",
"vcredist2019" to "Visual C++ 2019", "vcredist2022" to "Visual C++ 2015-2022",
"physx" to "NVIDIA PhysX", "oalinst" to "OpenAL", "xact" to "DirectX XACT audio",
"xact_x64" to "DirectX XACT audio (x64)", "xna40" to "XNA Framework 4.0",
"xna31" to "XNA Framework 3.1", "d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
"XLiveRedist" to "Games for Windows Live", "dotnet40" to ".NET Framework 4.0",
"dotnet45" to ".NET Framework 4.5", "dotnet452" to ".NET Framework 4.5.2",
"dotnet46" to ".NET Framework 4.6", "dotnet472" to ".NET Framework 4.7.2",
"dotnet48" to ".NET Framework 4.8", "dotnet35" to ".NET Framework 3.5",
"dotnetcoredesktop6" to ".NET Desktop 6", "dotnetcoredesktop7" to ".NET Desktop 7",
"dotnetcoredesktop8" to ".NET Desktop 8",
)
/** Preferred install order (runtimes others may depend on first). */
private val INSTALL_ORDER = listOf(
"vcredist2005", "vcredist2008", "vcredist2010", "vcredist2012", "vcredist2013",
"vcredist2015", "vcredist2019", "vcredist2022",
"dotnet35", "dotnet40", "dotnet45", "dotnet452", "dotnet46", "dotnet472", "dotnet48",
"dotnetcoredesktop6", "dotnetcoredesktop7", "dotnetcoredesktop8",
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
)
/**
* Detect from the .exe the user picked, resolving the game root first: walk up from the exe's
* folder (bounded) to the nearest ancestor that holds a redist container (`_CommonRedist`, …) at
* its top level, and scan from there — so a nested exe (`…/Game/bin/x.exe`) still finds a root-level
* `_CommonRedist`, without over-reaching into a shared `steamapps/common` (which has no redist
* container of its own). Falls back to scanning the exe's own folder.
*/
fun detectForExe(exeFile: File): List<Recommendation> {
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
var d: File? = start
var depth = 0
while (d != null && depth < 4) {
val hasRedistContainer = try {
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
} catch (_: Throwable) { false }
if (hasRedistContainer) return detect(d)
d = d.parentFile
depth++
}
return detect(start)
}
/**
* Scan [gameDir] and return the recommended components, de-duplicated (first reason wins), in a
* stable install-friendly order. Empty when nothing is detected or [gameDir] isn't a directory.
*/
fun detect(gameDir: File): List<Recommendation> {
if (!gameDir.isDirectory) return emptyList()
val hits = LinkedHashMap<String, Recommendation>() // componentName -> first recommendation
var visited = 0
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
val name = f.name.lowercase(Locale.US)
val path = f.path.replace('\\', '/').lowercase(Locale.US)
// Bundled = in a redist container or an installer (exe/msi/cab); shipped = a loose runtime DLL.
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
Kind.BUNDLED else Kind.SHIPPED
for (comp in classify(path, name, isDir)) {
val existing = hits[comp]
if (existing == null) {
hits[comp] = Recommendation(comp, LABELS[comp] ?: comp, relativeReason(gameDir, f), kind)
} else if (existing.kind == Kind.SHIPPED && kind == Kind.BUNDLED) {
hits[comp] = existing.copy(kind = Kind.BUNDLED) // a bundled installer outranks a shipped DLL
}
}
}
fun walk(dir: File, depth: Int, insideRedist: Boolean) {
if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return
val entries = dir.listFiles() ?: return
for (f in entries) {
if (visited++ > MAX_ENTRIES) return
val lower = f.name.lowercase(Locale.US)
if (f.isDirectory) {
if (SKIP_DIR_RE.matches(lower)) continue
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
// tree a few levels deep so runtime libraries the game ships (in bin/, etc.) are seen.
if (isRedistDir || depth < 3) walk(f, depth + 1, isRedistDir)
} else {
// Classify installers + runtime libraries (.dll/.exe/.msi) anywhere in the scanned
// tree; other files are ignored. Recommendations are advisory, so cast a wide net.
val ext = lower.substringAfterLast('.', "")
if (insideRedist || ext == "dll" || ext == "exe" || ext == "msi") consider(f, isDir = false, insideRedist = insideRedist)
}
}
}
return try {
walk(gameDir, 0, false)
resolveLooseVcredist(hits)
hits.values.sortedBy { INSTALL_ORDER.indexOf(it.componentName).let { i -> if (i < 0) 999 else i } }
} catch (_: Throwable) {
emptyList()
}
}
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
val loose = hits.remove(VCREDIST_LOOSE) ?: return
val hasSpecific = hits.keys.any { it in YEAR_TO_VCREDIST.values }
if (!hasSpecific) hits["vcredist2022"] = loose.copy(componentName = "vcredist2022", label = LABELS["vcredist2022"]!!)
}
private fun relativeReason(gameDir: File, f: File): String {
val g = gameDir.path.replace('\\', '/')
val p = f.path.replace('\\', '/')
return if (p.startsWith("$g/")) p.substring(g.length + 1) else f.name
}
/**
* Map a redist file/dir (its full lowercased [path], leaf [name], and whether it [isDir]) to
* catalog component name(s). Empty for things we deliberately skip.
*/
private fun classify(path: String, name: String, isDir: Boolean): List<String> {
// Hard skips first — DX12 is provided by DXVK/VKD3D; these aren't our components.
if (path.contains("d3d12") || path.contains("agility") ||
path.contains("battleye") || path.contains("anticheat") || path.contains("easyanticheat") ||
name.contains("socialclub") || name.contains("social-club") ||
name.contains("rockstar") || name == "setup.exe"
) return emptyList()
// Visual C++ runtimes — most important. Year comes from the path context.
if (path.contains("vcredist") || path.contains("vc_redist") || name.startsWith("vcredist") || name.startsWith("vc_redist")) {
val year = YEAR_TO_VCREDIST.keys.firstOrNull { path.contains(it) }
return when {
year != null -> listOf(YEAR_TO_VCREDIST.getValue(year))
isDir -> emptyList() // bare "vcredist" container — wait for the year subfolder / installer
else -> listOf(VCREDIST_LOOSE) // loose installer, no year → resolved post-walk
}
}
// VC++ runtime DLL the game ships (msvcp140.dll → 2015-2022, msvcp120.dll → 2013, …).
VCRUNTIME_DLL.entries.firstOrNull { name.startsWith(it.key) }?.let { return listOf(it.value) }
if (path.contains("openal") || name.startsWith("oalinst") || name == "openal32.dll" || name == "wrap_oal.dll") return listOf("oalinst")
if (path.contains("physx") || name.startsWith("physx")) return listOf("physx")
if (path.contains("xact") || name.startsWith("xactengine") || name.startsWith("x3daudio")) return listOf("xact", "xact_x64")
// Games for Windows Live — the redist folder, OR a GFWL library the game ships (xlive.dll,
// Codemasters' dbxLive32/64, gfwl*): GFWL titles need XLiveRedist to run even when they don't
// bundle the redist itself.
if (path.contains("xliveredist") || name.contains("xlive") || name.startsWith("gfwl") || name.startsWith("dbxlive")) {
return listOf("XLiveRedist")
}
if (path.contains("xna")) return listOf(if (path.contains("3.1") || path.contains("3_1") || path.contains("v31")) "xna31" else "xna40")
// .NET — Framework and Core/Desktop runtimes.
if (path.contains("dotnet") || path.contains(".net") || name.startsWith("ndp") || name.startsWith("dotnetfx") || name.contains("windowsdesktop-runtime")) {
val comp = dotnetComponent("$path $name")
return when {
comp != null -> listOf(comp)
isDir -> emptyList() // bare ".NET" container — wait for the versioned installer
else -> listOf("dotnet48") // unlabeled .NET Framework installer file → sensible default
}
}
// DirectX 11 helper DLLs the game ships (d3dx11_43.dll).
if (D3DX11_RE.matches(name)) return listOf("d3dx11")
// DirectX 9 helper DLLs / d3dcompiler / June-2010 DX redist (DXVK provides core d3d9 but NOT
// these helper DLLs).
if (name.startsWith("dxsetup") || name.startsWith("dxwebsetup") ||
D3DX9_RE.matches(name) || D3DCOMPILER_RE.matches(name) ||
(path.contains("directx") && name.endsWith(".exe"))
) return listOf("d3dx9")
return emptyList()
}
/** Runtime DLL prefix → the VC++ redistributable that provides it (checked with startsWith). */
private val VCRUNTIME_DLL = linkedMapOf(
"vcruntime140" to "vcredist2022", "msvcp140" to "vcredist2022", "msvcr140" to "vcredist2022", "concrt140" to "vcredist2022",
"msvcp120" to "vcredist2013", "msvcr120" to "vcredist2013",
"msvcp110" to "vcredist2012", "msvcr110" to "vcredist2012",
"msvcp100" to "vcredist2010", "msvcr100" to "vcredist2010",
"msvcp90" to "vcredist2008", "msvcr90" to "vcredist2008",
"msvcp80" to "vcredist2005", "msvcr80" to "vcredist2005",
)
private val D3DX9_RE = Regex("""d3dx9_\d+\.dll""")
private val D3DX11_RE = Regex("""d3dx11_\d+\.dll""")
private val D3DCOMPILER_RE = Regex("""d3dcompiler_\d+\.dll""")
/** Resolve a .NET component from installer/path text, or null when no version is discernible. */
private fun dotnetComponent(s: String): String? = when {
s.contains("desktop-runtime-8") || Regex("""\b8\.\d""").containsMatchIn(s) -> "dotnetcoredesktop8"
s.contains("desktop-runtime-7") || Regex("""\b7\.\d""").containsMatchIn(s) -> "dotnetcoredesktop7"
s.contains("desktop-runtime-6") || Regex("""\b6\.\d""").containsMatchIn(s) -> "dotnetcoredesktop6"
s.contains("ndp48") || s.contains("fx48") || s.contains("4.8") -> "dotnet48"
s.contains("ndp472") || s.contains("4.7.2") -> "dotnet472"
s.contains("ndp46") || s.contains("4.6") -> "dotnet46"
s.contains("ndp452") || s.contains("4.5.2") -> "dotnet452"
s.contains("dotnetfx45") || s.contains("ndp45") || s.contains("4.5") -> "dotnet45"
s.contains("dotnetfx40") || s.contains("ndp40") || s.contains("4.0") -> "dotnet40"
s.contains("dotnetfx35") || s.contains("3.5") -> "dotnet35"
else -> null // no version signal (e.g. a bare "DotNet" container dir)
}
}
@@ -12,7 +12,7 @@ import java.io.InputStream
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
object GameFiles {
const val EXTENSION = "droiddeck"
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
@@ -46,35 +46,49 @@ object GameFiles {
}
if (count > MAX_BYTES) return null
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
return GameLaunchLink.parse(text) ?: text.takeIf(GameLaunchLink::validId)
return text.takeIf(GameLaunchLink::validId)
}
fun filename(game: Library.SteamGame): String {
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
.trim().trim('.').ifBlank { "Game" }
val points = sanitized.codePoints().limit(40).toArray()
val title = String(points, 0, points.size)
return "$title (${game.gameIdString}).$EXTENSION"
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
private fun contents(id: String): String {
require(GameLaunchLink.validId(id))
return id
}
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
exportNamed(folder, game, filename(game))
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
require(folder.isDirectory) { "Folder is unavailable" }
val target = child(folder, filename(game))
val text = contents(game.gameIdString)
val target = exportTarget(folder, game, name)
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
if (!target.exists()) write(target, text)
exportArt(folder, game)
return target
}
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
val target = child(folder, name)
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
child(folder, filename(game, disambiguate = true))
} else target
}
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
private fun exportArt(folder: File, game: Library.SteamGame) {
val art = listOfNotNull(game.icon, game.art).firstOrNull {
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
} ?: return
val title = filename(game).substringBeforeLast(" (${game.gameIdString})")
val title = filename(game).substringBeforeLast(".")
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
@@ -95,12 +109,20 @@ object GameFiles {
record.getJSONObject("games")
} else JSONObject()
val next = JSONObject()
val exports = games.distinctBy { it.gameId }.map { game ->
val target = child(folder, filename(game))
val unique = games.distinctBy { it.gameId }
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
val exports = unique.map { game ->
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
val preferred = child(folder, name)
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
else exportTarget(folder, game, name)
val text = contents(game.gameIdString)
val existed = target.exists()
// Keep an edited export without letting it block the rest of the library sync.
val file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
!matches(target, contents(game.gameIdString))) target else export(folder, game)
next.put(file.name, game.gameIdString)
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
val file = if (owned && target.exists() &&
!matches(target, text)) target else exportNamed(folder, game, target.name)
if (!existed || owned) next.put(file.name, game.gameIdString)
file
}
// Write new exports before pruning; a failed write must not remove old launch files.
@@ -119,6 +141,16 @@ object GameFiles {
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
}
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
if (record.optString(file.name) == id) return true
if (!file.exists()) return false
return record.keys().asSequence().any { name ->
record.optString(name) == id && child(folder, name).let { old ->
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
}
}
}
private fun child(folder: File, name: String): File {
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
val file = File(folder, name)
@@ -0,0 +1,32 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.content.Intent
object GameLaunchIntent {
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
const val EXTRA_APP_ID = "app_id"
const val EXTRA_GAME_SOURCE = "game_source"
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
fun read(context: Context, intent: Intent): String? = when (intent.action) {
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
ACTION, GAMENATIVE_ACTION -> readId(intent)
else -> null
}
@Suppress("DEPRECATION")
internal fun readId(intent: Intent): String? = runCatching {
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
is Int -> value.takeIf { it > 0 }?.toString()
is String -> value.takeIf(GameLaunchLink::validId)
else -> null
}
id
}.getOrNull()
}
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import org.json.JSONObject
import java.io.File
/**
* Which catalog components a game's prefix already has from elsewhere - a real installer, the game,
* the user - so the list can say so instead of recommending them again. Bannerlator's detector,
* on DroidDeck's compatdata/<id>: only markers absent from a fresh Proton prefix count (OpenAL32,
* Mono, PhysX, Games for Windows Live); msvcp140, d3dx9 and the like ship with Proton and cannot
* tell. Files DroidDeck put there itself (droiddeck-wincomponents' state) are not counted.
* Pure filesystem reads; never throws.
*/
object PrefixInstalledDetector {
private const val STATE = ".droiddeck-wincomponents.json"
/** Detector names (oalinst, mono, physx, XLiveRedist) present in [compat]/pfx. */
fun detect(compat: File?): Set<String> = try {
val pfx = compat?.let { File(it, "pfx") }
if (pfx == null || !pfx.isDirectory) emptySet() else {
val ours = ours(compat)
val out = HashSet<String>()
val openal = listOf("system32", "syswow64").map { "drive_c/windows/$it/openal32.dll" }
if (openal.any { it !in ours && fileIgnoringCase(pfx, it) }) out += "oalinst"
if (File(pfx, "drive_c/windows/mono").isDirectory) out += "mono"
if (hasPhysX(pfx)) out += "physx"
if (registryContains(pfx, "Games for Windows")) out += "XLiveRedist"
out
}
} catch (_: Throwable) {
emptySet()
}
private fun ours(compat: File): Set<String> = try {
val files = JSONObject(File(compat, STATE).readText()).optJSONObject("files")
files?.keys()?.asSequence()?.map { it.lowercase() }?.toSet().orEmpty()
} catch (_: Exception) {
emptySet()
}
/** Windows names ignore case; the prefix's folder may spell them either way. */
private fun fileIgnoringCase(pfx: File, rel: String): Boolean {
val target = File(pfx, rel)
return target.parentFile?.listFiles()?.any { it.isFile && it.name.equals(target.name, ignoreCase = true) } == true
}
private fun hasPhysX(pfx: File): Boolean {
for (pf in arrayOf("drive_c/Program Files", "drive_c/Program Files (x86)")) {
val base = File(pfx, pf)
if (File(base, "NVIDIA Corporation/PhysX").isDirectory) return true
if (base.listFiles()?.any { it.isDirectory && it.name.contains("PhysX", ignoreCase = true) } == true) return true
}
return false
}
private fun registryContains(pfx: File, needle: String): Boolean =
arrayOf("system.reg", "user.reg").any { name ->
val f = File(pfx, name)
try {
f.isFile && f.length() < 32L * 1024 * 1024 &&
f.bufferedReader().use { r -> r.lineSequence().any { it.contains(needle, ignoreCase = true) } }
} catch (_: Throwable) {
false
}
}
}
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.frontend
import java.io.File
/**
* What Steam itself installs alongside a game: the Steamworks Shared redistributables (app 228980)
* listed under "SharedDepots" in the game's appmanifest. Those live in Steamworks Shared rather than
* the game's folder, so the folder scan ([DependencyDetector]) cannot see them; this reads Steam's
* list instead. Depots are matched to Bannerlator's catalog names; unknown ones are left out.
*/
object SteamRedists {
/** Steamworks Shared depot -> (catalog component, what Steam calls it). 2015-2019 runtimes are one family. */
val DEPOTS = linkedMapOf(
"228981" to ("vcredist2005_dll" to "VC++ 2005"),
"228982" to ("vcredist2008_dll" to "VC++ 2008"),
"228983" to ("vcredist2010_dll" to "VC++ 2010"),
"228984" to ("vcredist2012_dll" to "VC++ 2012"),
"228985" to ("vcredist2013_dll" to "VC++ 2013"),
"228986" to ("vcredist2015_dll" to "VC++ 2015"),
"228987" to ("vcredist2019_dll" to "VC++ 2017"),
"228988" to ("vcredist2019_dll" to "VC++ 2019"),
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
"228990" to ("d3dx9" to "DirectX (June 2010)"),
)
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
/** The game's appmanifest beside its install folder (steamapps/common/<dir> -> steamapps/). */
fun manifest(gameDir: File?, appId: Int): File? {
val steamapps = gameDir?.parentFile?.parentFile ?: return null
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
}
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
val text = try {
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
} catch (_: Exception) {
return emptyList()
}
val block = BLOCK.find(text)?.groupValues?.get(1) ?: return emptyList()
return ENTRY.findAll(block).mapNotNull { DEPOTS[it.groupValues[1]] }
.distinctBy { it.first }
.map { (component, label) -> DependencyDetector.Recommendation(component, label, label, DependencyDetector.Kind.STEAM) }
.toList()
}
}
@@ -0,0 +1,166 @@
package com.droiddeck.launcher.gpu
import android.app.ActivityManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.Locale
/**
* Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system,
* the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once
* ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are
* left out rather than guessed.
*/
object DeviceInfo {
class Section(val title: String, val rows: List<Pair<String, String>>)
fun collect(context: Context): List<Section> {
val vk = VulkanInfo.query()
// After Vulkan has been asked, detection can use its name too.
val gpu = GpuInfo.detect()
return listOf(
chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu),
).filter { it.rows.isNotEmpty() }
}
/** The whole window as plain text, for a bug report. */
fun text(sections: List<Section>): String = buildString {
append("DroidDeck device info\n")
for (s in sections) {
append('\n').append(s.title).append('\n')
for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n')
}
}
private fun MutableList<Pair<String, String>>.add(key: String, value: String?) {
if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim())
}
private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null }
private fun prop(name: String): String = GpuInfo.systemProperty(name)
private fun chip() = Section("Chip (SoC)", buildList<Pair<String, String>> {
val model = SocNames.model()
val platform = prop("ro.board.platform")
add("Name", SocNames.name(model, platform))
add("Model", model)
if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER)
add("Platform", platform)
add("Hardware", Build.HARDWARE)
add("Board", Build.BOARD)
val id = read("/sys/devices/soc0/soc_id")
val machine = read("/sys/devices/soc0/machine")
add("SoC id", listOfNotNull(id, machine).joinToString(" · "))
add("Family", read("/sys/devices/soc0/family"))
})
private fun gpu(gpu: GpuInfo) = Section("GPU", buildList<Pair<String, String>> {
add("GPU", gpu.name)
add("Family", gpu.family.label)
add("Model found from", when (gpu.modelSource) {
"kernel" -> "the kernel's GPU name"
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
"platform" -> "the platform (the kernel's name has no model)"
else -> "unknown"
})
add("Kernel name", gpu.kgslName)
val kgsl = "/sys/class/kgsl/kgsl-3d0"
add("Clock range", gpuRange())
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" })
add("Support", gpu.supportText)
})
private fun cpu() = Section("CPU", buildList<Pair<String, String>> {
add("Cores", Runtime.getRuntime().availableProcessors().toString())
// Each cpufreq policy is one cluster: the cores it covers and their speed range.
val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) }
?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty()
for (p in policies) {
val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull()
val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue
val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}"
add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " +
(if (min != null) ghz(min) + "–" else "") + ghz(max))
}
add("ABI", Build.SUPPORTED_ABIS.joinToString(", "))
})
private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6)
/** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */
private fun gpuRange(): String? {
fun mhz(hz: Long) = "${hz / 1_000_000} MHz"
val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies")
?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)"
read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" }
val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f ->
val n = f.name.lowercase()
(n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) }
} ?: return null
val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)"
val min = read("${node.path}/min_freq")?.toLongOrNull()
val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null
return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max)
}
private fun memory(context: Context) = Section("Memory", buildList<Pair<String, String>> {
val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList
val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo)
add("Total", FileUtils.sizeToString(mi.totalMem))
add("Available now", FileUtils.sizeToString(mi.availMem))
})
private fun system() = Section("System", buildList<Pair<String, String>> {
add("Device", "${Build.MANUFACTURER} ${Build.MODEL}")
add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
add("Security patch", Build.VERSION.SECURITY_PATCH)
add("Kernel", System.getProperty("os.version"))
add("Build", Build.DISPLAY)
})
private fun vulkan(context: Context, vk: Map<String, String>) = Section("Vulkan (the phone's own driver)", buildList<Pair<String, String>> {
vk["error"]?.let { add("Error", it) }
add("Device", vk["device"])
add("Type", vk["type"])
add("API version", vk["api"])
add("Loader version", vk["loader"])
add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" "))
// Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ...").
add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), ""))
add("Conformance", vk["conformance"])
add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / "))
add("Max texture size", vk["max_image_2d"])
add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" })
add("Extensions", vk["extension_count"])
val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures")
for ((key, label) in features) add(label, vk[key])
for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value)
// What Android certifies the phone for.
val pm = context.packageManager
val hw = pm.systemAvailableFeatures.associate { it.name to it.version }
hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let {
add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}")
}
hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) }
})
private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList<Pair<String, String>> {
add("Driver mode", SessionPrefs.gpuDriverMode(context))
val turnip = TurnipDriver(context)
val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() }
add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim())
val linux = LinuxVulkanDriverManager(context)
val id = SessionPrefs.linuxDriver(context)
add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim())
if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build")
})
}
@@ -16,10 +16,14 @@ data class GpuInfo(
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
val model: Int,
val family: Family,
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
/** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */
val soc: String,
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
val oneUi8Gen2: Boolean,
/** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */
val kgslName: String = "",
/** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */
val modelSource: String = "",
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
@@ -56,17 +60,49 @@ data class GpuInfo(
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
// Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)".
val soc = if (adreno) SocNames.label() else ""
val fromKernel = raw?.let { threeDigits(it) } ?: 0
// Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The
// Vulkan driver's own name, when this process has asked it, then the platform's code
// name stand in - the GPU is the same on every phone of a platform.
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
val source = when {
fromKernel > 0 -> "kernel"
fromVulkan > 0 -> "vulkan"
fromPlatform > 0 -> "platform"
else -> ""
}
val family = familyOf(adreno, model)
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
return GpuInfo(
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
kgslName = raw.orEmpty(), modelSource = source,
)
}
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
/** Qualcomm platform code names whose GPU is known for certain. */
private val PLATFORMS = mapOf(
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
"kalama" to 740, "pineapple" to 750, "sun" to 830,
)
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
/** android.os.SystemProperties.get, which apps may read but not call directly. */
fun systemProperty(name: String): String = try {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
} catch (_: Exception) {
""
}
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
!adreno -> Family.NOT_ADRENO
model == 0 -> Family.ADRENO_UNKNOWN
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.gpu
import android.os.Build
/**
* Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank
* (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order
* ClusterTune uses; then it is named from a table of models we are sure of, or from the platform
* code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant.
*/
object SocNames {
/** Model codes whose product name is certain. */
private val MODELS = mapOf(
"SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870",
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
"SM6375" to "Snapdragon 695",
)
/** Platform code names: every chip of one platform shares its CPU and GPU design. */
private val PLATFORMS = mapOf(
"msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888",
"taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2",
"pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite",
)
/** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */
fun model(prop: (String) -> String = GpuInfo::systemProperty): String {
val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else ""
return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform"))
.map { it.trim() }
// ro.fota.platform is often a branch name ("MSM_13.0"), not a chip.
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
}
/** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
/** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
val name = name(model, platform)
return when {
name != null && model.isNotEmpty() -> "$name ($model)"
name != null -> name
else -> model
}
}
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.gpu
import android.util.Log
/**
* The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and
* driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and
* kept; the first [query] creates a Vulkan instance, so it belongs off the main thread.
*/
object VulkanInfo {
@Volatile private var cached: Map<String, String>? = null
@JvmStatic private external fun nativeQuery(): String
/** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */
@Synchronized
fun query(): Map<String, String> {
cached?.let { return it }
val text = try {
System.loadLibrary("deviceinfo")
nativeQuery()
} catch (t: Throwable) {
Log.w("VulkanInfo", "query failed", t)
"error=" + (t.message ?: t.javaClass.simpleName)
}
val map = text.lineSequence().mapNotNull { line ->
val eq = line.indexOf('=')
if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1)
}.toMap(LinkedHashMap())
cached = map
return map
}
/** What [query] found, if it has run in this process; never blocks. */
fun cachedOrNull(): Map<String, String>? = cached
}
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.input;
import java.io.File;
/**
* Where the fake input rings live and how the guest is told about them. Kept apart from
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
*/
final class FakeInputRingPaths {
static final String RING_DIR_NAME = "fakeinput-rings";
private FakeInputRingPaths() {}
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
static File ringDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
static File ringFile(File fakeInputDir, int slot) {
File ringDir = ringDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
/**
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
* the export has to use that same spelling. A canonical path is not that: where
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
*/
static String exportPath(File ringFile) {
return ringFile.getAbsolutePath();
}
}
@@ -94,7 +94,6 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
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.
@@ -198,26 +197,8 @@ public class FakeInputWriter {
return builder.toString();
}
private static File getRingDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
private static File getRingFile(File fakeInputDir, int slot) {
File ringDir = getRingDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
private static String getCanonicalOrAbsolutePath(File file) {
try {
return file.getCanonicalPath();
} catch (IOException e) {
return file.getAbsolutePath();
}
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
}
private static void initializeRingHeader(ByteBuffer data) {
@@ -325,7 +306,7 @@ public class FakeInputWriter {
ringSlot.ringFile = ringFile.getAbsoluteFile();
ringSlot.ringRaf = raf;
ringSlot.ringChannel = channel;
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
return ringSlot;
} catch (IOException e) {
@@ -76,6 +76,17 @@ public final class PadBridge {
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
private int lastDeviceId = Integer.MIN_VALUE;
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
/**
* The Android input device id of the physical controller that last drove the pad, or
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
* on that slot finds the motors in the player's hands (RumbleComponent).
*/
private static volatile int activeControllerId = NO_CONTROLLER;
public static int activeControllerId() { return activeControllerId; }
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
}
@@ -90,6 +101,7 @@ public final class PadBridge {
}
public synchronized void stop() {
activeControllerId = NO_CONTROLLER;
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
@@ -218,6 +230,7 @@ public final class PadBridge {
*/
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statOnScreen++;
scheduleStats();
@@ -299,6 +312,7 @@ public final class PadBridge {
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
activeControllerId = device.getId();
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
@@ -300,7 +300,10 @@ object BwrapSpawner {
// 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")) {
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
// Android 16 refuses the size seal Firefox puts on a tab's shared
// memory, and the tab then dies on every page (droiddeck-desktop).
"MOZ_SHM_NO_SEALS")) {
env.putIfAbsent(name, "1")
}
}
@@ -21,13 +21,15 @@ import java.security.MessageDigest
*/
object ProotFastPath {
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
const val MAX_BINDS = 256
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
fun key(root: File, binds: List<String>): String? {
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
val digest = MessageDigest.getInstance("SHA-256")
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
return digest.take(12).joinToString("") { "%02x".format(it) }
@@ -14,7 +14,7 @@ import kotlin.math.abs
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
* names its supply `battery` and lays it out differently, and on many phones an app may not read
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
* from Android's own battery API - the same on every device - into a directory the session binds
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
*
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
private var thread: Thread? = null
override fun start() {
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
File(dir, "BAT0").deleteRecursively()
running = true
write()
thread = Thread({
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
if (running) write()
}
}, "battery-sysfs").apply { isDaemon = true; start() }
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
}
override fun stop() {
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
pct <= 15 -> "Low"
else -> "Normal"
}
for (name in listOf("BAT0", "BAT1")) {
for (name in listOf("BAT1")) {
val bat = File(dir, name).apply { mkdirs() }
val attrs = linkedMapOf(
"type" to "Battery",
@@ -5,6 +5,7 @@ import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
@@ -660,6 +661,107 @@ object ComponentsManager {
return Catalog(items, o.optLong("fetchedAt"))
}
/**
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
* means every model in [families]. A row the user already chose in Components is left alone;
* a Steam update that puts the Proton's own files back is replaced on the next session.
*/
internal data class ForcedPackage(
val families: Set<GpuInfo.Family>,
val comp: String,
val file: String,
val release: String,
val models: Set<Int> = emptySet(),
) {
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
}
internal val FORCED_PACKAGES = listOf(
// DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on
// Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag.
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
)
/**
* True when this Proton should receive the forced package. No active swap means the Proton's
* own copy. A record for a different Proton build means a Steam update put that copy back,
* whichever package was recorded. A record for this build is a Components choice and stays.
*/
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
activeFile.isNullOrEmpty() || !protonVersionMatches
/** The catalog row for this file and release, or null. */
internal fun catalogMatch(items: List<CatalogItem>, file: String, release: String): CatalogItem? =
items.firstOrNull { it.file == file && it.release == release }
/**
* Install every [FORCED_PACKAGES] row this GPU matches into each Proton that
* [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these
* Protons need nothing. A failed fetch throws and the session logs it without stopping.
*/
fun ensureForcedPackages(context: Context): String? = ensureForcedPackages(
context, GpuInfo.detect(),
{ file, release -> catalogItem(context, file, release) },
{ download(context, it) {} },
{ id, file -> swap(context, id, file) },
)
internal fun ensureForcedPackages(
context: Context,
gpu: GpuInfo,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
apply: (protonId: String, file: String) -> String,
): String? {
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }
if (rules.isEmpty()) return null
val installed = protons(context)
val lines = mutableListOf<String>()
for (rule in rules) {
val state = loadState(context)
val targets = installed.filter { p ->
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
}
if (targets.isEmpty()) continue
val missing = missingPackage(context, rule, lookup, fetch)
if (missing != null) {
lines.add(missing)
continue
}
lines.add(targets.joinToString("; ") { apply(it.id, rule.file) })
}
return lines.joinToString("; ").ifEmpty { null }
}
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
private fun missingPackage(
context: Context,
rule: ForcedPackage,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
): String? {
val wcp = File(packagesDir(context), safeName(rule.file))
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
wcp.delete()
val item = lookup(rule.file, rule.release)
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
fetch(item)
return null
}
/** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */
internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? =
resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items })
/** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */
internal fun resolveCatalogItem(
file: String,
release: String,
cached: () -> List<CatalogItem>,
refresh: () -> List<CatalogItem>,
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
/** 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" }
@@ -59,7 +59,7 @@ object GameEnvironmentStore {
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
val json = encode(resolved)
// The Display page's texture filtering, as DXVK options the launcher appends to
// DXVK_CONFIG after the profiles - a user's own DXVK_CONFIG entry keeps its options.
@@ -3,43 +3,58 @@ package com.droiddeck.launcher.session
import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.view.InputDevice
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.DataInputStream
import kotlin.math.max
/**
* Rumble for the on-screen pad, on the phone itself.
* Rumble for the pad, on the motors in the player's hands.
*
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
* a missing listener costs the guest nothing, and a bad packet is dropped.
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
* values. This listens for those and plays the effect on the physical controller that last drove
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
* missing listener costs the guest nothing, and a bad packet is dropped.
*/
class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var vibrator: Vibrator? = null
private var phone: Motors? = null
/** Where the last effect went, so the next one (or a stop) can end it there. */
private var playing: Motors? = null
private var controllerId = PadBridge.NO_CONTROLLER
private var controller: Motors? = null
private var preferences: SharedPreferences? = null
private var enabled = false
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == null || key == "rumble") refreshEnabled()
}
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
internal var controllerMotors: (Int) -> Motors? = ::lookupController
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
if (!enabled) vibrator?.cancel()
if (!enabled) { playing?.cancel(); playing = null }
}
override fun start() {
val ctx = app() ?: return
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
refreshEnabled()
server = s
@@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() {
try {
val bytes = ByteArray(8)
DataInputStream(client.inputStream).readFully(bytes)
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
buzz(max(strong, weak), ms)
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
} catch (e: Exception) {
// A partial packet or a closed peer: nothing to play.
} finally {
@@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() {
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
preferences = null
enabled = false
vibrator?.cancel()
vibrator = null
playing?.cancel()
playing = null
phone = null
controller = null
controllerId = PadBridge.NO_CONTROLLER
val s = server
server = null
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
@@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() {
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
@Synchronized private fun buzz(strength: Int, ms: Int) {
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
if (!enabled) return
val vibrator = vibrator ?: return
if (!vibrator.hasVibrator()) return
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
val duration = ms.toLong().coerceIn(1L, 5000L)
try {
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
} catch (e: Exception) {
Log.w(TAG, "rumble: $e")
val target = target()
if (target !== playing) {
playing?.cancel()
playing = target
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
try {
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
}
/** The active controller's motors when it has any, else the phone's. */
private fun target(): Motors? {
val id = PadBridge.activeControllerId()
if (id != controllerId) {
controllerId = id
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
}
return controller ?: phone
}
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
internal interface Motors {
val name: String
fun play(strong: Int, weak: Int, ms: Long)
fun cancel()
}
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
override val name = "the device's vibrator"
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
@RequiresApi(31)
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) {
val combined = CombinedVibration.startParallel()
var any = false
fun add(id: Int, strength: Int) {
if (strength == 0) return
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
any = true
}
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
if (any) manager.vibrate(combined.combine()) else manager.cancel()
}
override fun cancel() = manager.cancel()
}
/** Before Android 12 a pad has one vibrator. */
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
companion object {
private const val TAG = "SessionService"
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
const val NAME = "droiddeck-rumble"
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
else VibrationEffect.DEFAULT_AMPLITUDE
return VibrationEffect.createOneShot(ms, amplitude)
}
private fun lookupController(id: Int): Motors? {
val device = InputDevice.getDevice(id) ?: return null
val name = "\"${device.name}\""
if (Build.VERSION.SDK_INT >= 31) {
val manager = device.vibratorManager
val ids = manager.vibratorIds.sortedArray()
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
}
@Suppress("DEPRECATION")
val vibrator = device.vibrator
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
}
}
}
@@ -34,6 +34,16 @@ object SessionArtifacts {
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/**
* Written by a session's own ending, and only when every file in the folder, subfolders
* included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
* the app wrote), went through the redactor without a failure. It lists each of those files
* with its size and modification time, under the redactor's rules version: a share copies a
* file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
* a file added or changed since, a hidden one, or every file of a folder without it.
*/
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
fun collect(context: Context, dir: File, reason: String) {
try {
@@ -44,11 +54,11 @@ object SessionArtifacts {
}
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
copySteamLogs(context, dir)
val record = Record(dir)
copySteamLogs(context, dir, record)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
markScrubbed(scrubTree(record))
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -71,8 +81,7 @@ object SessionArtifacts {
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
scrubFolder(dir)
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
@@ -84,29 +93,121 @@ object SessionArtifacts {
* through the redactor before the folder can be shared. A file is rewritten only if a line
* changed. steam/ was scrubbed on the way in.
*/
private fun scrubFolder(dir: File) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
/** A file's size and modification time as they stood right after it was scrubbed. */
private class Scrubbed(val length: Long, val modified: Long)
/** Files of a folder as they stood right after scrubbing, filled by [scrubTree] and [copySteamLogs]. */
private class Record(val dir: File) {
val files = HashMap<File, Scrubbed>()
var failed = false
}
private fun scrubFolder(dir: File, record: Record) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
try {
val tmp = File(dir, ".${f.name}.scrub")
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
}
val done = scrubFile(dir, f)
if (done == null) record.failed = true else record.files[f] = done
}
}
/** Steam's logs: redacted into steam/, never copied verbatim. */
private fun copySteamLogs(context: Context, dir: File) {
/**
* Puts [f] through the redactor in place. Returns its size and modification time as it then
* stands, or null if it could not, and [f] may still hold the original. A writer still
* appending to [f] (the app's own log capture) changes its size afterwards, and a share then
* scrubs it again.
*/
private fun scrubFile(dir: File, f: File): Scrubbed? {
val tmp = File(dir, ".${f.name}.scrub")
return try {
val before = Scrubbed(f.length(), f.lastModified())
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
val scrubbed = tmp.readBytes()
if (scrubbed.contentEquals(f.readBytes())) {
tmp.delete()
// Unchanged by the redactor, and by anything else while it read.
before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
} else {
// Shared storage can refuse a rename; then the scrubbed bytes are written over the
// original instead, so it never stays behind unscrubbed.
if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() }
Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length }
}
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
tmp.delete()
null
}
}
/**
* Records [record]'s folder as scrubbed - every file went through without a failure - or
* leaves it unmarked, so the next share and the pass over older folders scrub it again.
*/
private fun markScrubbed(record: Record) {
val dir = record.dir
val tree = File(dir, SCRUBBED_TREE_MARKER)
if (record.failed) {
tree.delete()
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
return
}
try {
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
record.files.forEach { (f, s) ->
lines.append(s.length).append('\t').append(s.modified).append('\t')
.append(f.relativeTo(dir).path).append('\n')
}
tree.writeText(lines.toString())
} catch (e: Exception) {
tree.delete()
}
}
/**
* The files of [dir] that its own ending scrubbed and that are unchanged since (same size and
* modification time), by path relative to [dir]. Empty when the folder has no such record, or
* one made under other redactor rules.
*/
fun scrubbedFiles(dir: File): Set<String> {
val lines = try { File(dir, SCRUBBED_TREE_MARKER).takeIf { it.isFile }?.readLines() } catch (e: Exception) { null }
?: return emptySet()
if (lines.firstOrNull() != "rules ${LogRedactor.RULES_VERSION}") return emptySet()
return lines.drop(1).mapNotNull { line ->
val parts = line.split('\t', limit = 3)
if (parts.size != 3) return@mapNotNull null
val f = File(dir, parts[2])
parts[2].takeIf { f.isFile && f.length().toString() == parts[0] && f.lastModified().toString() == parts[1] }
}.toSet()
}
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
@androidx.annotation.VisibleForTesting
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
/**
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
* copied files there verbatim, and a Steam log the app did not copy over again (gone from the
* runtime, or past its size limit) stayed as the script left it. Files [copySteamLogs] already
* redacted on the way in are in [record] and left alone.
*/
private fun scrubTree(record: Record): Record {
record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) }
return record
}
/** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */
private fun copySteamLogs(context: Context, dir: File, record: Record) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
if (!logs.isDirectory) return
val out = File(dir, "steam").apply { mkdirs() }
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
try {
File(out, src.name).bufferedWriter().use { w ->
val dst = File(out, src.name)
dst.bufferedWriter().use { w ->
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
}
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${src.name}", e)
}
@@ -143,12 +244,11 @@ object SessionArtifacts {
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
if (newest) {
copySteamLogs(context, dir)
}
val record = Record(dir)
if (newest) copySteamLogs(context, dir, record)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
scrubFolder(dir)
markScrubbed(scrubTree(record))
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
@@ -0,0 +1,70 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.view.WindowManager
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
object SessionDisplay {
const val MATCH_SCREEN = "screen"
/**
* Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower
* than 16:9 and never taller than the panel (SHAPE_AUTO). The client treats the output as the
* games' native resolution, so the height is what bounds a game's default size on a 1440p
* phone; the shape is what keeps the client's interface edge to edge on a 20:9 one (1600x720)
* and keeps games at 16:9 or wider on a foldable's near-square panel.
*/
const val DEFAULT_RESOLUTION = "720p"
private val PRESET_HEIGHTS = listOf(720, 900, 1080)
/** The height of a preset choice ("900p"), or null for Match screen and fixed sizes. */
fun presetHeight(choice: String): Int? =
if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> {
if (choice == MATCH_SCREEN) return screenSize(panel)
presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) }
// Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size,
// including legacy ones past the custom dialog's limit.
val parts = choice.split('x').map { it.toIntOrNull() }
return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
else resolve(panel, 720, SessionPrefs.SHAPE_AUTO)
}
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */
fun resolutionOptions(panel: Pair<Int, Int>): List<String> {
val panelHeight = minOf(panel.first, panel.second)
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
}
fun panelSize(context: Context): Pair<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
val mode = manager.defaultDisplay.mode
return maxOf(mode.physicalWidth, mode.physicalHeight) to minOf(mode.physicalWidth, mode.physicalHeight)
}
/** Interpret an older cap/aspect combination without altering the saved picture size. */
fun resolve(panel: Pair<Int, Int>, cap: Int, shape: String, custom: Pair<Int, Int>? = null): Pair<Int, Int> {
custom?.let { return it }
val panelW = maxOf(panel.first, panel.second).toFloat()
val panelH = minOf(panel.first, panel.second).toFloat().coerceAtLeast(1f)
// Auto retains wide panels but floors squarer ones at 16:9 for game compatibility.
// Match screen removes that floor; fixed 16:9 also keeps foldable sessions stable.
val aspect = when (shape) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
return ((height * aspect).toInt() and 1.inv()) to (height and 1.inv())
}
/** Mirrors gamescope's even-width 16:9 calculation, including near-16:9 rounding. */
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
}
@@ -28,14 +28,18 @@ object SessionFiles {
*/
fun stage(context: Context, root: File) {
GameEnvironmentStore.publish(context)
WinComponents.publish(context)
val files = arrayOf(
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
"usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
"libssbs.so" to "usr/local/lib/libssbs.so",
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
@@ -103,6 +107,10 @@ object SessionFiles {
"usr/local/lib/mangoapp/libtraceevent.so.1",
"usr/local/lib/mangoapp/libtracefs.so.1",
).map { it to it }
// What the Windows components installer reads .msi packages with (tools/msitools).
val msitools = listOf("msiinfo", "cabextract", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
.map { "usr/local/lib/droiddeck-msitools/$it" } +
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
// The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers.
val wlroots = if (File(root, "usr/bin/labwc").isFile) {
arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so")
@@ -114,7 +122,7 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + wlroots + mangoapp + fexPreloads).filter { (asset, _) ->
) + wlroots + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
@@ -3,6 +3,12 @@ package com.droiddeck.launcher.session
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.FileProvider
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -16,32 +22,74 @@ import java.util.zip.ZipOutputStream
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
*/
object SessionLogShare {
private val main = Handler(Looper.getMainLooper())
/**
* How far the zip for a share has got, 0..1, or null when none is being made. The Share logs
* buttons show it in place of their label, so a tap is answered at once.
*/
var progress by mutableStateOf<Float?>(null)
private set
/**
* Zips [folder] on a worker thread with [progress] following along, then hands the zip (null
* if there was nothing to zip or it failed) to [done] on the main thread. Call on the main
* thread; a tap while a zip is being made is ignored.
*/
fun prepare(context: Context, folder: () -> File?, done: (File?) -> Unit) {
if (progress != null) return
progress = 0f
Thread({
val zip = runCatching { folder()?.let { dir -> zipFolder(context, dir) { p -> main.post { if (progress != null) progress = p } } } }
.onFailure { Log.w("SessionLogShare", "could not package session logs", it) }
.getOrNull()
main.post { progress = null; done(zip) }
}, "share-logs").start()
}
/** The newest session folder in either place logs are written, or null if there is none. */
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
/** Builds the zip (blocking). Returns null when there is no session to share. */
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
/** Builds a zip for one specific session folder (blocking). */
fun zipFolder(context: Context, folder: File): File? {
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
if (!folder.isDirectory) return null
val files = folder.walkTopDown().filter { it.isFile }.toList()
// The client's logs past STEAM_LOG_MAX_BYTES stay out, as they do from the app's own copy: a
// CEF debug log or an old *.previous.txt reached 17 MB, every share scrubbed them line by
// line, and the share sheet took ten seconds to appear.
val files = folder.walkTopDown().filter { it.isFile }
.filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
if (files.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
val zip = File(out, "DroidDeck-${folder.name}.zip")
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
// session pass yet, and the redactor changes nothing in a line that is already clean.
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
// A finished folder was scrubbed whole when it ended. A file its record lists, unchanged
// since, goes in as it is; anything else (added or changed since, hidden, or any file of a
// session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on
// every share kept the share sheet from appearing for ten seconds or more.
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
val live = liveSteamLogs(context, folder)
val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L)
var done = 0L
var shown = -1
fun advance(f: File) {
done += f.length()
val percent = (done * 100 / total).toInt()
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
}
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
}
return zip
}
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
if (!scrubbed && LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
@@ -8,6 +8,7 @@ import org.json.JSONObject
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
const val SUSPEND_AUTO = "auto"
const val SUSPEND_NATIVE = "native"
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
@@ -88,27 +89,16 @@ object SessionPrefs {
const val SHAPE_WIDE = "16:9"
const val SHAPE_EXACT = "exact"
/** The choices the settings offer, in order. */
val shapeChoices = listOf(
SHAPE_AUTO to "Auto (16:9+)",
SHAPE_EXACT to "Match screen",
SHAPE_WIDE to "Always 16:9",
)
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
* display cannot change size once the session is up.
*/
fun shapeMode(context: Context): String =
private fun shapeMode(context: Context): String =
prefs(context).getString("shape", null)
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
fun setShapeMode(context: Context, mode: String) {
prefs(context).edit().putString("shape", mode).apply()
}
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
fun setOscMode(context: Context, mode: String) {
@@ -175,37 +165,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
}
/**
* Steam only: gamescope makes every game window the size of the screen. A game that resizes
* its own window when it loses focus (FlatOut) otherwise comes back smaller, drawn in a
* corner; a game that sets its own resolution and never looks at its window again (Quake 3)
* instead draws small in the bottom-left of the stretched one. On unless turned off.
*/
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", true)
fun setForceFullscreen(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("forceFullscreen", on).apply()
writeForceFullscreenFlag(context)
}
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("stretch16x9", on).apply()
}
/**
* The same choice as a file the running session watches, so the drawer can change it live:
* the session hands every change to gamescope, which reads GAMESCOPE_FORCE_WINDOWS_FULLSCREEN
* off its root window whenever it changes. Written again at every session start so a file left
* by an earlier session never disagrees with the setting.
*/
fun writeForceFullscreenFlag(context: Context) {
runCatching {
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-fill")
.writeText(if (forceFullscreen(context)) "1\n" else "0\n")
}
}
/**
* DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On
@@ -502,6 +467,14 @@ object SessionPrefs {
prefs(context).edit().putBoolean("storageDiagnostics", on).apply()
}
/** Experimental: Steam's space reservation on the second library reports success untouched. */
fun skipLibraryPreallocation(context: Context): Boolean =
prefs(context).getBoolean("skipLibraryPreallocation", false)
fun setSkipLibraryPreallocation(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("skipLibraryPreallocation", on).apply()
}
// ── Per-mode display ────────────────────────────────────────────────────────────────────
/**
@@ -512,11 +485,33 @@ object SessionPrefs {
* and the emulators under it get the same GPU headroom. Read once, when the session's display
* is sized; a cap the user chose wins over the default.
*/
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
prefs(context).contains("displayResolution.$mode") ||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
else "${legacy.first}x${legacy.second}"
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
}
prefs(context).edit().putString("displayResolution.$mode", value).apply()
}
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
@@ -528,6 +523,22 @@ object SessionPrefs {
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
/**
* Force SSBS for Proton games: Wine resumes threads from a Windows CONTEXT that never carries
* PSTATE.SSBS, so they run with speculative store bypass disabled; libssbs.so keeps it set
* (on DiRT 3 / GE-Proton: from ~99% of a game's threads running without it to none). The
* speed-up is reported on Oryon cores (Snapdragon 8 Elite) and was not measurable on an
* 8 Gen 3, so it is off unless turned on. A game's own environment can still say
* DROIDDECK_FORCE_SSBS=0.
*/
fun forceSsbs(context: Context): Boolean = prefs(context).getBoolean("forceSsbs", false)
fun setForceSsbs(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("forceSsbs", on).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
@@ -596,25 +607,13 @@ object SessionPrefs {
prefs(context).edit().putString("theme", id).apply()
}
/** What a mode gets when nothing was chosen. */
@Suppress("UNUSED_PARAMETER")
fun defaultResolutionCap(mode: String): Int = 720
fun setResolutionCap(context: Context, mode: String, cap: Int) {
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
}
/**
* A fixed size for the session's display, per mode, or null. When set it replaces both the
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
*/
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
parseResolution(prefs(context).getString("customRes.$mode", null))
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
}
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
fun parseResolution(text: String?): Pair<Int, Int>? {
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
@@ -669,20 +668,27 @@ object SessionPrefs {
/**
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
* modes): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old
* titles, the rest sharpen where the picture is enlarged; FSR (fit) rounds the picture to
* FSR's preferred size first. Sharpen only works at any size.
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
* are equivalent on Wayland, so saved aliases resolve to one choice.
*/
val upscalerChoices = listOf(
0 to "Off", 1 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 5 to "AMD FSR 1 (fit)", 3 to "Snapdragon GSR",
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
)
fun upscaler(context: Context): Int =
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
fun canonicalUpscaler(mode: Int): Int = when (mode) {
1 -> 0
5 -> 4
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
}
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
fun setUpscaler(context: Context, mode: Int) {
prefs(context).edit().putInt("upscaler", mode).apply()
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
}
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
@@ -751,11 +757,14 @@ object SessionPrefs {
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
// Direct games share Steam's settings but have no Steam client to prepare.
?.let { if (it == SUSPEND_NATIVE && mode != SessionService.MODE_STEAM) SUSPEND_AUTO else it }
?: SUSPEND_MANUAL
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
val normalized = policy.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
?: SUSPEND_MANUAL
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
}
@@ -43,6 +43,7 @@ import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/**
* Owns the running session: the proot tree, the audio daemon, the network link and the locks that
@@ -77,6 +78,10 @@ class SessionService : Service() {
private var manualPauseRequested = false
/** An explicit Steam sleep request is independent of the background policy. */
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
private var suspendAttemptFailed = false
@@ -195,6 +200,7 @@ class SessionService : Service() {
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
steamSleepToken = null
completeNativeSleep()
suspendOperationPending = false
suspendAttemptFailed = false
suspendController = null
@@ -303,6 +309,14 @@ class SessionService : Service() {
// The desktop's Steam launchers start the client there (droiddeck-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
// Some GPUs need a component Proton does not ship. Apply those before the guest, so the
// next game launch copies them into the prefix. A download failure is logged and the
// session still starts; the next session tries again.
if (steamHere) {
runCatching { ComponentsManager.ensureForcedPackages(this) }
.onSuccess { if (it != null) Log.i(TAG, it) }
.onFailure { Log.w(TAG, "forced components", it) }
}
addClientEnvironment(guest, steamHere)
// Where the fast path's description of proot's view goes, once the binds are known.
val fastPathAt = guest.size
@@ -402,6 +416,9 @@ class SessionService : Service() {
guest.addAll(fastPathAt, env)
shellGuest.addAll(fastPathAt, env)
Log.i(TAG, "proot: fast path on (${prootBinds.size} binds)")
} ?: run {
Log.w(TAG, "proot: fast path off - ${prootBinds.size} binds (it holds ${ProotFastPath.MAX_BINDS}) or a path it cannot be told")
null
}
} else null
@@ -590,9 +607,7 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(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
@@ -811,6 +826,20 @@ class SessionService : Service() {
} else {
Log.i(TAG, "game storage: internal only")
}
// guest already has the session command at this point. These go with the `env -i`
// assignments so they reach the Steam process instead of becoming script args.
val storageEnv = ArrayList<String>()
val skipLibrary = storageDiagnosticLibrary
if (SessionState.mode == MODE_STEAM && skipLibrary != null && SessionPrefs.skipLibraryPreallocation(this)) {
val device = runCatching { android.system.Os.stat(skipLibrary.absolutePath).st_dev }.getOrNull()
if (device != null) {
storageEnv.add("BL_STORAGE_DEVICE=$device")
storageEnv.add("BL_STORAGE_PREALLOCATE_SKIP=1")
Log.i(TAG, "game storage: Steam's space reservation on the second library is skipped")
} else {
Log.w(TAG, "game storage: library could not be identified; space reservation not skipped")
}
}
if (SessionState.mode == MODE_STEAM && SessionPrefs.storageDiagnosticsEnabled(this)) {
val target = storageDiagnosticLibrary ?: File(LinuxRuntime.rootDir(this), "root/.local/share/Steam")
val device = StorageDiagnostics.writeSnapshot(
@@ -821,16 +850,18 @@ class SessionService : Service() {
} ?: false,
)
if (device != null) {
// guest already has the session command at this point. Put these with the `env -i`
// assignments so they reach the Steam process instead of becoming script args.
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
guest.add(envAt, "BL_STORAGE_DIAGNOSTICS=1")
guest.add(envAt + 1, "BL_STORAGE_DEVICE=$device")
guest.add(envAt + 2, "BL_STORAGE_LOG=${File(sessionDir, "storage.log").absolutePath}")
// Both observe the same library, so a device the skip already set is the same one.
if (storageEnv.none { it.startsWith("BL_STORAGE_DEVICE=") }) storageEnv.add("BL_STORAGE_DEVICE=$device")
storageEnv.add("BL_STORAGE_DIAGNOSTICS=1")
storageEnv.add("BL_STORAGE_LOG=${File(sessionDir, "storage.log").absolutePath}")
} else {
Log.w(TAG, "storage diagnostics: selected library could not be identified; metadata snapshot only")
}
}
if (storageEnv.isNotEmpty()) {
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
guest.addAll(envAt, storageEnv)
}
// The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so
// the shortcuts the app writes point somewhere whatever storage the folder is on.
for (root in com.droiddeck.launcher.frontend.AddedGames.roots(this)) {
@@ -992,6 +1023,8 @@ class SessionService : Service() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
updateSuspendPolicy()
@@ -1070,11 +1103,28 @@ class SessionService : Service() {
private fun watchLaunchRequests(dir: File) {
launchWatcher?.stopWatching()
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready").forEach { File(dir, it).delete() }
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp",
"steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp"
).forEach { File(dir, it).delete() }
val gen = sessionGen
@Suppress("DEPRECATION")
val watcher = object : android.os.FileObserver(dir.path, CLOSE_WRITE or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
if (path == "steam-native-ready") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
request.delete()
mainHandler.post {
if (gen != sessionGen || !SessionState.running || token != nativeSleepToken) return@post
nativeSleepReady = true
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
nativeSleepTimeout = null
writeSteamSleepState(token, "paused")
SessionEvents.record("steam.native_sleep_prepared")
updateSuspendPolicy()
}
return
}
if (path == "steam-sleep") {
val request = File(dir, path)
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
@@ -1142,18 +1192,27 @@ class SessionService : Service() {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
manualPauseRequested = true
}
val controller = suspendController ?: return
val hidden = !activityVisible || !screenOn
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
val controller = suspendController ?: return
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
val gen = sessionGen
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"nativeSleep" to (nativeSleepToken != null),
))
if (shouldSuspend) {
controller.freeze { success ->
@@ -1166,6 +1225,7 @@ class SessionService : Service() {
releaseLocks()
refreshNotification()
} else {
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = true
Log.w(TAG, "could not confirm that the session stopped")
@@ -1211,7 +1271,63 @@ class SessionService : Service() {
}
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy == SessionPrefs.SUSPEND_AUTO && activityVisible && screenOn) completeSteamSleep()
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
}
/** Let Steam prepare before freezing; retain the wake lock until that freeze completes. */
private fun prepareNativeSleep() {
val token = UUID.randomUUID().toString().replace("-", "")
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
nativeSleepTimeout = null
nativeSleepReady = true
nativeSleepFallback = true
writeSteamSleepState(token, "paused")
SessionEvents.record("steam.native_sleep_timeout")
updateSuspendPolicy()
}
nativeSleepTimeout = timeout
// A missing or stalled guest helper must not keep the device awake indefinitely.
mainHandler.postDelayed(timeout, 4000L)
}
private fun completeNativeSleep() {
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
nativeSleepTimeout = null
val token = nativeSleepToken ?: return
writeSteamSleepState(token, "awake")
val dir = LinuxRuntime.sessionRoot(this)
File(dir, "steam-native-sleep").delete()
File(dir, "steam-native-ready").delete()
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
}
/** Resume must also recover a Steam sleep whose request never reached Android. */
private fun requestSteamWake() {
if (!SessionState.running || SessionState.mode != MODE_STEAM) return
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-wake.tmp")
staged.writeText("wake\n")
check(staged.renameTo(File(dir, "steam-wake")))
}.onFailure { Log.w(TAG, "could not request Steam wake", it) }
}
private fun finishSessionStop(status: Int, stoppedGen: Int) {
@@ -1266,6 +1382,7 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
completeNativeSleep()
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
@@ -0,0 +1,21 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
object SteamRepair {
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
private fun request(context: Context) = File(home(context), ".bl-steam-repair")
private fun unconfirmed(context: Context) = File(home(context), ".local/share/Steam/package/droiddeck-unconfirmed")
fun queued(context: Context): Boolean = request(context).exists()
fun queue(context: Context): Boolean = runCatching {
request(context).apply { parentFile?.mkdirs() }.writeText("1\n")
}.isSuccess
fun clientShown(context: Context) {
unconfirmed(context).delete()
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.session
/**
* What to call a catalog component on screen. Bannerlator's catalog keys (vcredist2010_dll,
* oalinst_dll, d3dcompiler_47...) have no display name, and some descriptions do not fit the entry,
* so names come from here: families by pattern, the rest from a table, the key itself otherwise.
* The "_dll" twin of an installer (the direct DLL copy) shares the installer's name.
*/
object WinComponentNames {
private val TABLE = mapOf(
"oalinst" to "OpenAL", "XLiveRedist" to "Games for Windows Live", "gfw" to "Games for Windows Live",
"physx" to "NVIDIA PhysX", "xact" to "DirectX XACT audio", "xact_x64" to "DirectX XACT audio (64-bit)",
"xinput" to "XInput", "xna31" to "XNA Framework 3.1", "xna40" to "XNA Framework 4.0",
"d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
"directmusic" to "DirectMusic", "directplay" to "DirectPlay", "directshow" to "DirectShow",
"dsound" to "DirectSound", "dx8vb" to "DirectX 8 for Visual Basic", "mediafoundation" to "Media Foundation",
"quartz" to "DirectShow (quartz)", "amstream" to "DirectShow streams (amstream)", "devenum" to "DirectShow devices (devenum)",
"cnc-ddraw" to "cnc-ddraw (DirectDraw)", "gdiplus" to "GDI+", "gecko" to "Wine Gecko", "mono" to "Wine Mono",
"vcredist6" to "Visual C++ 6", "vcredist6sp6" to "Visual C++ 6 SP6",
"vcredist2015" to "Visual C++ 2015", "vcredist2019" to "Visual C++ 2015-2019", "vcredist2022" to "Visual C++ 2015-2022",
"vbrun6" to "Visual Basic 6 runtime", "mfc40" to "MFC 4.0", "mfc42" to "MFC 4.2",
"dotnetcore3" to ".NET Core 3", "dotnetcoredesktop3" to ".NET Desktop 3", "dotnetcoredesktop6" to ".NET Desktop 6",
"dotnetcoredesktop7" to ".NET Desktop 7", "dotnetcoredesktop8" to ".NET Desktop 8", "dotnet50" to ".NET 5",
"webview2" to "Microsoft WebView2", "powershell" to "PowerShell", "powershell_core" to "PowerShell Core",
"K-Lite" to "K-Lite Codec Pack", "VulkanRT" to "Vulkan Runtime", "ffdshow" to "ffdshow codecs",
"lavfilters702" to "LAV Filters 0.70.2", "lavfilters741" to "LAV Filters 0.74.1", "quicktime72" to "QuickTime 7.2",
"cjkfonts" to "CJK fonts", "jet40" to "Jet 4.0 database", "mdac28" to "MDAC 2.8", "wsh57" to "Windows Script Host 5.7",
"riched20" to "RichEdit 2.0", "msftedit" to "RichEdit 4.1 (msftedit)", "sqlite3" to "SQLite 3",
"win7" to "Windows 7 mode", "winXP" to "Windows XP mode", "gmdls" to "General MIDI sounds (gm.dls)",
)
private val VCREDIST = Regex("vcredist(\\d{4})")
private val DOTNET = Regex("dotnet(\\d)(\\d)(\\d?)(sp\\d)?")
private val D3DCOMPILER = Regex("d3dcompiler_(\\d+)")
private val MSXML = Regex("msxml(\\d)")
private val MONO = Regex("mono-([\\d.]+)")
private val XAUDIO = Regex("xaudio([\\d.]+)")
fun of(id: String): String {
val base = id.removeSuffix("_dll")
TABLE[base]?.let { return it }
VCREDIST.matchEntire(base)?.let { return "Visual C++ " + it.groupValues[1] }
DOTNET.matchEntire(base)?.let { m ->
val (major, minor, patch, sp) = m.destructured
return ".NET Framework $major.$minor" + (if (patch.isNotEmpty()) ".$patch" else "") +
(if (sp.isNotEmpty()) " " + sp.uppercase() else "")
}
D3DCOMPILER.matchEntire(base)?.let { return "D3DCompiler " + it.groupValues[1] }
MSXML.matchEntire(base)?.let { return "MSXML " + it.groupValues[1] }
MONO.matchEntire(base)?.let { return "Wine Mono " + it.groupValues[1] }
XAUDIO.matchEntire(base)?.let { return "XAudio " + it.groupValues[1] }
if (base.startsWith("dm") || base == "dswave" || base == "dsdmo") return "DirectMusic ($base)"
return id
}
}
@@ -0,0 +1,399 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.core.ArchivePaths
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.GuestCommand
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.zip.ZipInputStream
/**
* Windows components (OpenAL, d3dx9, XAudio, VC++ DLLs...) for a game's Proton prefix, picked per
* game, from Bannerlator's catalog (components.json on winlator-contents, Bottles' manifest format).
*
* Installing one runs its steps once, into the runtime: the archives it names are downloaded and
* its DLLs copied to /opt/droiddeck/wincomponents/<name>/{system32,syswow64}, with component.json
* holding its DLL overrides and the components it bundles. The picks go to the runtime as
* ~/.config/droiddeck/wincomponents.json, and droiddeck-wincomponents copies the picked components
* into the game's prefix at every launch (see that script for why every launch).
*
* A component whose installer is a Windows Installer package (.msi: XNA, PhysX, Games for Windows
* Live, MSXML...) installs here too, without running it: droiddeck-msi-install reads the package's
* tables in the runtime and lays out its files (Program Files, the .NET GAC, winsxs) and registry
* values the way Wine's msiexec would, under the same store folder. One that runs any other
* installer (.exe), unpacks a cabinet or edits the prefix's registry by itself is listed with the
* reason it cannot be installed yet.
*/
object WinComponents {
private const val TAG = "WinComponents"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
private const val SELECTION = "wincomponents.json"
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
private const val STORE = "opt/droiddeck/wincomponents"
private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
// register_dll is left out on purpose: the DLLs it names are ones Wine ships and has registered
// at the same path, so the native file at that path is the one the registration loads.
private val FILE_STEPS = setOf("download_archive", "archive_extract", "copy_dll", "copy_file", "override_dll", "register_dll")
private val INSTALLER_STEPS = setOf("install_exe", "install_msi")
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
/** Where a downloaded .msi waits for the installer, inside the runtime so it can read it. */
private const val MSI_CACHE = "$STORE/.packages"
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
private val PROTON_PROVIDES = setOf("gecko")
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
class Step(val action: String, val json: JSONObject) {
fun str(key: String): String = json.optString(key, "")
}
class Component(
val name: String, val description: String, val provider: String, val status: String,
val dependencies: List<String>, val steps: List<Step>,
)
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
/** A step that installs a Windows Installer package, which droiddeck-msi-install does here. */
private fun isMsi(step: Step): Boolean = step.action in INSTALLER_STEPS && step.str("url").startsWith("https://") &&
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
.any { it.endsWith(".msi", ignoreCase = true) })
fun fetch(): List<Component>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
return try {
val arr = JSONObject(body).getJSONArray("components")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val name = o.optString("name")
if (!validId(name)) return@mapNotNull null
val steps = o.optJSONArray("steps") ?: JSONArray()
val deps = o.optJSONArray("dependencies") ?: JSONArray()
Component(
name, o.optString("description"), o.optString("provider"), o.optString("status"),
(0 until deps.length()).map { deps.getString(it) }.filter { validId(it) },
(0 until steps.length()).map { steps.getJSONObject(it).let { s -> Step(s.optString("action"), s) } },
)
}
} catch (e: Exception) {
Log.w(TAG, "catalog: $e"); null
}
}
/** Whether [c] can be installed here, its bundled components included. */
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
if (c.status != "ready" || depth > 8 || c.name in PROTON_PROVIDES) return Support.UNSUPPORTED
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
val own = when {
installers.isNotEmpty() && installers.all { isMsi(it) } &&
c.steps.all { it in installers || it.action == "delete_dlls" || it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
installers.isNotEmpty() -> Support.NEEDS_INSTALLER
c.steps.all { it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
else -> Support.UNSUPPORTED
}
if (own != Support.READY) return own
return c.dependencies.map { all[it]?.let { d -> support(d, all, depth + 1) } ?: Support.UNSUPPORTED }
.maxOrNull() ?: Support.READY
}
private fun fileStepOk(step: Step): Boolean = when (step.action) {
"download_archive", "archive_extract" -> step.str("url").let { url ->
!url.startsWith("http") || url.startsWith("https://github.com/") &&
url.substringBefore('?').let { it.endsWith(".tar.xz") || it.endsWith(".zip") }
}
"copy_dll", "copy_file" -> ".." !in step.str("dest")
else -> true
}
/** The installed version of a component, or null. */
fun installed(context: Context, id: String): String? = runCatching {
JSONObject(File(LinuxRuntime.rootDir(context), "$STORE/$id/component.json").readText()).optString("version", "?")
}.getOrNull()
/** Every component in the runtime, by id. */
fun installedIds(context: Context): List<String> =
File(LinuxRuntime.rootDir(context), STORE).listFiles { f -> f.isDirectory && validId(f.name) }
.orEmpty().map { it.name }.filter { installed(context, it) != null }.sorted()
/**
* Installs [c] and the components it bundles that are not installed yet. [onProgress] gets a
* line for the step and 0..100 (or -1). Returns null on success, else a message.
*/
fun install(context: Context, c: Component, all: Map<String, Component>, onProgress: (String, Int) -> Unit): String? {
if (support(c, all) != Support.READY) return "${c.name} cannot be installed here yet"
val order = ArrayList<Component>()
fun visit(x: Component, depth: Int) {
if (depth > 8 || order.any { it.name == x.name }) return
x.dependencies.mapNotNull { all[it] }.forEach { visit(it, depth + 1) }
if (x.name == c.name || installed(context, x.name) == null) order.add(x)
}
visit(c, 0)
// One download per archive however many components copy out of it (DirectMusic's eleven
// parts all come from dxnt, 100 MB).
val work = File(context.cacheDir, "wincomponents").apply { FileUtils.delete(this); mkdirs() }
val archives = HashMap<String, File>()
return try {
for (x in order) {
onProgress(x.name, -1)
installOne(context, x, work, archives, onProgress)?.let { return "${x.name}: $it" }
}
null
} catch (e: Exception) {
Log.e(TAG, "install ${c.name}", e)
e.message ?: "Install failed"
} finally {
FileUtils.delete(work)
}
}
private fun installOne(context: Context, c: Component, work: File, archives: HashMap<String, File>, onProgress: (String, Int) -> Unit): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
val staging = File(root, "$STORE/.${c.name}.new").apply { FileUtils.delete(this); mkdirs() }
val overrides = ArrayList<String>()
var source: File? = null
var msi: JSONObject? = null
for (step in c.steps) when (step.action) {
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
// same folder, the first one names the component.
"install_msi", "install_exe" -> {
val (result, problem) = installMsi(context, c, step, staging, onProgress)
if (result == null) return problem
msi = msi?.apply {
val notes = optJSONArray("notes") ?: JSONArray().also { put("notes", it) }
result.optJSONArray("notes")?.let { for (i in 0 until it.length()) notes.put(it.get(i)) }
} ?: result
}
// A catalog step that makes room for the installer's own copy; ours is copied anyway.
"delete_dlls" -> Unit
"download_archive", "archive_extract" -> {
val url = step.str("url")
if (!url.startsWith("http")) continue
source = archives[url] ?: run {
val name = url.substringBefore('?').substringAfterLast('/')
val file = File(work, "${archives.size}-$name")
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }
if (!ok) return "download failed: $name"
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return "checksum mismatch: $name"
}
onProgress("${c.name}: unpacking $name", -1)
val dir = File(work, "${archives.size}-x").apply { mkdirs() }
if (!extract(file, dir)) return "could not unpack $name"
file.delete()
dir.also { archives[url] = it }
}
}
"copy_dll", "copy_file" -> {
val from = source ?: return "a copy step before any archive"
val dest = step.str("dest")
val (arch, base) = when (dest.substringBefore('/').lowercase()) {
"win64", "system32" -> "win64" to "system32"
"win32", "syswow64" -> "win32" to "syswow64"
else -> null to "system32"
}
val sub = dest.substringAfter('/', "")
copyMatching(from, step.str("file_name"), File(staging, if (sub.isEmpty()) base else "$base/$sub"), arch)
}
"override_dll" -> {
fun add(dll: String, type: String) {
if (dll.isEmpty()) return
overrides += when (type.ifEmpty { "native,builtin" }) {
"native,builtin" -> dll
"native" -> "$dll=n"
"builtin" -> "$dll=b"
"builtin,native" -> "$dll=b,n"
else -> dll
}
}
add(step.str("dll"), step.str("type"))
step.json.optJSONArray("bundle")?.let { b ->
for (i in 0 until b.length()) b.getJSONObject(i).let { add(it.optString("value"), it.optString("data")) }
}
}
"register_dll" -> Unit
else -> return "unsupported step ${step.action}"
}
val version = msi?.optString("version")?.takeIf { it.isNotEmpty() }
?: Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: "catalog"
val meta = JSONObject()
.put("id", c.name).put("version", version)
.put("overrides", JSONArray(overrides.distinct()))
.put("requires", JSONArray(c.dependencies))
msi?.let { meta.put("kind", "msi").put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
FileUtils.writeString(File(staging, "component.json"), meta.toString(1))
val target = File(root, "$STORE/${c.name}")
FileUtils.delete(target)
if (!staging.renameTo(target)) return "could not place the files"
return null
}
/**
* Downloads the component's .msi into the runtime and has droiddeck-msi-install lay it out in
* [staging]. Returns the installer's result (product, version, counts, notes), or null and why.
* Everything the installer prints also goes to Download/DroidDeck/tools.
*/
private fun installMsi(context: Context, c: Component, step: Step, staging: File, onProgress: (String, Int) -> Unit): Pair<JSONObject?, String> {
val root = LinuxRuntime.rootDir(context)
val url = step.str("url")
val name = url.substringBefore('?').substringAfterLast('/')
val cache = File(root, MSI_CACHE).apply { mkdirs() }
val file = File(cache, "${c.name}.msi")
try {
if (!Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }) {
return null to "download failed: $name"
}
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return null to "checksum mismatch: $name"
}
onProgress("${c.name}: installing", -1)
var result: JSONObject? = null
var problem: String? = null
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
logName = "wincomponents-${c.name}") { line ->
when {
line.startsWith("progress ") -> onProgress("${c.name}: ${line.removePrefix("progress ")}", -1)
line.startsWith("result ") -> result = runCatching { JSONObject(line.removePrefix("result ")) }.getOrNull()
line.startsWith("error ") -> problem = line.removePrefix("error ")
}
}
val done = result
if (status != 0 || done == null) return null to (problem ?: "the installer stopped (status $status)")
return done to ""
} catch (e: Exception) {
Log.e(TAG, "msi ${c.name}", e)
return null to (e.message ?: "the installer failed")
} finally {
file.delete()
}
}
private fun digest(file: File, algorithm: String): String {
val md = MessageDigest.getInstance(algorithm)
FileInputStream(file).use { input ->
val buffer = ByteArray(1 shl 16)
while (true) { val n = input.read(buffer); if (n < 0) break; md.update(buffer, 0, n) }
}
return md.digest().joinToString("") { "%02x".format(it) }
}
private fun extract(archive: File, dest: File): Boolean = try {
if (archive.name.endsWith(".zip", ignoreCase = true)) ZipInputStream(BufferedInputStream(FileInputStream(archive))).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val out = ArchivePaths.inside(dest, entry.name)
if (out != null && !entry.isDirectory) {
out.parentFile?.mkdirs()
FileOutputStream(out).use { FileUtils.copy(zip, it) }
}
entry = zip.nextEntry
}
} else TarArchiveInputStream(XZCompressorInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use { tar ->
var entry = tar.nextEntry
while (entry != null) {
val out = ArchivePaths.inside(dest, entry.name)
if (out != null && entry.isFile) {
out.parentFile?.mkdirs()
FileOutputStream(out).use { FileUtils.copy(tar, it) }
}
entry = tar.nextEntry
}
}
true
} catch (e: Exception) {
Log.w(TAG, "extract ${archive.name}", e); false
}
/**
* Bannerlator's rule: the manifest's own temp paths are Bottles', so the files are found by name
* anywhere in the unpacked tree, preferring the ones under the matching win32/win64 folder.
*/
private fun copyMatching(srcRoot: File, pattern: String, dest: File, arch: String?) {
if (pattern.isEmpty()) return
val rx = Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
var matches = srcRoot.walkTopDown().filter { it.isFile && rx.matches(it.name) }.toList()
if (arch != null) {
val inArch = matches.filter { it.path.contains("/$arch/", true) }
val other = if (arch == "win64") "win32" else "win64"
matches = inArch.ifEmpty { matches.filterNot { it.path.contains("/$other/", true) } }
}
dest.mkdirs()
matches.forEach { f -> f.copyTo(File(dest, f.name.lowercase()), overwrite = true) }
}
/** Removes a component from the runtime and from every game's picks. */
@Synchronized
fun uninstall(context: Context, id: String) {
require(validId(id))
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
}
/** Each game's picks, by the appid its prefix is named after. */
@Synchronized
fun read(context: Context): Map<String, List<String>> = try {
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
val games = json.optJSONObject("games") ?: JSONObject()
games.keys().asSequence().associateWith { app ->
val ids = games.getJSONArray(app)
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
}
} catch (_: java.io.FileNotFoundException) {
emptyMap()
}
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
@Synchronized
fun setPicks(context: Context, appKey: String, ids: List<String>) {
require(ids.all { validId(it) })
val all = read(context).toMutableMap()
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
save(context, all)
}
private fun save(context: Context, games: Map<String, List<String>>) {
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
}).toString()
write(File(context.filesDir, SELECTION), text)
publish(context, text)
}
/**
* Writes the picks where the launch script reads them; SessionFiles calls this at every session
* start too, so a runtime reinstalled since keeps them. An empty selection still writes a file,
* which is how a component turned off for every game is taken out of their prefixes.
*/
@Synchronized
fun publish(context: Context, text: String? = null) {
val body = text ?: runCatching { AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8) }.getOrNull() ?: return
val root = LinuxRuntime.rootDir(context)
if (root.isDirectory) write(File(root, GUEST_SELECTION), body)
}
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,23 @@
package com.droiddeck.launcher.ui
import android.content.res.Configuration
import androidx.compose.ui.unit.Density
import com.droiddeck.launcher.core.AppUiPrefs
import kotlin.math.roundToInt
internal fun scaledAppDensity(base: Density, percent: Int): Density {
val scale = AppUiPrefs.normalizeScale(percent) / 100f
return if (scale == 1f) base else Density(base.density * scale, base.fontScale)
}
/** Responsive layouts and popup height limits must see the same dp space as the scaled UI. */
internal fun scaledAppConfiguration(base: Configuration, percent: Int): Configuration {
val scale = AppUiPrefs.normalizeScale(percent) / 100f
if (scale == 1f) return base
return Configuration(base).apply {
if (densityDpi > 0) densityDpi = (densityDpi * scale).roundToInt()
if (screenWidthDp > 0) screenWidthDp = (screenWidthDp / scale).roundToInt()
if (screenHeightDp > 0) screenHeightDp = (screenHeightDp / scale).roundToInt()
if (smallestScreenWidthDp > 0) smallestScreenWidthDp = (smallestScreenWidthDp / scale).roundToInt()
}
}
@@ -90,7 +90,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
}
}
Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
Rise(2) { LaunchSettings(s, a, host) }
Rise(2) { LaunchSettings(s, a, host, current) }
Rise(3) { GameFileFolderActions(s, a) }
}
return
@@ -109,7 +109,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
GameActions(current, s, a)
}
SectionTitle(stringResource(R.string.games_launch_settings), null)
LaunchSettings(s, a, host)
LaunchSettings(s, a, host, current)
GameFileFolderActions(s, a)
}
}
@@ -284,7 +284,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
}
}
/** A page's title, with room at its right for a status chip. */
/** A page's title, with room at its right for controls or status. */
@Composable
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
Row(
@@ -101,6 +101,7 @@ class FrontEndState(
val gameSyncFolder: String? = null,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
val isHomeApp: Boolean = false,
val homeScreenEnabled: Boolean = false,
val defaultHomeLabel: String? = null,
@@ -168,6 +169,7 @@ class FrontEndActions(
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
@@ -162,8 +162,6 @@ internal fun SetupPanel(
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 }
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
@@ -187,9 +185,8 @@ internal fun SetupPanel(
),
) {
PageHeader(stringResource(R.string.setup_title)) {
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
}
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 -> {
@@ -303,7 +300,8 @@ internal fun SetupPanel(
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
host, "offline", stringResource(R.string.setup_offline),
@@ -314,6 +312,15 @@ internal fun SetupPanel(
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
@@ -415,10 +422,20 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
* app-wide settings - each card opens the same page or menu Setup does.
*/
@Composable
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) {
// 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 context = androidx.compose.ui.platform.LocalContext.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
val wincompKey = game?.let {
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
else it.appId.takeIf { id -> id > 0 }?.toString()
}
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
add { m ->
@@ -430,7 +447,19 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
}
}
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
if (wincompKey != null) add { m ->
SettingCard(
stringResource(R.string.wincomp_title),
if (wincompPicks.isEmpty()) stringResource(R.string.wincomp_card_none) else wincompPicks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
"card:wincomp", m,
) { wincompOpen = inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard }
}
}
val opened = wincompOpen
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
) { wincompOpen = null }
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)) {
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -206,8 +207,27 @@ internal fun PrimaryButton(
}
}
/**
* [label] while idle; while [progress] is set, a spinner and how far along it is in its place.
* The label stays laid out underneath, invisible, so the button keeps its width. The spinner
* turns until there is a first figure to show.
*/
@Composable
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
internal fun ProgressLabel(label: @Composable (Modifier) -> Unit, progress: Float?, fontSize: androidx.compose.ui.unit.TextUnit = 15.sp) {
val pal = LocalPalette.current
Box(contentAlignment = Alignment.Center) {
label(Modifier.alpha(if (progress == null) 1f else 0f))
if (progress != null) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (progress > 0f) CircularProgressIndicator(progress = { progress }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.22f), modifier = Modifier.size(16.dp))
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
Text("${(progress * 100).toInt()}%", fontSize = fontSize, fontWeight = FontWeight.SemiBold, maxLines = 1,
color = MaterialTheme.colorScheme.onBackground)
}
}
}
@Composable
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
@@ -222,7 +242,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp),
) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) }
) {
ProgressLabel({ m -> Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1, modifier = m) },
progress, fontSize = if (compact) 12.sp else 15.sp)
}
}
@Composable
@@ -52,13 +52,13 @@ import kotlinx.coroutines.withContext
// dialog, with each variable's actions in a menu off its value as everywhere else.
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
var open by remember { mutableStateOf(false) }
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
// would otherwise give the pad nothing to move from.
var byPad by remember { mutableStateOf(false) }
val inputMode = LocalInputModeManager.current
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
SettingsRow(stringResource(R.string.game_env_title), if (showHint) stringResource(R.string.game_env_hint) else null) {
SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) {
byPad = inputMode.inputMode == InputMode.Keyboard
open = true
@@ -70,7 +70,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier) {
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
private fun PadFocus(byPad: Boolean, target: FocusRequester) {
internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
val inputMode = LocalInputModeManager.current
LaunchedEffect(Unit) {
if (!byPad) return@LaunchedEffect
@@ -95,6 +95,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
val preset = SessionPrefs.fexPreset(context)
val forceSsbs = SessionPrefs.forceSsbs(context)
val firstFocus = remember { FocusRequester() }
var editByPad by remember { mutableStateOf(false) }
val dialogInput = LocalInputModeManager.current
@@ -159,7 +160,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
if (current == null && !error) Small(stringResource(R.string.game_env_loading))
if (current != null) {
val own = current.entries(scope)
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
val entries = GameEnvironment.effective(current, preset, scope, forceSsbs).toSortedMap()
if (entries.isEmpty()) Small(stringResource(R.string.game_env_empty))
else Panel {
entries.entries.forEachIndexed { i, (name, value) ->
@@ -1,6 +1,17 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.droiddeck.launcher.gpu.DeviceInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -180,29 +191,104 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
}
}
/**
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
* off the main thread, with a button that copies it for a bug report.
*/
@Composable
private fun DeviceCard(s: GpuDriversState) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val tint = when {
s.unsupported -> pal.error
s.supported -> pal.good
else -> AttentionAmber
}
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
var open by rememberSaveable { mutableStateOf(false) }
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
var copied by remember { mutableStateOf(false) }
LaunchedEffect(open) {
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
}
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val toggle = { open = !open; copied = false }
Column(
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, Shape14, pal.signal, pal.line),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle)
.padding(14.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(Modifier.weight(1f)) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
}
Icon(
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
)
}
Column(Modifier.weight(1f)) {
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
AnimatedVisibility(open) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
HorizontalDivider(color = pal.line)
val sections = details
if (sections == null) {
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
for (section in sections) DeviceInfoSection(section)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
)
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
copied = true
}
}
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
}
}
/** One part of the device details: its title, then label/value rows. Focusable, so the d-pad walks the list. */
@Composable
private fun DeviceInfoSection(section: DeviceInfo.Section) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.fillMaxWidth().clip(Shape12)
.glideBorder(hot, Shape12, pal.signal)
.focusable(interactionSource = src).padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
for ((label, value) in section.rows) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
}
}
}
}
@@ -143,8 +143,12 @@ internal fun LaunchFlood(
val start = fromColors ?: (colors.primary to pal.primary2)
VeilRing()
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
// edge, off the page, so the wobble is felt in the pull. An underdamped spring then swings back
// short of the edge (about 3% at this damping), so once an edge has reached the page's it holds
// there, or the page would show through in a sliver while the next screen opens.
val edges = remember { List(4) { Animatable(0f) } }
val reached = remember { BooleanArray(4) }
fun edge(i: Int) = edges[i].value.let { if (reached[i]) maxOf(it, 1f) else it }
val covered by rememberUpdatedState(onCovered)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
@@ -152,6 +156,7 @@ internal fun LaunchFlood(
if (page == Size.Zero) return@LaunchedEffect
if (Motion.scale == 0f) {
edges.forEach { it.snapTo(1f) }
reached.fill(true)
progress(1f)
covered()
return@LaunchedEffect
@@ -164,18 +169,19 @@ internal fun LaunchFlood(
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(1f, spring(dampingRatio = 0.48f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
if (value >= 1f) reached[i] = true
progress((0 until 4).sumOf { edge(it).coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}
// Covered once every edge has reached the page's, the first time round.
snapshotFlow { edges.all { it.value >= 0.995f } }.first { it }
// Covered once every edge has reached the page's.
snapshotFlow { (0 until 4).all { edge(it) >= 1f } }.first { it }
covered()
runs.forEach { it.join() }
}
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
drawFlood(from, edges.map { it.value }, start, pal.signal, cornerAtRest.toPx())
drawFlood(from, List(4) { edge(it) }, start, pal.signal, cornerAtRest.toPx())
}
}
@@ -43,10 +43,8 @@ class DownloadRow(val key: String, val label: String, val detail: String, val pr
class ModeSettings(
val mode: String,
val resolutionCap: Int,
/** A fixed session size, or null for the cap and shape. */
val customResolution: Pair<Int, Int>? = null,
val shapeMode: String,
val resolution: String,
val panelSize: Pair<Int, Int> = 1280 to 720,
val hdr: Boolean,
val hdrReason: String?,
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
@@ -71,16 +69,19 @@ class ModeSettings(
val storageOptions: List<Pair<String, String>> = emptyList(),
/** Steam only: record allocation and sampled storage timings in the next session's Share logs. */
val storageDiagnostics: Boolean = false,
/** Steam only: report Steam's space reservation on the second library as done (experimental). */
val skipPreallocation: Boolean = false,
val fexPreset: String? = null,
/** Steam only: Force SSBS for Proton games (SessionPrefs.forceSsbs). */
val forceSsbs: Boolean = false,
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
val syncBackend: String? = null,
/** Steam only: games are stretched to fill the screen (null = not a Steam page). */
val forceFullscreen: Boolean? = null,
val stretch16x9: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
val steamRepairQueued: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
val mangoapp: Boolean = true,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
@@ -112,10 +113,7 @@ class ModeSettings(
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
class ModeSettingsActions(
val onResolution: (Int) -> Unit,
/** Null clears it. */
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onResolution: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
/** Opens the GPU drivers on the Components page: they are shared by every session. */
val onGpuDrivers: () -> Unit = {},
@@ -134,11 +132,13 @@ class ModeSettingsActions(
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
val onPickGameStorageFolder: () -> Unit = {},
val onStorageDiagnostics: (Boolean) -> Unit = {},
val onSkipPreallocation: (Boolean) -> Unit = {},
val onFexPreset: (String) -> Unit = {},
val onForceSsbs: (Boolean) -> Unit = {},
val onSyncBackend: (String) -> Unit = {},
val onForceFullscreen: (Boolean) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
@@ -158,139 +158,158 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class ModeSettingsTab(val label: Int) {
DISPLAY(R.string.drawer_page_display),
CONTROLS(R.string.drawer_page_controls),
GAMES(R.string.game_settings_title),
AUDIO(R.string.mode_audio),
SESSION(R.string.mode_session),
STEAM(R.string.mode_tab_steam),
}
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
val pageScroll = androidx.compose.foundation.rememberScrollState()
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
androidx.compose.runtime.LaunchedEffect(Unit) {
// One frame first: the box has to be laid out before it can take focus. Opened from the
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching { firstChip.requestFocus() }
runCatching {
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
}
}
SettingsPage(
host,
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
title = null,
onBack = a.onDismiss,
scroll = pageScroll,
scroll = scrolls[tabIndex],
modifier = Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
),
header = {
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
},
) {
SettingsGroup(stringResource(R.string.mode_display)) {
val default = SessionPrefs.defaultResolutionCap(s.mode)
var editCustom by remember { mutableStateOf(false) }
val custom = s.customResolution
ChoiceRow(
host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session),
listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native))
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", stringResource(R.string.mode_ratio),
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
ChoiceRow(
host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
note = stringResource(R.string.mode_fps_note),
onPick = a.onFpsLimit,
)
ChoiceRow(
host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.",
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.",
onPick = a.onUpscaler,
)
SliderRow(
stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
onDismiss = { editCustom = false },
)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
if (tab == ModeSettingsTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
chipModifier = Modifier.focusRequester(firstChip))
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(
host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, s.upscaler,
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
)
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, onChange = a.onUpscaleSharpness,
)
}
SettingsGroup(stringResource(R.string.display_frame_rate)) {
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
}
SettingsGroup(stringResource(R.string.mode_hdr)) {
ToggleRow(
host, "hdr", stringResource(R.string.mode_hdr10),
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
?: stringResource(R.string.mode_hdr_restart),
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
SettingsGroup(stringResource(R.string.mode_drivers)) {
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
}
}
}
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_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,
)
if (tab == ModeSettingsTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
note = stringResource(R.string.mode_touch_note),
onPick = a.onTouch,
)
if (steam && s.oscMode != null) ChoiceRow(
host, "osc", stringResource(R.string.mode_osc), null,
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
listOf(
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
), s.steamController,
note = stringResource(R.string.mode_controller_note),
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_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,
)
}
}
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
),
s.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
if (tab == ModeSettingsTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.mode_suspend_hint),
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
) + if (steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
s.suspendPolicy,
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
onPick = a.onSuspendPolicy,
)
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
}
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
s.pipAutoEnter, onChange = a.onPipAutoEnter)
}
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
ToggleRow(
host, "steam-startup", stringResource(R.string.mode_steam_startup),
stringResource(R.string.mode_steam_startup_hint),
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
)
}
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
@@ -349,7 +368,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
@@ -368,8 +387,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
@@ -388,7 +413,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
}
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
@@ -406,14 +431,16 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
stringResource(R.string.mode_added_art_hint),
s.addedGamesArt, onChange = a.onAddedGamesArt,
)
for (g in s.addedGames) ChoiceRow(
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
note = stringResource(R.string.mode_added_exe_note),
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
for (g in s.addedGames) {
ChoiceRow(
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
note = stringResource(R.string.mode_added_exe_note),
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
}
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
@@ -433,19 +460,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
GameEnvironmentRow()
if (s.forceFullscreen != null) ToggleRow(
host, "fill", stringResource(R.string.mode_fill),
stringResource(R.string.mode_fill_hint),
s.forceFullscreen, onChange = a.onForceFullscreen,
)
if (s.stretch16x9 != null) ToggleRow(
host, "stretch169", stringResource(R.string.mode_stretch169),
stringResource(R.string.mode_stretch169_hint),
s.stretch16x9, onChange = a.onStretch16x9,
)
}
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
@@ -454,7 +473,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
@@ -489,8 +508,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
stringResource(R.string.mode_storage_diagnostics_hint), s.storageDiagnostics,
onChange = a.onStorageDiagnostics,
)
ToggleRow(
host, "skipPreallocation", stringResource(R.string.mode_skip_preallocation),
stringResource(R.string.mode_skip_preallocation_hint), s.skipPreallocation,
onChange = a.onSkipPreallocation,
)
}
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
if (!steam && tab == ModeSettingsTab.DISPLAY && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
ChoiceRow(
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
@@ -523,13 +547,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
/** The Resolution menu's "Custom…" entry. */
private const val CUSTOM = -1
/** Width × height for the session, with the common handheld shapes one tap away. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
val parsed = SessionPrefs.parseResolution("${w}x$h")
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionDisplay
/** Pre-session resolution choice, applied when the session starts. */
@Composable
fun ResolutionRow(
host: MenuHost, choice: String, panel: Pair<Int, Int>, onPick: (String) -> Unit,
chipModifier: Modifier = Modifier,
) {
val custom = "custom"
val size = SessionDisplay.resolveChoice(panel, choice)
val screen = SessionDisplay.screenSize(panel)
val choices = SessionDisplay.resolutionOptions(panel)
val selected = if (size == screen) SessionDisplay.MATCH_SCREEN else choice
var editCustom by remember { mutableStateOf(false) }
val options = choices.map { value ->
val dimensions = SessionDisplay.resolveChoice(panel, value)
value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second)
else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}")
else "${dimensions.first}×${dimensions.second}"
} + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
else stringResource(R.string.mode_res_custom_value, size.first, size.second))
ChoiceRow(host, "resolution", stringResource(R.string.display_resolution),
stringResource(R.string.common_applies_next_session),
options, if (selected in choices) selected else custom, chipModifier = chipModifier,
onPick = { value -> if (value == custom) editCustom = true else onPick(value) })
if (editCustom) CustomResolutionDialog(size,
onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") },
onDismiss = { editCustom = false })
}
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.ui
/** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */
internal enum class SessionDrawerPage(val firstControl: String) {
CONTROLLER("touch"),
SECOND_SCREEN("second-screen:none"),
DISPLAY("hud"),
EFFECTS("look"),
GAMES("anisotropy"),
SESSION("suspend"),
}
internal fun sessionDrawerPages(hasSecondScreen: Boolean): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN }
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
val index = indexOf(current).coerceAtLeast(0)
return this[Math.floorMod(index + direction, size)]
}
@@ -70,6 +70,9 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextOverflow
// The session's loading screen, its paused screen and the HUD text style.
@@ -226,7 +229,8 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
var firstUsed = false
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), onClick = onShareLogs)
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(),
progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs)
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
}
}
@@ -273,7 +277,11 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
.padding(horizontal = 12.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
Text("B", color = colors.background, style = TextStyle(
fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold,
platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
))
}
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
@@ -28,15 +28,24 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.outlined.AutoFixHigh
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.PictureInPictureAlt
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.draw.clipToBounds
import android.view.Display
import androidx.activity.compose.BackHandler
@@ -79,6 +88,8 @@ 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.ui.draw.rotate
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
@@ -93,7 +104,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -129,22 +139,33 @@ import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
import java.util.Locale
private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_effects, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings)
private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.SESSION -> R.string.mode_session
}
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.AutoFixHigh, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "look", "touch", "cmp-proton", "suspend")
private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(DRAWER_PAGES)
private val last = mutableMapOf<SessionDrawerPage, String>()
var focused by mutableStateOf<String?>(null)
private set
private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() }
fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
fun track(page: SessionDrawerPage, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
if (it.isFocused) {
last[page] = key
focused = key
@@ -152,9 +173,9 @@ private class DrawerFocus {
}
/** Where focus was last on [page], if anywhere. */
fun target(page: Int) = last[page]
fun target(page: SessionDrawerPage) = last[page]
fun request(key: String) = runCatching { requester(key).requestFocus() }
fun forget(page: Int) { last[page] = null }
fun forget(page: SessionDrawerPage) { last.remove(page) }
}
/** Everything the drawer shows and does. */
@@ -168,14 +189,12 @@ class DrawerActions(
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val backActionsInverted: Boolean,
val touchMode: String,
val touchAuto: String,
val shapeMode: String,
val fexPreset: String,
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
val fillScreen: Boolean? = null,
val upscaler: Int = 0,
val upscaleSharpness: Int = 75,
/** The compositor's post chain (gpu/ScreenEffects), changed live. */
@@ -204,9 +223,7 @@ class DrawerActions(
val onSuspendPolicy: (String) -> Unit,
val onBackActionsInverted: (Boolean) -> Unit,
val onTouch: (String) -> Unit,
val onShape: (String) -> Unit,
val onFexPreset: (String) -> Unit,
val onFillScreen: (Boolean) -> Unit = {},
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
val onEffects: (ScreenEffects) -> Unit = {},
@@ -225,7 +242,7 @@ class DrawerActions(
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
/** Games tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
@@ -235,16 +252,20 @@ class DrawerActions(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) {
internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) {
val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty())
val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER
LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) }
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val host = rememberMenuHost()
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var advancedEffectsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
val pageScroll = remember { SessionDrawerPage.entries.associateWith { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
@@ -254,13 +275,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
// The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first
// option; anything else (a menu closing, the drawer opening again) goes back where it was.
var focusedPage by remember { mutableIntStateOf(page) }
var focusedPage by remember { mutableStateOf(page) }
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
if (!open) {
host.open = null
@@ -270,8 +291,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
val turned = page != focusedPage
focusedPage = page
// The page's top control: on Settings that is Android apps, when it is shown.
val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page]
// The page's top control: on Session that is Android apps, when it is shown.
val first = if (page == SessionDrawerPage.SESSION && a.isHomeApp) "apps" else page.firstControl
if (turned) focus.forget(page)
val target = focus.target(page) ?: first
repeat(24) {
@@ -284,7 +305,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
if (open || veil > 0.01f) Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
modifier = Modifier.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
@@ -350,31 +371,21 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
) {
DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) {
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
host.open = null; onPageChange(pages.step(page, -1))
}
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerPageTabs(page = page, pages = pages, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) {
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
host.open = null; onPageChange(pages.step(page, 1))
}
}
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)),
) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) {
when (page) {
0 -> {
SessionDrawerPage.DISPLAY -> {
SettingsGroup(stringResource(R.string.drawer_page_display)) {
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
if (a.fillScreen != null) ToggleRow(
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
ChoiceRow(host, "upscaler", stringResource(R.string.drawer_scaling), null,
SessionPrefs.upscalerChoices, a.upscaler,
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
SliderRow(stringResource(R.string.drawer_scaling_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
enabled = a.upscaler != 0, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"),
onChange = a.onUpscaleSharpness)
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
@@ -384,14 +395,22 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
SettingsGroup(stringResource(R.string.display_image_scaling)) {
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
SessionPrefs.upscalerChoices, a.upscaler,
note = stringResource(R.string.display_filter_note),
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
}
}
DRAWER_PAGE_EFFECTS -> {
ScreenEffectsGroup(host, a) { key -> focus.track(page, key) }
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
SessionDrawerPage.EFFECTS -> {
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
}
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
DRAWER_PAGE_CONTROLS -> {
SettingsGroup(stringResource(R.string.drawer_page_controls)) {
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
@@ -402,12 +421,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
a.controller?.let { c ->
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
if (a.onScreenButtonsVisible) {
if (a.steam) {
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
}
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
}
}
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
@@ -423,18 +446,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) {
ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null,
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
.map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
else -> {
SessionDrawerPage.SECOND_SCREEN -> {
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.GAMES -> {
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
}
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) {
@@ -466,32 +494,22 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
SettingsGroup(stringResource(R.string.drawer_session_behavior)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
stringResource(R.string.drawer_suspend_hint),
null,
listOf(
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
),
) + if (a.steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
a.suspendPolicy,
note = stringResource(R.string.mode_suspend_note),
note = stringResource(if (a.steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
chipModifier = focus.track(page, "suspend"),
onPick = a.onSuspendPolicy,
)
}
SettingsGroup(stringResource(R.string.drawer_next_session)) {
ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
}
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
}
SettingsGroup(stringResource(R.string.drawer_support)) {
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs"),
progress = com.droiddeck.launcher.session.SessionLogShare.progress) {
host.open = null
a.onShareLogs()
}
@@ -501,12 +519,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
a.pipAutoEnter, onChange = a.onPipAutoEnter)
}
Spacer(Modifier.height(18.dp))
if (a.pipSupported) DrawerOutlineButton(stringResource(R.string.pip_title), modifier = focus.track(page, "pip")) {
host.open = null; a.onPip()
}
if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) {
host.open = null; a.onBackground()
Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt,
modifier = focus.track(page, "pip")) { host.open = null; a.onPip() }
if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp,
modifier = focus.track(page, "background")) { host.open = null; a.onBackground() }
}
}
}
@@ -547,7 +564,28 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
@Composable
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val shape = RoundedCornerShape(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
.glideBorder(hot, shape, pal.signal)
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp))
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
@Composable
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
@@ -560,7 +598,7 @@ private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onC
contentPadding = PaddingValues(horizontal = 12.dp),
border = BorderStroke(if (hot) 2.dp else 1.dp, if (hot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
) { Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) }
) { ProgressLabel({ m -> Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, modifier = m) }, progress, fontSize = 13.sp) }
}
@Composable
@@ -636,58 +674,60 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
/**
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
* selected one steps forward - full size, bright, a soft glow - while the others step back to
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
* a little smaller and fade, and the whole line leans a little toward the selection. LB / RB still turn
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
* pad like the dots it replaces.
*/
@Composable
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) {
private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPage>, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (SessionDrawerPage) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val count = drawerPageIcons.size
val count = pages.size
val selectedIndex = pages.indexOf(page)
val middle = (count - 1) / 2f
val motion = Motion.tw<Float>(340, easing = FastOutSlowInEasing)
androidx.compose.foundation.layout.BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
) {
val tab = if (compact) 40f else 56f
val tab = if (compact) 36f else 44f
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
// ([tab] dp) and pushed outward by the lean, which scales with the spacing. So
// (count-1) * spacing * (1 + lean/64) + 56 <= width
// - never more than the mock's 64 dp, never tighter than 32 dp.
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
// (count-1) * spacing * (1 + lean/spacingMax) + tab <= width
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP)
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
drawerPageIcons.forEachIndexed { index, icon ->
val selected = index == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index]))
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
val select = { onSelect(index) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
val lean by animateFloatAsState(-(selectedIndex - middle) * leanStep, motion, label = "tabLean")
pages.forEachIndexed { index, item ->
androidx.compose.runtime.key(item) {
val selected = item == page
val source = remember { MutableInteractionSource() }
val focused = source.collectIsFocusedAsState().value
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(item.title))
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
val select = { onSelect(item) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset(x = ((index - middle) * spacing + lean).dp)
.size(tab.dp)
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.clip(RoundedCornerShape(16.dp))
.background(
Brush.radialGradient(
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
),
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Icon(item.icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
}
}
}
}
@@ -696,54 +736,114 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
/** The Look row's value when the rows below match no Look. */
private const val LOOK_CUSTOM = "custom"
/**
* Screen effects on the Effects page: a Look row that moves every row under it, then the rows
* themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them.
*/
/** Presets and simple effects first; detailed adjustments stay behind Advanced. */
@Composable
private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
private fun ScreenEffectsGroup(
host: MenuHost, a: DrawerActions, advancedExpanded: Boolean, onAdvancedToggle: () -> Unit,
track: (String) -> Modifier,
) {
val e = a.effects
val look = ScreenEffectLooks.match(e, a.upscaler)
val preset = ScreenEffectLooks.match(e, a.upscaler)
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
SettingsGroup(stringResource(R.string.drawer_effects)) {
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look),
look?.desc ?: stringResource(R.string.drawer_effects_hint),
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
picked.scalingMode?.let(a.onUpscaler)
a.onEffects(picked.effects)
}
}
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) }
ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) }
ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) }
ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) }
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
}
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val rotation by animateFloatAsState(if (advancedExpanded) 180f else 0f, Motion.tw(180), label = "advancedArrow")
val description = stringResource(if (advancedExpanded) R.string.widgets_expanded else R.string.widgets_collapsed)
val toggle = { host.open = null; onAdvancedToggle() }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = track("effects-advanced").fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = toggle)
.controllerConfirm(onClick = toggle).semantics { stateDescription = description }.padding(horizontal = 4.dp),
) {
Text(stringResource(R.string.drawer_effects_advanced).uppercase(), fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.onSurfaceVariant,
modifier = Modifier.size(22.dp).rotate(rotation))
}
AnimatedVisibility(advancedExpanded) {
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp))) {
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
if (e.cas) SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5,
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
if (e.deband) SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5,
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
}
}
}
/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */
/** Secondary-display modes are mutually exclusive and apply immediately. */
@Composable
private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
Column(Modifier.fillMaxWidth().selectableGroup()) {
modes.forEach { mode ->
val selected = a.secondScreenMode == mode
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val pick = { host.open = null; a.onSecondScreenMode(mode) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = track("second-screen:${mode.id}").fillMaxWidth().heightIn(min = 56.dp)
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
.hoverable(source)
.selectable(selected, interactionSource = source, indication = null, role = Role.RadioButton, onClick = pick)
.controllerConfirm(onClick = pick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
RadioButton(selected, onClick = null,
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
}
}
}
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = track("second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
@Composable
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
@@ -751,16 +851,8 @@ private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (Stri
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
/** Display, Effects, Controls, Components, Settings. */
const val DRAWER_PAGES = 5
private const val DRAWER_PAGE_EFFECTS = 1
private const val DRAWER_PAGE_CONTROLS = 2
private const val DRAWER_PAGE_COMPONENTS = 3
/**
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
* deleting stay on the Components page in the app.
*/
@@ -884,14 +976,14 @@ private fun <T> DrawerStackedChoice(
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
/** A close row of icons: inactive tabs stay legible while the active tab leads. */
private const val DRAWER_TAB_SPACING_DP = 48f
private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_LEAN_DP = 8f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_SCALE = 0.8f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
private const val DRAWER_TAB_SIDE_ALPHA = 0.8f
/** Room between the Stop pill and the box that steps out under it: the pull. */
private val StepPull = 6.dp
@@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -60,6 +61,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -82,6 +84,10 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.style.TextAlign
import kotlin.math.roundToInt
import androidx.compose.ui.input.InputMode
@@ -468,8 +474,16 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
fun SliderRow(
label: String, hint: String?, value: Int, range: IntRange, step: Int = 1, enabled: Boolean = true,
format: (Int) -> String = { it.toString() }, modifier: Modifier = Modifier, onChange: (Int) -> Unit,
) = SettingsRow(label, hint) {
ValueSlider(value, range, step, enabled, label, format, modifier, onChange)
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth()) {
ValueSlider(value, range, step, enabled, label, format,
modifier = modifier.padding(horizontal = 6.dp, vertical = 3.dp), onChange = onChange)
if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 8.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
@Composable
@@ -479,34 +493,39 @@ fun ValueSlider(
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
val currentOnChange by rememberUpdatedState(onChange)
val span = (range.last - range.first).coerceAtLeast(1)
val fraction = ((value - range.first).toFloat() / span).coerceIn(0f, 1f)
val shape = RoundedCornerShape(99.dp)
val thumbInset = with(LocalDensity.current) { 8.dp.toPx() }
var trackPx by remember { mutableIntStateOf(0) }
val text = format(value)
// A step from the d-pad lands on the grid of [step] from the range's start; a Look's own
// value, which need not be on it, is kept until the user moves.
val shape = RoundedCornerShape(8.dp)
// Preserve off-grid preset values until the user adjusts; every input snaps to the same step.
fun stepped(v: Int) = (((v - range.first).toFloat() / step).roundToInt() * step + range.first).coerceIn(range.first, range.last)
fun fromX(x: Float) { if (trackPx > 0) onChange(stepped(range.first + (x / trackPx * span).roundToInt())) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.heightIn(min = 48.dp)
fun fromX(x: Float) {
val width = trackPx - 2 * thumbInset
if (width > 0) {
val position = ((x - thumbInset) / width).coerceIn(0f, 1f)
currentOnChange(stepped(range.first + (position * span).roundToInt()))
}
}
Column(
modifier = modifier.fillMaxWidth().heightIn(min = 72.dp)
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src)
.focusable(enabled, src)
.hoverable(source).focusable(enabled, source)
.onPreviewKeyEvent { event ->
val keyEvent = event.nativeKeyEvent
val left = keyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) {
false
} else {
// Every down counts, repeats included, so a held direction keeps stepping.
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) false
else {
// Repeated key-down events let a held direction keep adjusting.
if (enabled && keyEvent.action == KeyEvent.ACTION_DOWN) {
val next = stepped(value + if (left) -step else step)
if (next != value) onChange(next)
if (next != value) currentOnChange(next)
}
true
}
@@ -515,28 +534,33 @@ fun ValueSlider(
stateDescription = text
progressBarRangeInfo = ProgressBarRangeInfo(value.toFloat(), range.first.toFloat()..range.last.toFloat())
if (label != null) contentDescription = label
},
if (!enabled) disabled()
setProgress { next ->
if (!enabled) false else { currentOnChange(stepped(next.roundToInt())); true }
}
}
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, textAlign = TextAlign.End,
maxLines = 1, modifier = Modifier.width(56.dp))
Spacer(Modifier.width(10.dp))
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.size(width = 164.dp, height = 30.dp).clip(shape).background(colors.surfaceVariant)
.glideBorder(hot, shape, colors.onBackground, pal.line2)
.onSizeChanged { trackPx = it.width }
.pointerInput(enabled, range, step) { if (enabled) detectTapGestures { fromX(it.x) } }
.pointerInput(enabled, range, step) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (label != null) Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, modifier = Modifier.weight(1f))
else Spacer(Modifier.weight(1f))
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1)
}
Canvas(
Modifier.fillMaxWidth().height(40.dp).onSizeChanged { trackPx = it.width }
.pointerInput(enabled, range, step, thumbInset) { if (enabled) detectTapGestures { fromX(it.x) } }
.pointerInput(enabled, range, step, thumbInset) {
if (enabled) detectHorizontalDragGestures { change, _ -> fromX(change.position.x); change.consume() }
},
) {
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(pal.signal))
Box(
Modifier.padding(4.dp).size(22.dp)
.graphicsLayer { translationX = fraction * (trackPx - 30.dp.toPx()).coerceAtLeast(0f) }
.clip(CircleShape)
.background(pal.onSignal),
)
val start = Offset(thumbInset, size.height / 2f)
val end = Offset(size.width - thumbInset, start.y)
val thumb = Offset(start.x + (end.x - start.x) * fraction, start.y)
val fill = if (hot) pal.signal else colors.onBackground
drawLine(colors.onSurfaceVariant.copy(alpha = 0.35f), start, end, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
if (fraction > 0) drawLine(fill, start, thumb, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
drawCircle(fill, radius = 7.dp.toPx(), center = thumb)
}
}
}
@@ -567,14 +591,14 @@ fun MultiRow(
}
@Composable
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit) {
SettingsRow(label, hint) { SecondaryButton(button, onClick = onClick) }
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) {
SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) }
}
@Composable
fun SettingsPage(
host: MenuHost,
title: String,
title: String?,
onBack: () -> Unit,
eyebrow: String? = null,
lede: String? = null,
@@ -584,6 +608,9 @@ fun SettingsPage(
scroll: androidx.compose.foundation.ScrollState? = null,
scrollContent: Boolean = true,
compactLayout: Boolean = false,
/** Fixed controls below the title, or beside Back when the title is omitted. */
header: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
val scrollState = scroll ?: rememberScrollState()
@@ -591,13 +618,24 @@ fun SettingsPage(
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
val narrow = LocalNarrowPane.current && !compactLayout
Column(
modifier = Modifier.fillMaxSize().padding(
modifier = modifier.fillMaxSize().padding(
horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp,
vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp,
),
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp),
) {
if (narrow) {
if (title == null) {
Rise(0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
if (header != null) Box(Modifier.weight(1f)) { header() }
}
}
} else if (narrow) {
// Back and the title share one line: a 4:3 screen has no height to spare.
Rise(0) {
Row(
@@ -656,6 +694,7 @@ fun SettingsPage(
}
}
}
if (header != null && title != null) header()
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
@@ -5,9 +5,13 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.session.SessionPrefs
class Palette(
@@ -67,7 +71,7 @@ val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) }
val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White
@Composable
fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) {
val id = theme ?: SessionPrefs.theme(LocalContext.current)
val p = Themes.byId(id)
val scheme = darkColorScheme(
@@ -77,5 +81,13 @@ fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
surfaceVariant = p.surfaceVariant, onSurfaceVariant = p.onSurfaceVariant,
error = p.error,
)
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, content = content) }
val baseDensity = LocalDensity.current
val baseConfiguration = LocalConfiguration.current
val density = remember(baseDensity, appScale) { scaledAppDensity(baseDensity, appScale) }
val configuration = remember(baseConfiguration, appScale) { scaledAppConfiguration(baseConfiguration, appScale) }
CompositionLocalProvider(
LocalPalette provides p,
LocalDensity provides density,
LocalConfiguration provides configuration,
) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -0,0 +1,280 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
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.rememberCoroutineScope
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.DependencyDetector
import com.droiddeck.launcher.frontend.PrefixInstalledDetector
import com.droiddeck.launcher.frontend.SteamRedists
import com.droiddeck.launcher.session.WinComponentNames
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.session.WinComponents.Support
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import androidx.compose.runtime.withFrameNanos
import kotlinx.coroutines.withContext
import java.io.File
// Windows components for one added game, Bannerlator's list: what the game's folder suggests first,
// then everything that installs here, then the ones that need a Windows installer (not yet). Each
// one is a switch, downloaded the first time it is turned on for any game; the launch puts the
// picked ones into the game's prefix (droiddeck-wincomponents).
/** Bannerlator's detector names installers; here the DLL-copy twin is the one that installs. */
private fun installable(name: String, all: Map<String, WinComponents.Component>): String {
val dll = when (name) {
"oalinst" -> "oalinst_dll"
else -> "${name}_dll"
}
val twin = all[dll]
return if (twin != null && WinComponents.support(twin, all) == Support.READY) dll else name
}
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
internal fun WinComponentsDialog(
appKey: String, gameName: String, gameDir: File?, byPad: Boolean,
/** compatdata/<id>: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */
compat: File? = null, steamAppId: Int? = null,
onClose: () -> Unit,
) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
// null while the catalog loads; empty when it could not be had.
var catalog by remember { mutableStateOf<Map<String, WinComponents.Component>?>(null) }
var offline by remember { mutableStateOf(false) }
var recommended by remember { mutableStateOf(emptyList<DependencyDetector.Recommendation>()) }
var installed by remember { mutableStateOf(emptySet<String>()) }
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
var present by remember { mutableStateOf(emptySet<String>()) }
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
var progress by remember { mutableStateOf<Pair<String, Int>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
// The component the error is about, so it shows on its own row and not only at the bottom.
var failed by remember { mutableStateOf<String?>(null) }
var showWaiting by remember { mutableStateOf(false) }
// Done holds focus while the list loads, then the top switch takes it - opened by touch or by
// pad, so the d-pad always has somewhere to start; LB and RB jump between the sections, since
// the full list runs past sixty switches.
val recFocus = remember { FocusRequester() }
val allFocus = remember { FocusRequester() }
val waitFocus = remember { FocusRequester() }
val doneFocus = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
var switchFocused by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val (entries, found) = withContext(Dispatchers.IO) {
// The folder's own installers first, then what Steam installs with the game.
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
WinComponents.fetch() to (own + steam)
}
offline = entries == null
catalog = entries.orEmpty().associateBy { it.name }
recommended = found
present = withContext(Dispatchers.IO) { PrefixInstalledDetector.detect(compat) }
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
}
fun setPicks(next: List<String>) {
picks = next
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
}
fun toggle(id: String, on: Boolean) {
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
// then is ignored here.
if (progress != null) return
error = null
failed = null
if (!on) { setPicks(picks - id); return }
val all = catalog.orEmpty()
val c = all[id]
if (id in installed || c == null) { setPicks(picks + id); return }
progress = id to -1
coroutine.launch {
val problem = withContext(Dispatchers.IO) {
WinComponents.install(context, c, all) { stage, percent -> progress = stage to percent }
}
progress = null
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
if (problem != null) { error = problem; failed = id } else setPicks(picks + id)
}
}
val all = catalog
val here = if (all == null) emptySet() else present.map { installable(it, all) }.toSet()
fun supportOf(id: String): Support =
all?.get(id)?.let { WinComponents.support(it, all) } ?: if (id in installed) Support.READY else Support.UNSUPPORTED
val recIds = if (all == null) emptyList() else recommended.distinctBy { installable(it.componentName, all) }
val ready = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.READY }.map { it.name }
val extra = (installed + picks).filter { all?.containsKey(it) != true }
val list = (ready + extra).distinct().sortedBy { WinComponentNames.of(it).lowercase() }
val waiting = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.NEEDS_INSTALLER }
.map { it.name }.sortedBy { WinComponentNames.of(it).lowercase() }
val hasRec = all != null && recIds.any { supportOf(installable(it.componentName, all)) == Support.READY }
val sections = listOfNotNull(recFocus.takeIf { hasRec }, allFocus.takeIf { list.isNotEmpty() },
waitFocus.takeIf { waiting.isNotEmpty() }, doneFocus)
var section by remember { mutableStateOf(0) }
fun jump(step: Int) {
section = (section + step + sections.size) % sections.size
runCatching { sections[section].requestFocus() }
}
LaunchedEffect(all != null) {
// The dialog's window starts in touch mode, where a switch cannot hold focus at all.
inputMode.requestInputMode(InputMode.Keyboard)
section = 0
val target = if (all == null) doneFocus else sections.first()
switchFocused = false
// It animates in and the list composes over a few frames: ask until the switch has it.
repeat(60) {
withFrameNanos { }
runCatching { target.requestFocus() }
withFrameNanos { }
if (switchFocused || (target !== recFocus && target !== allFocus)) return@LaunchedEffect
}
}
AppDialog(shown, close, "winComponents", wide = true, modifier = Modifier.bumpers({ jump(-1) }, { jump(1) })) {
DialogHeader(gameName, stringResource(R.string.wincomp_title))
Small(stringResource(R.string.wincomp_applies))
if (byPad) Small(stringResource(R.string.wincomp_pad_hint))
if (all == null) Small(stringResource(R.string.wincomp_loading))
if (offline) Small(stringResource(R.string.wincomp_offline), error = true)
@Composable
fun Item(id: String, reason: String?, focus: FocusRequester?) {
val c = all?.get(id)
val support = supportOf(id)
val status = when {
id == failed && error != null -> stringResource(R.string.wincomp_failed, error.orEmpty())
id in here && id !in picks -> stringResource(R.string.wincomp_already_here)
id in installed -> stringResource(R.string.wincomp_downloaded)
support == Support.NEEDS_INSTALLER -> stringResource(R.string.wincomp_needs_installer)
support == Support.UNSUPPORTED -> stringResource(R.string.wincomp_unsupported)
else -> null
}
// The catalog key stays in the detail: it is what a log or a bug report names.
val detail = listOfNotNull(reason, c?.description?.takeIf { it.isNotEmpty() }, status, id).joinToString(" · ")
val usable = support == Support.READY
ComponentRow(
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || id in picks,
dim = !usable, modifier = (if (usable && focus != null) Modifier.focusRequester(focus) else Modifier)
.onFocusChanged { if (it.isFocused) switchFocused = true },
) { on -> toggle(id, on) }
}
/** The section's requester goes to its first switch that can take focus. */
fun firstUsable(ids: List<String>, focus: FocusRequester): (String) -> FocusRequester? {
val first = ids.firstOrNull { supportOf(it) == Support.READY }
return { id -> focus.takeIf { id == first } }
}
if (all != null && recIds.isNotEmpty()) {
Section(stringResource(R.string.wincomp_recommended))
val focusOf = firstUsable(recIds.map { installable(it.componentName, all) }, recFocus)
Panel {
recIds.forEachIndexed { i, rec ->
if (i > 0) Divider()
val reason = stringResource(
when (rec.kind) {
DependencyDetector.Kind.BUNDLED -> R.string.wincomp_found_bundled
DependencyDetector.Kind.SHIPPED -> R.string.wincomp_found_shipped
DependencyDetector.Kind.STEAM -> R.string.wincomp_found_steam
}, rec.reason,
)
val id = installable(rec.componentName, all)
Item(id, reason, focusOf(id))
}
}
} else if (all != null && gameDir != null) Small(stringResource(R.string.wincomp_no_recommendation))
if (list.isNotEmpty()) {
Section(stringResource(R.string.wincomp_all, list.size))
val focusOf = firstUsable(list, allFocus)
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
}
if (waiting.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Section(stringResource(R.string.wincomp_installers, waiting.size))
Spacer(Modifier.weight(1f))
SecondaryButton(
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
modifier = Modifier.focusRequester(waitFocus),
) { showWaiting = !showWaiting }
}
Small(stringResource(R.string.wincomp_installers_note))
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
}
progress?.let { (stage, percent) ->
Small(if (percent >= 0) "$stage · $percent%" else stage)
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth())
else LinearProgressIndicator(Modifier.fillMaxWidth())
}
error?.let { Small(it, error = true) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
Small(stringResource(R.string.wincomp_next_launch))
Spacer(Modifier.weight(1f))
PrimaryButton(stringResource(R.string.game_env_done), enabled = progress == null, modifier = Modifier.focusRequester(doneFocus), onClick = close)
}
}
}
@Composable
private fun Section(title: String) =
Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
@Composable
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
Column(Modifier.fillMaxWidth().clip(Shape14).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, Shape14), content = content)
@Composable
private fun Divider() =
Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).heightIn(min = 1.dp, max = 1.dp).background(LocalPalette.current.line))
@Composable
private fun ComponentRow(name: String, detail: String, checked: Boolean, enabled: Boolean, dim: Boolean, modifier: Modifier, onChange: (Boolean) -> Unit) {
val colors = MaterialTheme.colorScheme
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp).alpha(if (dim) 0.55f else 1f),
) {
Column(Modifier.weight(1f)) {
Text(name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (detail.isNotEmpty()) Text(detail, fontSize = 12.sp, lineHeight = 16.sp, color = colors.onSurfaceVariant)
}
ToggleSwitch(checked, enabled, name, modifier, onChange)
}
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.wayland
import android.view.Choreographer
import android.view.Surface
import com.droiddeck.launcher.session.SessionState
/**
* The compositor is a process-wide thing, not an activity's: it is started once, keeps running
@@ -35,13 +36,17 @@ object CompositorHost {
fpsLimit: Int,
): Boolean {
pace(refreshHz, fpsLimit)
// The compositor outlives sessions, and the next one may be on the other screen of a dual-screen
// device: gamescope reads the size from the toplevel configure and the wl_output mode when it
// connects, and kept the first session's (the Thor's 1920x1080 top screen letterboxed the bottom
// one's session to 16:9). A running session keeps the size it was started at.
if (!SessionState.running) WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
if (started) {
attached = surface
WaylandCompositor.nativeSetSurface(surface)
resumeVsync()
return false
}
WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
WaylandCompositor.nativeStartWithSurface(
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
@@ -115,6 +115,11 @@ public final class WaylandCompositor {
public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; }
/** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */
public static void clearPointerLockListener(PointerLockListener l) {
if (pointerLockListener == l) pointerLockListener = null;
}
/** Invoked from native (droiddeck_on_pointer_lock). */
@SuppressWarnings("unused")
static void onPointerLock(boolean locked, int x, int y) {
@@ -500,4 +505,11 @@ public final class WaylandCompositor {
if (dx == 0 && dy == 0) return;
nativeSendSceneInput(6, dx * 256, dy * 256);
}
/** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */
public static void sendPointerDelta(float dx, float dy) {
int x = Math.round(dx * 256f), y = Math.round(dy * 256f);
if (x == 0 && y == 0) return;
nativeSendSceneInput(6, x, y);
}
}
-3
View File
@@ -285,8 +285,6 @@
<string name="mode_added_launches_in">Inicia %1$s · en %2$s</string>
<string name="mode_added_choose">Elegir otro archivo…</string>
<string name="mode_added_exe_note">Por defecto, el .exe con el nombre de la carpeta o, si no, el más grande.</string>
<string name="mode_fill">Estirar juegos para llenar la pantalla</string>
<string name="mode_fill_hint">Mantiene a pantalla completa los juegos que se redimensionan solos. Desactívalo si un juego se ve pequeño. Se aplica en la próxima sesión.</string>
<string name="mode_stretch169">Estirar juegos 16:9</string>
<string name="mode_stretch169_hint">En pantallas más cuadradas que 16:9, estira los juegos para llenar en lugar de mostrar barras. Se aplica en la próxima sesión.</string>
<string name="mode_audio">Audio</string>
@@ -444,7 +442,6 @@
<string name="drawer_prev_page">Página anterior</string>
<string name="drawer_next_page">Página siguiente</string>
<string name="drawer_hud">HUD de rendimiento</string>
<string name="drawer_fill">Estirar juegos para llenar</string>
<string name="drawer_scaling">Modo de escalado</string>
<string name="drawer_scaling_sharpness">Nitidez del escalado</string>
<string name="drawer_effects">Efectos de pantalla</string>
+949
View File
@@ -0,0 +1,949 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>Le premier lancement télécharge Steam. Cela peut prendre quelques minutes.</item>
<item>Par défaut, quitter l\'application met Steam en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>Le premier lancement configure le bureau. Cela peut prendre quelques minutes.</item>
<item>Par défaut, quitter l\'application met la session en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
<item>Le tactile fonctionne comme un pavé tactile : faites glisser pour déplacer le pointeur, tapotez pour cliquer.</item>
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
</string-array>
<string name="session_channel">Session</string>
<string name="session_channel_description">Affiché lorsqu\'une session Steam ou Bureau est ouverte</string>
<string name="session_notification">Steam est en cours d\'exécution</string>
<string name="session_notification_paused">Session en pause - Appuyez pour reprendre quand vous êtes prêt</string>
<string name="resume_session">Reprendre</string>
<string name="stop_session">Terminer cette session</string>
<string name="frame_gen_title">Génération d\'images</string>
<string name="frame_gen_hint">Sélectionnez le mode de génération d\'images à utiliser</string>
<string name="frame_gen_off">Désactivé</string>
<string name="frame_gen_winfg">Win-FG %1$d×</string>
<string name="frame_gen_lsfg">LSFG %1$d×</string>
<string name="frame_gen_lsfg_adaptive">LSFG Adaptatif %1$d</string>
<string name="lsfg_needs_steam">Nécessite Lossless Scaling sur Steam</string>
<string name="lsfg_needs_install">Installez Lossless Scaling sur Steam ou importez Lossless.dll</string>
<string name="lsfg_import">Importer Lossless.dll…</string>
<string name="lsfg_using_steam">Utilise la version sur Steam</string>
<string name="lsfg_using_import">Utilise la version importée</string>
<string name="lsfg_pick_title">Sélectionner le fichier Lossless.dll</string>
<string name="lsfg_imported">Lossless.dll importé</string>
<string name="lsfg_import_unchanged">Ce Lossless.dll est déjà utilisé</string>
<string name="lsfg_import_not_owned">Ce compte Steam ne possède pas Lossless Scaling</string>
<string name="lsfg_import_invalid">Ce fichier n\'est pas le Lossless.dll de Lossless Scaling</string>
<string name="lsfg_import_failed">Impossible de préparer LSFG à partir de ce Lossless.dll</string>
<string name="lsfg_problem_missing">LSFG nécessite Lossless Scaling depuis Steam, ou un fichier Lossless.dll importé</string>
<string name="lsfg_problem_failed">LSFG ne peut pas préparer ses shaders à partir de ce Lossless.dll</string>
<string name="hud_fg_unsupported">%1$s : non pris en charge</string>
<string name="hud_fg_failed">%1$s : échec du moteur</string>
<string name="hud_fg_needs_lossless">%1$s : nécessite Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · Démarrage en cours</string>
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam.</string>
<string name="second_library_import_hint">Ajoutez une bibliothèque Steam et analysez-y les jeux Windows. Les jeux déjà possédés utiliseront les entrées Steam correspondantes, les autres deviendront des raccourcis non-Steam.</string>
<string name="added_games_forget_hint">« Oublier » supprimera ces raccourcis lors de la prochaine session. Mais les fichiers resteront sur le disque.</string>
<string name="game_env_title">Environnement de jeu</string>
<string name="game_env_hint">Variables d\'environnement pour les jeux Proton.</string>
<string name="game_env_edit">Modifier</string>
<string name="game_env_shared">Tous les jeux Proton</string>
<string name="game_env_scope">Profil</string>
<string name="game_env_app_id">ID d\'application Steam</string>
<string name="game_env_open">Ouvrir</string>
<string name="game_env_profile">Jeu %1$s</string>
<string name="game_env_add">Ajouter une variable</string>
<string name="game_env_name">Nom de la variable</string>
<string name="game_env_value">Valeur</string>
<string name="game_env_save">Enregistrer</string>
<string name="game_env_remove">Supprimer</string>
<string name="game_env_restore">Rétablir l\'héritage</string>
<string name="game_env_reset">Réinitialiser ce profil</string>
<string name="game_env_unset">Annuler la définition pour ce profil</string>
<string name="game_env_empty">Aucune variable définie.</string>
<string name="game_env_inherited">Hérité</string>
<string name="game_env_custom">Variable personnalisée</string>
<string name="game_env_error">Impossible de charger ou d\'enregistrer. Vérifiez l\'espace de stockage restant et réessayez.</string>
<string name="game_env_invalid">Veuillez saisir un nom et une valeur valides, jusqu\'à 8192 caractères.</string>
<string name="game_env_unused">L\'environnement d\'exécution Proton standard ne lit pas cette variable.</string>
<string name="game_env_loading">Chargement…</string>
<string name="game_env_feature">Remplacement du niveau de fonctionnalité D3D12. 12_2 signalera les capacités DirectX 12 Ultimate ; les fonctionnalités non prises en charge peuvent entraîner un échec. Laissez vide pour la détection automatique.</string>
<string name="game_env_shader">Remplacement du modèle de shader D3D12. 6_9 nécessite un VKD3D suffisamment récent et des fonctionnalités GPU correspondantes. Laissez vide pour la détection automatique.</string>
<string name="game_env_cache">Maintient le cache de shaders Mesa activé pour réduire les recompilations. Définir sur false pour l\'activer.</string>
<string name="game_env_cache_size">Taille maximale du cache de shaders Mesa, par exemple 1G. Un cache plus grand occupe plus d\'espace de stockage.</string>
<string name="game_env_vkd3d_config">Options VKD3D pour D3D12. nodxr désactive le ray tracing ; à utiliser uniquement si le jeu le nécessite.</string>
<string name="game_env_proton_log">Définir sur 1 pour générer un journal de diagnostic Proton. La journalisation peut réduire les performances et consommer de l\'espace de stockage.</string>
<string name="game_env_wined3d">Définir sur 1 pour utiliser WineD3D (basé sur OpenGL) au lieu de DXVK, pour tester la compatibilité D3D9–11.</string>
<string name="game_env_xalia">Définir sur 0 pour désactiver l\'assistant de navigation à la manette de Proton pour ce jeu.</string>
<string name="game_env_hud">Superposition DXVK pour D3D9–11. fps affiche le taux de rafraîchissement ; annuler la définition masque la superposition.</string>
<string name="game_env_dxvk_config">Paramètres DXVK séparés par des points-virgules. Les versions récentes utilisent dxvk.maxFrameRate pour limiter le taux de rafraîchissement ; pour un jeu quotidien, il est recommandé d\'utiliser le limiteur de taux de rafraîchissement de session.</string>
<string name="game_env_vkd3d_limit">Limite de taux de rafraîchissement D3D12. 0 désactive la limite.</string>
<string name="game_env_glthread">Mesa OpenGL multithread. Pour les jeux OpenGL limités par le CPU, true peut aider ; à tester jeu par jeu.</string>
<string name="fex_preset_title">Préréglages FEX</string>
<string name="fex_next_launch">Prendra effet au prochain lancement du jeu.</string>
<string name="game_settings_title">Jeu</string>
<string name="fex_default_label">FEX par défaut</string>
<string name="fex_default_detail">Utiliser les paramètres par défaut de FEX.</string>
<string name="fex_stability_label">Stabilité</string>
<string name="fex_stability_detail">Paramètres les plus sûrs, les plus lents.</string>
<string name="fex_compatibility_label">Compatibilité</string>
<string name="fex_compatibility_detail">TSO complet + multiblock.</string>
<string name="fex_intermediate_label">Intermédiaire</string>
<string name="fex_intermediate_detail">Paramètres TSO équilibrés, avec une précision x87 réduite.</string>
<string name="fex_performance_label">Performance</string>
<string name="fex_performance_detail">Désactive tous les TSO ; active le multiblock.</string>
<string name="fex_performance_tso_label">Performance + TSO</string>
<string name="fex_performance_tso_detail">Paramètres de performance, avec TSO principal activé.</string>
<string name="fex_extreme_label">Extrême</string>
<string name="fex_extreme_detail">Désactive la vérification du code auto-modifiant. Les jeux JIT et les DRM peuvent ne pas fonctionner.</string>
<string name="fex_extreme_tso_label">Extrême + TSO</string>
<string name="fex_extreme_tso_detail">Paramètres extrêmes, avec TSO principal activé.</string>
<string name="fex_extreme_gn_label">Extrême-gn</string>
<string name="fex_extreme_gn_detail">Paramètres de performance, active TSC et les astuces de métadonnées. Il est recommandé d\'essayer d\'abord ce mode avant « Extrême ».</string>
<string name="fex_denuvo_label">Denuvo</string>
<string name="fex_denuvo_detail">Paramètres de performance, active la vérification complète du code et masque les bits de l\'hyperviseur.</string>
<string name="game_env_custom_value">Valeur personnalisée</string>
<string name="game_env_select_value">Sélectionner une valeur</string>
<string name="user_apps_section">Émulateurs et applications</string>
<string name="user_apps_add">Ajouter</string>
<string name="user_apps_add_detail">Votre propre application ou jeu</string>
<string name="user_apps_kind_script">Script</string>
<string name="user_apps_kind_appimage">AppImage</string>
<string name="user_apps_kind_flatpak">Flatpak</string>
<string name="user_apps_progress">%1$s · %2$s</string>
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
<string name="user_apps_starting">Démarrage…</string>
<string name="user_apps_added">%1$s ajouté</string>
<string name="user_apps_removed">%1$s supprimé</string>
<string name="user_apps_back">Bureau</string>
<string name="user_apps_gone">Cette application n\'existe plus. Veuillez la resélectionner depuis le bureau.</string>
<string name="user_apps_open">Ouvrir %1$s</string>
<string name="user_apps_remove">Supprimer</string>
<string name="user_apps_remove_confirm">Appuyez à nouveau pour supprimer</string>
<string name="user_apps_stop_session">Veuillez d\'abord terminer la session avant de supprimer</string>
<string name="user_apps_script_linked">Exécute %1$s depuis son dossier et lie ce dossier à la session. La suppression de l\'application n\'affectera pas ce dossier.</string>
<string name="user_apps_script_copied">Exécute %1$s depuis un dossier copié à l\'intérieur de DroidDeck. La suppression de l\'application supprimera la copie, mais pas le fichier d\'origine.</string>
<string name="user_apps_runtime_required">Veuillez d\'abord installer l\'environnement d\'exécution Linux</string>
<string name="user_apps_file_gone">Le fichier n\'existe plus</string>
<string name="user_apps_no_space">Espace disponible insuffisant : %1$s est nécessaire pour copier ce dossier</string>
<string name="user_apps_copying">Copie de %1$s en cours</string>
<string name="user_apps_failed">Échec de l\'ajout de l\'application</string>
<string name="user_apps_github_checking">Recherche des versions de %1$s</string>
<string name="user_apps_github_unreachable">Impossible de lire les versions de %1$s</string>
<string name="user_apps_github_no_appimage">%1$s ne fournit pas de version ARM64 AppImage</string>
<string name="user_apps_downloading">Téléchargement de %1$s en cours</string>
<string name="user_apps_download_failed">Échec du téléchargement</string>
<string name="user_apps_update_failed">Échec de la mise à jour de l\'application</string>
<string name="user_apps_icon_failed">Impossible de lire cette icône</string>
<string name="user_apps_saved">%1$s enregistré</string>
<string name="user_apps_edit">Modifier</string>
<string name="user_apps_check_updates">Vérifier les mises à jour</string>
<string name="user_apps_checking_updates">Vérification des mises à jour…</string>
<string name="user_apps_update_to">Mettre à jour vers %1$s</string>
<string name="user_apps_up_to_date">À jour · %1$s</string>
<string name="user_apps_updated">%1$s mis à jour</string>
<string name="user_apps_github_version">%1$s · %2$s</string>
<string name="add_app_title">Ajouter une application</string>
<string name="add_app_tab_script">Script</string>
<string name="add_app_tab_appimage">AppImage</string>
<string name="add_app_tab_github">GitHub</string>
<string name="add_app_tab_flatpak">Flatpak</string>
<string name="add_app_script_hint">Un fichier .sh. Il s\'exécutera depuis son propre dossier et s\'affichera en plein écran via gamescope.</string>
<string name="add_app_appimage_hint">Un AppImage ARM64 (aarch64). Il sera décompressé dans l\'environnement d\'exécution Linux lors de la première utilisation.</string>
<string name="add_app_github_hint">Installer l\'AppImage ARM64 depuis la dernière version du dépôt.</string>
<string name="add_app_flatpak_hint">ID d\'application Flathub ou sa page Flathub. Si Flatpak n\'est pas encore configuré, il le sera d\'abord.</string>
<string name="add_app_file">Fichier</string>
<string name="add_app_no_file">Aucun fichier sélectionné</string>
<string name="add_app_choose_file">Choisir un fichier</string>
<string name="add_app_change_file">Modifier</string>
<string name="add_app_pick_script">Sélectionner le script</string>
<string name="add_app_pick_appimage">Sélectionner l\'AppImage</string>
<string name="add_app_pick_icon">Sélectionner l\'icône</string>
<string name="add_app_repository">Dépôt</string>
<string name="add_app_repository_placeholder">owner/repo ou lien github.com</string>
<string name="add_app_repository_invalid">N\'est pas un dépôt GitHub</string>
<string name="add_app_flatpak_id">ID d\'application</string>
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
<string name="add_app_flatpak_invalid">N\'est pas un ID d\'application Flathub valide</string>
<string name="add_app_source">Source</string>
<string name="add_app_appearance">Afficher sur le bureau</string>
<string name="add_app_preview_name">Nouvelle application</string>
<string name="add_app_name">Nom</string>
<string name="add_app_name_placeholder">Depuis l\'application</string>
<string name="add_app_icon">Icône</string>
<string name="add_app_icon_default">Icône par défaut</string>
<string name="add_app_choose_icon">Choisir une icône</string>
<string name="add_app_clear_icon">Effacer</string>
<string name="add_app_icon_suggestions">Depuis le dépôt</string>
<string name="add_app_icon_searching">Recherche d\'icônes…</string>
<string name="add_app_icon_reset">Par défaut</string>
<string name="edit_app_title">Modifier %1$s</string>
<string name="edit_app_save">Enregistrer</string>
<string name="add_app_checking_folder">Vérification du dossier…</string>
<string name="add_app_folder_linked">Son dossier est lié à la session : %1$s</string>
<string name="add_app_folder_copied">Ce dossier contient des programmes Linux qui ne peuvent pas s\'exécuter depuis le stockage partagé. Il sera donc copié dans DroidDeck (%1$s).</string>
<string name="add_app_confirm">Ajouter</string>
<string name="add_app_cancel">Annuler</string>
<string name="add_app_runtime_required">Veuillez d\'abord installer l\'environnement d\'exécution Linux depuis les « Paramètres ».</string>
<string name="add_app_busy">Veuillez attendre que %1$s soit terminé.</string>
<string name="common_cancel">Annuler</string>
<string name="common_auto">Auto</string>
<string name="common_never">Jamais</string>
<string name="common_always">Toujours</string>
<string name="common_uninstall">Désinstaller</string>
<string name="common_check">Vérifier</string>
<string name="common_update">Mettre à jour</string>
<string name="common_checking">Vérification…</string>
<string name="common_working">Traitement en cours…</string>
<string name="common_forget">Oublier</string>
<string name="common_add_ellipsis">Ajouter…</string>
<string name="common_use">Utiliser</string>
<string name="common_applies_next_session">Prendra effet à la prochaine session.</string>
<string name="mode_steam_title">Session Steam</string>
<string name="mode_desktop_title">Session Bureau</string>
<string name="mode_display">Affichage</string>
<string name="mode_resolution">Résolution</string>
<string name="mode_res_720">Max 720p</string>
<string name="mode_res_900">Max 900p</string>
<string name="mode_res_1080">Max 1080p</string>
<string name="mode_res_native">Native</string>
<string name="mode_res_default">%1$s (par défaut)</string>
<string name="mode_res_custom_value">Personnalisée · %1$d×%2$d</string>
<string name="mode_res_custom">Personnalisée…</string>
<string name="mode_res_note">720p peut rendre les menus plus réactifs.</string>
<string name="mode_ratio">Format d\'écran</string>
<string name="mode_ratio_custom">Défini par la résolution personnalisée.</string>
<string name="mode_ratio_auto">Le mode automatique utilise au moins 16:9.</string>
<string name="mode_fps">Limite de taux de rafraîchissement</string>
<string name="mode_fps_note">Limite le taux de rafraîchissement de toute la session. Le moniteur basculera vers un taux de rafraîchissement divisible par cette limite.</string>
<string name="mode_hdr">HDR</string>
<string name="mode_hdr10">Sortie HDR10</string>
<string name="mode_hdr_unavailable">Non disponible : %1$s.</string>
<string name="mode_hdr_restart">Prendra effet après le redémarrage de l\'application.</string>
<string name="mode_drivers">Pilotes</string>
<string name="mode_gpu_drivers">Pilotes GPU</string>
<string name="mode_gpu_drivers_hint">Partagés entre Steam et le bureau. À configurer dans la page « Composants ».</string>
<string name="mode_touch_controls">Tactile et contrôles</string>
<string name="mode_touch">Tactile</string>
<string name="mode_touch_touchpad">Pavé tactile</string>
<string name="mode_touch_direct">Direct</string>
<string name="mode_touch_note">Auto : pavé tactile pour le bureau, tactile direct pour Steam.</string>
<string name="mode_osc">Commandes à l\'écran</string>
<string name="mode_osc_qam">Steam + QAM</string>
<string name="mode_osc_note">Le mode automatique affiche les commandes à l\'écran lorsqu\'aucune manette n\'est connectée.</string>
<string name="mode_controller">Manette</string>
<string name="mode_controller_hint">Comment Steam reconnaît votre manette. Prendra effet à la prochaine session.</string>
<string name="mode_controller_deck">Manette Steam Deck</string>
<string name="mode_controller_x360">Manette Xbox 360</string>
<string name="mode_controller_note">Deck : fournit un bouton d\'accès rapide et un gyroscope. Xbox 360 : utilisez Guide+A pour ouvrir l\'accès rapide.</string>
<string name="mode_back">Retour</string>
<string name="mode_session">Session</string>
<string name="mode_suspend">Comportement en arrière-plan</string>
<string name="mode_suspend_hint">Comportement lorsque l\'application est masquée ou que l\'écran est éteint.</string>
<string name="mode_suspend_manual">Manuel</string>
<string name="mode_suspend_note">Le mode automatique met en pause en arrière-plan et reprend à l\'affichage. Le mode manuel nécessite d\'appuyer sur « Reprendre ». Ne reste jamais en cours d\'exécution.</string>
<string name="mode_startup">Démarrage</string>
<string name="mode_steam_startup">Lancer Steam à l\'ouverture de DroidDeck</string>
<string name="mode_steam_startup_hint">Ouvre une session Steam au démarrage de DroidDeck.</string>
<string name="mode_decky">Decky</string>
<string name="mode_decky_checking">Vérification des versions…</string>
<string name="mode_decky_ready">Prêt à installer %1$s.</string>
<string name="mode_decky_none">Aucune version compatible trouvée. Veuillez réessayer plus tard.</string>
<string name="mode_decky_installed">Installé · %1$s</string>
<string name="mode_decky_update">Mise à jour disponible · %1$s</string>
<string name="mode_decky_current">À jour · %1$s</string>
<string name="mode_decky_loader">Chargeur</string>
<string name="mode_decky_install_latest">Installer la dernière version</string>
<string name="mode_decky_on">Démarre avec Steam. Une fois activé, d\'autres applications sur cet appareil peuvent contrôler Steam.</string>
<string name="mode_decky_off">Désactivé. Le port de débogage distant de Steam reste fermé.</string>
<string name="mode_decky_remove_title">Désinstaller Decky Loader ?</string>
<string name="mode_decky_remove_text">Supprime le chargeur. Vos plugins et paramètres seront conservés.</string>
<string name="mode_client">Client</string>
<string name="mode_deck_mode">Mode Steam Deck</string>
<string name="mode_deck_mode_hint">Interface Steam Deck et contrôles de performance à accès rapide. Prendra effet à la prochaine session.</string>
<string name="mode_mangoapp">Superposition de performance</string>
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
<string name="mode_branch">Branche du client</string>
<string name="mode_branch_deck">Bêta Steam Deck (requis pour le mode Deck).</string>
<string name="mode_branch_hint">Prendra effet à la prochaine session. Steam peut se mettre à jour une fois de lui-même.</string>
<string name="mode_branch_public">Bêta</string>
<string name="mode_branch_deck_beta">Bêta Steam Deck</string>
<string name="mode_branch_note">Le mode Deck utilise toujours la version bêta de Steam Deck.</string>
<string name="mode_added_games">Jeux ajoutés</string>
<string name="mode_added_none">Aucun dossier de jeu contenant un .exe trouvé</string>
<string name="mode_games_folder">Dossier de jeux</string>
<string name="mode_games_folder_another">Autre dossier de jeux</string>
<string name="mode_added_art">Images depuis Steam</string>
<string name="mode_added_art_hint">Complète les images manquantes selon le nom du dossier. Les fichiers cover.jpg, hero.jpg, logo.png ou icon.png dans le dossier du jeu sont prioritaires.</string>
<string name="mode_added_launches">Lancer %1$s</string>
<string name="mode_added_launches_in">Lancer %1$s · dans %2$s</string>
<string name="mode_added_choose">Choisir un autre fichier…</string>
<string name="mode_added_exe_note">Utilise par défaut le .exe portant le même nom que le dossier, sinon le .exe le plus volumineux.</string>
<string name="mode_stretch169">Étirer les jeux 16:9</string>
<string name="mode_stretch169_hint">Sur les écrans dont le rapport d\'aspect est inférieur à 16:9, étire le jeu pour remplir l\'écran au lieu d\'afficher des bandes noires. Prendra effet à la prochaine session.</string>
<string name="mode_audio">Audio</string>
<string name="mode_directaudio">Utiliser DirectAudio pour le jeu</string>
<string name="mode_directaudio_hint">Contourne PulseAudio pour réduire la latence.</string>
<string name="mode_client_audio">Audio du client Steam</string>
<string name="mode_client_audio_hint">Prendra effet à la prochaine session.</string>
<string name="mode_client_audio_classic">Classique</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Microphone</string>
<string name="mode_mic_hint">Utilisé pour le chat vocal.</string>
<string name="mode_network">Réseau</string>
<string name="mode_wifi_discovery">Découverte Wi-Fi</string>
<string name="mode_wifi_discovery_hint">Affiche les noms Wi-Fi et les réseaux à proximité dans Steam.</string>
<string name="mode_wifi_location_off">Les services de localisation sont désactivés.</string>
<string name="mode_wifi_permission_denied">L\'autorisation de localisation n\'a pas été accordée.</string>
<string name="mode_wifi_open_settings">Ouvrir les paramètres</string>
<string name="mode_wifi_continue">Continuer</string>
<string name="mode_wifi_explain_title">Activer la découverte Wi-Fi ?</string>
<string name="mode_wifi_explain_text">Android a besoin de l\'autorisation de localisation pour lire les noms Wi-Fi et découvrir les réseaux à proximité. DroidDeck utilise cette autorisation pour afficher ces réseaux dans Steam. Nous ne suivons pas votre position. L\'accès à Internet reste possible sans cette autorisation.</string>
<string name="mode_storage">Stockage des jeux</string>
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible</string>
<string name="mode_storage_auto_none">Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement)</string>
<string name="mode_storage_internal">Stockage interne uniquement</string>
<string name="mode_storage_folder">Dossier : %1$s</string>
<string name="mode_second_library">Bibliothèque secondaire</string>
<string name="mode_storage_note">Les jeux lisant des ressources depuis une carte SD ou un stockage partagé peuvent saccader.</string>
<string name="mode_choose_folder">Choisir un dossier…</string>
<string name="mode_renderer">Rendu</string>
<string name="mode_desktop_renderer">Moteur de rendu du bureau</string>
<string name="mode_renderer_hint">Responsable du dessin du bureau.</string>
<string name="mode_renderer_vulkan">vulkan - GPU</string>
<string name="mode_renderer_gles2">gles2 - GPU (expérimental)</string>
<string name="mode_renderer_pixman">pixman - Logiciel</string>
<string name="mode_renderer_note">Vulkan utilise le GPU pour exécuter les programmes de bureau, et bascule vers pixman en cas d\'échec. Avec pixman, les jeux s\'exécutent toujours en plein écran sur le GPU.</string>
<string name="mode_custom_res_title">Résolution personnalisée</string>
<string name="mode_custom_res_text">Remplace la limite de résolution et le format d\'écran. Les dimensions ne correspondant pas à l\'écran afficheront des bandes noires.</string>
<string name="mode_width">Largeur</string>
<string name="mode_height">Hauteur</string>
<string name="mode_custom_res_range">Plage : 320×240 à 3840×2160.</string>
<plurals name="mode_added_count">
<item quantity="one">%1$d jeu</item>
<item quantity="other">%1$d jeux</item>
</plurals>
<string name="common_ok">OK</string>
<string name="common_delete">Supprimer</string>
<string name="common_restore">Restaurer</string>
<string name="common_download">Télécharger</string>
<string name="common_delete_named">Supprimer %1$s</string>
<string name="common_size_mb">%1$.1f Mo</string>
<string name="comp_runtime_driver">Pilote d\'environnement d\'exécution</string>
<string name="comp_runtime_driver_hint">Utilisé par Steam, les jeux et le bureau. Prendra effet à la prochaine session.</string>
<string name="comp_import_turnip">Importer Turnip zip…</string>
<string name="comp_display_driver">Pilote d\'affichage</string>
<string name="comp_display_driver_hint">Responsable de la sortie vidéo vers l\'écran dans les deux modes. Prendra effet après le redémarrage de l\'application.</string>
<string name="comp_import_adrenotools">Importer AdrenoTools zip…</string>
<string name="comp_never">Jamais</string>
<string name="comp_nightlies_checked">Nightlies vérifié le %1$s</string>
<string name="comp_title">Composants</string>
<string name="comp_about">À propos des composants</string>
<string name="comp_check_drivers">Vérifier les nouveaux pilotes</string>
<string name="comp_check_nightlies">Vérifier les nouveaux paquets dans Nightlies</string>
<string name="comp_proton">Proton</string>
<string name="comp_proton_note">Les mises à jour de Steam remplacent le Proton de Valve ; les fichiers originaux de chaque version sont conservés.</string>
<string name="comp_swapped">%1$s · %2$d remplacé(s)</string>
<string name="comp_all_original">%1$s · Tout est original</string>
<string name="comp_game_running_suffix">%1$s · Jeu en cours d\'exécution</string>
<string name="comp_gpu_drivers">Pilotes GPU</string>
<string name="comp_reapplied">%1$s · Réappliqué au lancement de %2$s</string>
<string name="comp_next_queued">Suivant : %1$s · Après la fermeture du jeu</string>
<string name="comp_in_use">%1$s en cours d\'utilisation : %2$s</string>
<string name="comp_waits">En attente de la fermeture du jeu</string>
<string name="comp_applies_next_game">Prendra effet au prochain lancement du jeu</string>
<string name="comp_import_zip">Importer .zip</string>
<string name="comp_reading">Lecture de Proton…</string>
<string name="comp_no_proton">Proton n\'est pas encore installé. Lancez Steam une fois pour télécharger son Proton ARM64, ou ajoutez GE-Proton ou CachyOS depuis les « Paramètres ».</string>
<string name="comp_original">Original · %1$s</string>
<string name="comp_this_build">%1$s · Version actuelle</string>
<string name="comp_earlier_build">%1$s · Version antérieure</string>
<string name="comp_installed_count">Installé · %1$d</string>
<string name="comp_nothing_stored">Rien n\'a encore été enregistré pour %1$s. Les fichiers fournis avec ce Proton seront enregistrés comme fichiers originaux.</string>
<string name="comp_waits_long">Un jeu utilise actuellement ce Proton, il faut donc attendre sa fermeture.</string>
<string name="comp_applies_long">Prendra effet au prochain lancement du jeu.</string>
<string name="comp_swap_title">Remplacer %1$s ?</string>
<string name="comp_swap_body">Placer %1$s dans %2$s ? Ses fichiers originaux continueront d\'être conservés. %3$s</string>
<string name="comp_restore_title">Restaurer %1$s ?</string>
<string name="comp_restore_body">Remettre les fichiers originaux de %1$s dans %2$s ? %3$s</string>
<string name="comp_delete_package_body">Supprime ce paquet de l\'application. Il pourra être téléchargé à nouveau ultérieurement.</string>
<string name="comp_delete_original_title">Supprimer cette version originale ?</string>
<string name="comp_delete_original_body">Cette version originale de %1$s provient d\'une version antérieure de %2$s. La version originale de la version actuelle sera toujours conservée.</string>
<string name="comp_nightlies_count">Dans Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Actualiser pour lister les paquets dans Nightlies.</string>
<string name="comp_nothing_new">Aucun nouvel élément depuis la dernière vérification.</string>
<string name="comp_import_wcp">Importer .wcp</string>
<string name="comp_restore_original">Restaurer les fichiers originaux de ce Proton</string>
<string name="comp_restore_original_body">Remettre les fichiers originaux %2$s de %1$s ?</string>
<string name="comp_swap">Basculer</string>
<string name="comp_about_text">FEX, DXVK et VKD3D-Proton : pour chaque Proton utilisé par Steam pour exécuter des jeux.\n\nSélectionnez un élément installé pour le remplacer. Cela prendra effet au prochain lancement du jeu, ou après la fermeture du jeu en cours d\'exécution.\n\nVotre sélection sera revérifiée avant chaque lancement ; si elle a été modifiée, elle sera restaurée. Les fichiers originaux de chaque Proton sont conservés, ils ne seront donc jamais perdus lors des mises à jour de Steam.</string>
<string name="comp_tag_original">Original</string>
<string name="comp_tag_in_use">Utilisé</string>
<string name="comp_tag_new">Nouveau</string>
<string name="comp_tag_stored">Enregistré</string>
<string name="perf_title">Performance</string>
<string name="perf_lede">Cœurs CPU, correctifs de session et nom d\'hôte. Prendra effet à la prochaine session.</string>
<string name="perf_client_cores_group">Cœurs du client Steam</string>
<string name="perf_override">Remplacer la sélection des cœurs de Steam</string>
<string name="perf_override_hint">Fixe Steam, l\'assistant d\'interface et gamescope sur les cœurs sélectionnés ci-dessous.</string>
<string name="perf_client_cores">Cœurs du client</string>
<string name="perf_client_cores_on">Cœurs utilisés par Steam.</string>
<string name="perf_client_cores_off">Activer le remplacement pour pouvoir sélectionner.</string>
<string name="perf_game_cores_group">Cœurs de jeu</string>
<string name="perf_game_cores">Cœurs de jeu</string>
<string name="perf_game_cores_hint">Cœurs utilisés par les jeux. Si tous sont sélectionnés, Android planifie librement.</string>
<string name="perf_client_ui">Interface du client</string>
<string name="perf_glthread">Multithread GL</string>
<string name="perf_glthread_hint">Peut rendre les menus Steam plus réactifs.</string>
<string name="perf_zink">Zink : descripteurs différés</string>
<string name="perf_zink_hint">Recommandé pour les pilotes sans tampon de descripteurs.</string>
<string name="perf_noglerror">Ignorer la vérification des erreurs GL</string>
<string name="perf_noglerror_hint">Désactive la validation GL par appel.</string>
<string name="perf_gsrealtime">gamescope : file d\'attente GPU temps réel</string>
<string name="perf_gsrealtime_hint">Augmente la priorité du travail GPU du compositeur. Peut améliorer la stabilité du temps de frame, mais consomme une partie des performances du jeu.</string>
<string name="perf_gpu">GPU</string>
<string name="perf_gpuclock">Maintenir le GPU à sa fréquence maximale</string>
<string name="perf_gpuclock_hint">Uniquement pour Adreno. Moins de saccades, mais plus de consommation d\'énergie et de chaleur.</string>
<string name="perf_fixes">Correctifs de session</string>
<string name="perf_sysmem">Turnip : rendu sysmem</string>
<string name="perf_sysmem_hint">Steam et la plupart des jeux seront plus rapides. Requis pour Adreno 710/720/722.</string>
<string name="perf_xalia">Ignorer l\'assistant xalia de Steam</string>
<string name="perf_xalia_hint">Désactive l\'assistant de navigation à la manette de Proton, qui consomme du CPU dans chaque jeu.</string>
<string name="perf_seccomp">Exécuter proot sans seccomp</string>
<string name="perf_seccomp_hint">Peut corriger certaines erreurs d\'appels système manquants sur certains noyaux, mais peut réduire les performances.</string>
<string name="perf_fastpath">Chemin rapide proot</string>
<string name="perf_fastpath_needs_seccomp">Nécessite le filtre seccomp de proot.</string>
<string name="perf_fastpath_hint">Recherche de fichiers plus rapide, moins de saccades. Désactivez cette option si un programme ne trouve pas ses fichiers.</string>
<string name="perf_identity">Identité de la session</string>
<string name="perf_hostname">Nom d\'hôte</string>
<string name="perf_hostname_hint">Le nom sous lequel Steam voit cet appareil. Laisser vide pour restaurer %1$s.</string>
<string name="perf_hostname_invalid">Ne peut contenir que des lettres, des chiffres et des tirets au milieu, jusqu\'à 63 caractères.</string>
<string name="perf_phantom_warning">Android est configuré pour terminer cette session</string>
<string name="driver_installed">Installé</string>
<string name="driver_available">Téléchargeable</string>
<string name="driver_nothing_new">Aucun nouvel élément depuis la dernière vérification.</string>
<string name="driver_restore_builtin">Restaurer le pilote intégré</string>
<string name="driver_delete_builtin">Pilote intégré à l\'application : cette opération supprimera les fichiers décompressés et masquera le pilote. Utilisez « Restaurer le pilote intégré » pour le restaurer.</string>
<string name="driver_delete_bundle">Moitié du bundle Android + Linux : les deux parties seront supprimées. Le pilote en cours d\'utilisation basculera vers le pilote par défaut.</string>
<string name="driver_delete_plain">Ses fichiers seront supprimés de l\'application.</string>
<string name="driver_delete_in_use">Ses fichiers seront supprimés. Comme il est en cours d\'utilisation, le pilote par défaut sera utilisé à la place.</string>
<string name="drawer_page_display">Affichage</string>
<string name="drawer_page_controls">Contrôles</string>
<string name="drawer_page_components">Composants</string>
<string name="drawer_page_settings">Paramètres</string>
<string name="drawer_page_desc">Page %1$s</string>
<string name="drawer_steam">Steam</string>
<string name="drawer_desktop">Bureau</string>
<string name="drawer_steam_menu">Menu Steam</string>
<string name="drawer_quick_access">Accès rapide</string>
<string name="drawer_open_qam">Ouvrir le menu d\'accès rapide</string>
<string name="drawer_prev_page">Page précédente</string>
<string name="drawer_next_page">Page suivante</string>
<string name="drawer_hud">HUD de performance</string>
<string name="drawer_touch_auto">Auto (%1$s)</string>
<string name="drawer_keyboard">Clavier</string>
<string name="drawer_pc_keyboard">Clavier PC</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Deuxième écran</string>
<string name="drawer_second_screen_shows">Afficher</string>
<string name="drawer_android_apps">Applications Android</string>
<string name="drawer_launch_app">Lancer l\'application</string>
<string name="common_hide">Masquer</string>
<string name="common_show">Afficher</string>
<string name="drawer_no_apps">Aucune application à lancer</string>
<string name="drawer_session_behavior">Comportement de la session</string>
<string name="drawer_suspend_hint">Appliqué immédiatement, et prendra effet pour les futures sessions de ce mode.</string>
<string name="drawer_next_session">Prochaine session</string>
<string name="drawer_support">Assistance</string>
<string name="drawer_logs">Journaux de session</string>
<string name="drawer_logs_hint">Joindre au rapport d\'erreur</string>
<string name="drawer_share_logs">Partager les journaux</string>
<string name="drawer_background">Arrière-plan</string>
<string name="drawer_stop">Arrêter</string>
<string name="drawer_stop_title">Arrêter la session ?</string>
<string name="drawer_running_now">En cours d\'exécution</string>
<string name="drawer_no_game">Aucun jeu n\'utilise actuellement Proton</string>
<string name="drawer_no_proton">Proton n\'est pas encore installé.</string>
<string name="drawer_game_running">Jeu en cours d\'exécution</string>
<string name="drawer_reapplied">Réappliqué au lancement de %1$s</string>
<string name="drawer_original">Original</string>
<string name="drawer_components_note">Veuillez télécharger, importer et supprimer depuis la page « Composants ».</string>
<string name="setup_check_done">Terminé</string>
<string name="setup_check_warn">À traiter</string>
<string name="setup_check_busy">Traitement en cours</string>
<string name="setup_limit_adb_failed">Veuillez vérifier l\'adresse IP et le port du débogage sans fil ; si Android a supprimé cet appareil, veuillez le réappairer.</string>
<string name="setup_install">Installer</string>
<string name="setup_manage">Gérer</string>
<string name="setup_tab_overview">Aperçu</string>
<string name="setup_tab_controller">Manette</string>
<string name="setup_tab_session">Session</string>
<string name="setup_tab_launcher">Lanceur</string>
<string name="setup_title">Paramètres</string>
<string name="setup_all_set">● Tout est prêt</string>
<string name="setup_n_ready">%1$d/%2$d prêt(s)</string>
<string name="setup_system_check">Vérification du système</string>
<string name="setup_gpu_supported">Appareil pris en charge</string>
<string name="setup_gpu_untested">GPU non testé</string>
<string name="setup_gpu_unsupported">GPU non pris en charge</string>
<string name="setup_gpu_detail">%1$s · %2$s</string>
<string name="setup_gpu_untested_detail">%1$s : %2$s</string>
<string name="setup_gpu_unsupported_detail">Steam nécessite un pilote Adreno ; %1$s peut afficher un écran noir.</string>
<string name="setup_runtime">Environnement d\'exécution Linux</string>
<string name="setup_runtime_progress">%1$s · %2$d%%</string>
<string name="setup_runtime_missing">Non installé · Environ 3 Go, installé lors du premier lancement d\'un jeu</string>
<string name="setup_installed">Installé</string>
<string name="setup_runtime_update">%1$s · Mise à jour disponible</string>
<string name="setup_runtime_current">%1$s · À jour</string>
<string name="setup_limit">Limite des processus enfants</string>
<string name="setup_limit_on">Activée · Android peut fermer Steam. La désactivation de cette limite prend environ une minute.</string>
<string name="setup_limit_unset">Non définie · Android peut fermer Steam. La désactivation de cette limite prend environ une minute.</string>
<string name="setup_limit_unknown">Impossible de vérifier · La désactivation de cette limite prend environ une minute.</string>
<string name="setup_fix_it">Corriger</string>
<string name="setup_details">Détails</string>
<string name="setup_dev_options">Options pour les développeurs</string>
<string name="setup_use_wireless">Utiliser le débogage sans fil</string>
<string name="setup_check_again">Vérifier à nouveau</string>
<string name="setup_limit_turn_on">Activer la limite</string>
<string name="setup_account">Compte Steam</string>
<string name="setup_signed_in_offline">Connecté en tant que %1$s · Mode hors ligne</string>
<string name="setup_signed_in">Connecté en tant que %1$s</string>
<string name="setup_sign_in">Appuyez sur Play et connectez-vous à Steam</string>
<string name="setup_tools">Outils</string>
<string name="setup_controller">Manette</string>
<string name="setup_controller_unavailable">Les paramètres de la manette ne sont pas disponibles.</string>
<string name="setup_session">Session</string>
<string name="setup_logs">Journaux de session</string>
<string name="setup_logs_hint">Enregistré après chaque fin de session</string>
<string name="setup_latest_logs">Derniers journaux de session</string>
<string name="setup_saved_logs">Journaux de session enregistrés</string>
<string name="setup_saved_logs_hint">Conserver les %1$d plus récents</string>
<string name="setup_clear_logs">Effacer les journaux</string>
<string name="setup_offline">Mode hors ligne</string>
<string name="setup_sign_in_first">Veuillez d\'abord vous connecter à Steam</string>
<string name="setup_launcher">Lanceur</string>
<string name="setup_theme">Thème</string>
<string name="setup_theme_hint">Apparence du lanceur</string>
<string name="setup_home">Utiliser comme écran d\'accueil</string>
<string name="setup_home_on">DroidDeck peut être utilisé comme application d\'écran d\'accueil</string>
<string name="setup_home_off">DroidDeck ne sera pas proposé comme application d\'écran d\'accueil</string>
<string name="setup_fullscreen">Plein écran</string>
<string name="setup_fullscreen_on">Masquer la barre d\'état et la barre de navigation Android</string>
<string name="setup_fullscreen_off">Afficher la barre d\'état et la barre de navigation Android</string>
<string name="setup_default_home">Application d\'écran d\'accueil par défaut</string>
<string name="setup_choose_home">Choisir l\'application d\'écran d\'accueil</string>
<string name="setup_choose">Choisir</string>
<string name="setup_linux_apps">Applications Linux (bêta)</string>
<string name="setup_store">Boutique Flathub</string>
<string name="setup_store_on">Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck.</string>
<string name="setup_store_off">Installer des applications et des jeux Linux depuis Flathub</string>
<string name="setup_tool_files">Fichiers</string>
<string name="setup_tool_files_hint">Parcourir et gérer les fichiers</string>
<string name="setup_tool_protons">Versions de Proton</string>
<string name="setup_tool_protons_hint">Installer les versions ARM64 de Proton</string>
<string name="setup_tool_performance">Performance</string>
<string name="setup_tool_performance_hint">Attribution des cœurs CPU</string>
<string name="setup_tool_roms">Dossiers ROM</string>
<string name="setup_tool_roms_hint">Emplacement de stockage des jeux d\'émulateur</string>
<string name="setup_card_components">Composants</string>
<string name="setup_card_components_hint">FEX, DXVK, VKD3D</string>
<string name="setup_card_controls">Contrôles</string>
<string name="setup_card_controls_hint">Mappage des boutons</string>
<string name="store_tab_discover">Découvrir</string>
<string name="store_tab_search">Rechercher</string>
<string name="store_tab_installed">Installé</string>
<string name="store_tab_count">%1$s · %2$d</string>
<string name="store_title">Boutique</string>
<string name="store_chip_flathub">Flathub · ARM64</string>
<string name="store_chip_ready">● Flatpak prêt</string>
<string name="store_needs_runtime">Veuillez d\'abord installer l\'environnement d\'exécution Linux depuis les « Paramètres ».</string>
<string name="store_setup_title">Configurer Flatpak</string>
<string name="store_setup_text">Ajouter Flatpak et se connecter à Flathub (environ 20 Mo). La première application téléchargera également son environnement d\'exécution.</string>
<string name="store_setting_up">Configuration en cours…</string>
<string name="store_flatpak">Flatpak</string>
<string name="store_updates">Mises à jour</string>
<string name="store_busy">%1$s · %2$s</string>
<string name="store_busy_percent">%1$s · %2$s · %3$d%%</string>
<string name="store_starting">Démarrage…</string>
<string name="store_categories">Catégories</string>
<string name="store_unreachable">Impossible de se connecter à Flathub. Veuillez vérifier votre connexion réseau et réessayer.</string>
<string name="store_try_again">Réessayer</string>
<string name="store_search_label">Rechercher sur Flathub</string>
<string name="store_search_placeholder">Firefox, jeux, émulateurs…</string>
<string name="store_search">Rechercher</string>
<string name="store_all">Tout</string>
<string name="store_searching">Recherche en cours…</string>
<string name="store_search_hint">Recherchez par nom ou sélectionnez une catégorie. Seules les applications ARM64 sont répertoriées ici.</string>
<string name="store_no_results">Aucune application ARM64 correspondante sur Flathub.</string>
<string name="store_results">Résultats</string>
<string name="store_check_updates">Vérifier les mises à jour</string>
<string name="store_update_all">Tout mettre à jour (%1$d)</string>
<string name="store_all_current">● Tout est à jour</string>
<string name="store_no_apps">Aucune application pour le moment. Recherchez dans « Découvrir » ou « Rechercher ».</string>
<string name="store_setup_first_note">Configurez Flatpak pour pouvoir installer des applications.</string>
<string name="store_update_available_prefix">Mise à jour disponible · %1$s</string>
<string name="store_open">Ouvrir</string>
<string name="store_details">Détails</string>
<string name="store_remove">Supprimer</string>
<string name="store_press_again">Appuyez à nouveau pour supprimer</string>
<string name="store_installing">Installation…</string>
<string name="store_install">Installer</string>
<string name="store_setup_first">Configurer d\'abord Flatpak</string>
<string name="store_download_size">Télécharger %1$s</string>
<string name="store_installed_chip">● Installé</string>
<string name="store_details_failed">Impossible de charger la page Flathub de cette application.</string>
<string name="store_loading">Chargement depuis Flathub…</string>
<string name="store_no_arm">Flathub ne propose pas de version ARM64, l\'installation est donc impossible ici.</string>
<string name="store_screenshots">Captures d\'écran</string>
<string name="store_about">À propos</string>
<string name="store_app_id">ID de l\'application</string>
<string name="store_license">Licence</string>
<string name="store_runtime">Environnement d\'exécution</string>
<string name="store_installed_size">Taille installée</string>
<string name="store_installed_size_value">%1$s (plus l\'environnement d\'exécution partagé)</string>
<string name="store_website">Site web</string>
<string name="store_verified">Vérifié</string>
<string name="upd_title">Mises à jour</string>
<string name="upd_build">DroidDeck %1$s · %2$s</string>
<string name="upd_checked">Vérifié le %1$s</string>
<string name="upd_not_checked">Non vérifié</string>
<string name="upd_perm_title">Autoriser DroidDeck à se mettre à jour</string>
<string name="upd_perm_text">Activez « Autoriser depuis cette source » sur la page suivante, puis revenez. La mise à jour continuera automatiquement.</string>
<string name="upd_open_settings">Ouvrir les paramètres</string>
<string name="upd_not_now">Pas maintenant</string>
<string name="upd_signed_status">Signature différente</string>
<string name="upd_signed_headline">Impossible de mettre à jour sur place</string>
<string name="upd_signed_detail">Cette copie n\'est pas signée avec la clé de publication de DroidDeck, elle ne peut donc pas être mise à jour par écrasement direct. Veuillez désinstaller puis basculer.</string>
<string name="upd_available">Mise à jour disponible</string>
<string name="upd_blocked_status">Installation impossible</string>
<string name="upd_switch">Basculer possible</string>
<string name="upd_ahead_status">En avance sur Stable</string>
<string name="upd_ahead_headline">Vous êtes en avance sur Stable</string>
<string name="upd_ahead_detail">Cette version est plus récente que la dernière Stable. Vous basculerez sur Stable après la publication de la prochaine version Stable.</string>
<string name="upd_gone_status">Test terminé</string>
<string name="upd_gone_headline">Ce test est terminé</string>
<string name="upd_gone_detail">Ce correctif a été fusionné ou abandonné. Suivez Preview pour obtenir les derniers correctifs.</string>
<string name="upd_current_status">À jour</string>
<string name="upd_current_headline">Vous disposez de la dernière version de %1$s</string>
<string name="upd_dot_ago">· %1$s</string>
<string name="upd_stage_percent">%1$s… %2$d%%</string>
<string name="upd_stage">%1$s…</string>
<string name="upd_update_button">Mettre à jour %1$s</string>
<string name="upd_install_button">Installer %1$s</string>
<string name="upd_install_anyway">Installer Stable %1$s quand même</string>
<string name="upd_follow_preview">Suivre Preview</string>
<string name="upd_stop_session">Veuillez arrêter la session en cours avant de mettre à jour.</string>
<string name="upd_less">Réduire</string>
<string name="upd_more">Plus</string>
<string name="upd_new_stable">DroidDeck %1$s</string>
<string name="upd_new_preview">Nouvelle version Preview</string>
<string name="upd_new_test">Version de test PR #%1$d</string>
<string name="upd_mb">%1$d Mo</string>
<string name="upd_channel">Canal de mise à jour</string>
<string name="upd_stable">Stable</string>
<string name="upd_stable_hint">Version testée, adaptée à la plupart des utilisateurs</string>
<string name="upd_version_ago">%1$s · %2$s</string>
<string name="upd_preview">Preview</string>
<string name="upd_preview_hint">Derniers correctifs avant Stable</string>
<string name="upd_preview_current">À jour</string>
<string name="upd_tests">Versions de test</string>
<string name="upd_tests_hint">Essayer un correctif avant sa publication officielle</string>
<string name="upd_tests_none">Aucun pour le moment</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Non compilé pour cette copie de DroidDeck</string>
<string name="upd_updated">%1$s mis à jour</string>
<string name="upd_test_name">Version de test PR #%1$d</string>
<string name="upd_while_ago">Il y a peu</string>
<string name="upd_just_now">À l\'instant</string>
<plurals name="upd_tests_count">
<item quantity="one">%1$d disponible</item>
<item quantity="other">%1$d disponibles</item>
</plurals>
<plurals name="upd_preview_behind">
<item quantity="one">Modifications depuis votre version · %1$d version de retard</item>
<item quantity="other">Modifications depuis votre version · %1$d versions de retard</item>
</plurals>
<plurals name="upd_preview_recent">
<item quantity="one">Modifications récentes · %1$d version</item>
<item quantity="other">Modifications récentes · %1$d versions</item>
</plurals>
<string name="gate_title">Un paramètre Android à modifier</string>
<string name="gate_eyebrow">Steam</string>
<string name="gate_lede">Android arrête les applications qui lancent de nombreux processus en arrière-plan, et Steam en lance des dizaines. Tant que ce paramètre n\'est pas modifié, les jeux peuvent se fermer sans message d\'erreur.</string>
<string name="gate_fix_for_me">Corriger pour moi</string>
<string name="gate_toggle_steps">Activez « Désactiver la limite de processus enfants », puis revenez. Gardez les options pour les développeurs activées, sinon l\'interrupteur se désactivera à nouveau.</string>
<string name="gate_open_dev">Ouvrir les options pour les développeurs</string>
<string name="gate_no_toggle">Cette version d\'Android ne dispose pas de cet interrupteur, DroidDeck effectuera donc la modification via le débogage sans fil. Cela prend environ une minute, sans ordinateur.</string>
<string name="gate_needs_wifi">Le débogage sans fil nécessite un Wi-Fi. N\'importe quel réseau convient, Internet n\'est pas nécessaire.</string>
<string name="gate_needs_notifications">Le code d\'appairage s\'affichera dans une notification. Veuillez autoriser DroidDeck à envoyer des notifications, ou utilisez « Autre méthode ».</string>
<string name="gate_allow_notifications">Autoriser les notifications</string>
<string name="gate_step1">Ouvrez Paramètres → Options pour les développeurs → Débogage sans fil, et activez-le</string>
<string name="gate_step2">Appuyez sur « Associer l\'appareil avec un code d\'appairage »</string>
<string name="gate_step3">Saisissez le code d\'appairage dans la notification DroidDeck</string>
<string name="gate_waiting">En attente de la fenêtre d\'appairage…</string>
<string name="gate_code_needed">Fenêtre trouvée. Veuillez saisir le code d\'appairage affiché dans la notification.</string>
<string name="gate_status">État</string>
<string name="gate_polling">Vérification toutes les 2 secondes. Cette page se fermera automatiquement une fois cet interrupteur désactivé.</string>
<string name="gate_other_ways">Autres méthodes</string>
<string name="gate_other_toggle">Débogage sans fil, saisie manuelle de l\'adresse, ADB sur PC</string>
<string name="gate_other_no_toggle">Saisie manuelle de l\'adresse, ADB sur PC, options pour les développeurs</string>
<string name="gate_use_wireless_instead">Utiliser plutôt le débogage sans fil</string>
<string name="gate_some_roms">Certaines ROM proposent encore cet interrupteur. Recherchez « processus enfants » dans les options pour les développeurs.</string>
<string name="gate_manual_hint">Si la méthode par notification ne fonctionne pas, saisissez manuellement l\'adresse et le code d\'appairage de la fenêtre.</string>
<string name="gate_enter_manually">Saisir l\'adresse manuellement</string>
<string name="gate_from_computer">Utiliser ADB depuis un ordinateur :</string>
<string name="gate_copy_command">Copier la commande</string>
<string name="gate_choose_display">Choisissez l\'écran sur lequel ouvrir les paramètres Android.</string>
<string name="gate_main_screen">Écran principal</string>
<string name="wadb_finding_port">Appairé. Recherche du port de débogage sans fil…</string>
<string name="wadb_enter_port">Appairé. Veuillez saisir le port indiqué dans les paramètres.</string>
<string name="wadb_applying">Appareil trouvé. Application des paramètres…</string>
<string name="wadb_apply_failed">Impossible d\'appliquer automatiquement. Veuillez vérifier l\'adresse ci-dessous et réessayer. %1$s</string>
<string name="wadb_title_wireless">Débogage sans fil</string>
<string name="wadb_title_done">Limite des processus enfants mise à jour</string>
<string name="wadb_title">Modifier la limite des processus enfants</string>
<string name="wadb_lede_compact0">Si cette option n\'est pas disponible, appairez via le débogage sans fil.</string>
<string name="wadb_lede_compact1">Appairé. Veuillez vérifier l\'adresse IP et le port dans le débogage sans fil.</string>
<string name="wadb_lede_done">La limite des processus enfants a été mise à jour.</string>
<string name="wadb_lede0">Si l\'option de processus enfants n\'est pas disponible dans les options pour les développeurs, utilisez l\'étape 2. Ouvrez Options pour les développeurs → Débogage sans fil → Associer l\'appareil avec un code d\'appairage.</string>
<string name="wadb_lede1">Appairé. Utilisez l\'adresse ci-dessous, ou mettez-la à jour dans Débogage sans fil → Adresse IP et port.</string>
<string name="wadb_lede_done_long">Limite des processus enfants mise à jour via le débogage sans fil.</string>
<string name="wadb_confirmed_on">Android a confirmé que la limite est activée.</string>
<string name="wadb_confirmed_off">Android a confirmé que la limite est désactivée.</string>
<string name="wadb_pair_group">2 · Appairer le débogage sans fil</string>
<string name="wadb_pair_address">Adresse IP et port dans la fenêtre d\'appairage</string>
<string name="wadb_pin">Code d\'appairage (PIN)</string>
<string name="wadb_pin_placeholder">6 chiffres</string>
<string name="wadb_pair">Appairer</string>
<string name="wadb_apply_group">2 · Appliquer via le débogage sans fil</string>
<string name="wadb_connect_address">Adresse IP et port de connexion</string>
<string name="wadb_pair_again">Réappairer</string>
<string name="wadb_turn_off">Désactiver la limite</string>
<string name="wadb_other_ways">Autres méthodes de modification</string>
<string name="wadb_dev_try_first">1 · Options pour les développeurs (à essayer en premier)</string>
<string name="wadb_restrict_on">Activez « Limiter les processus enfants ».</string>
<string name="wadb_restrict_off">Désactivez « Limiter les processus enfants ».</string>
<string name="wadb_computer_last">3 · ADB sur PC (en dernier recours)</string>
<string name="wadb_only_if">À utiliser uniquement si le débogage sans fil n\'est pas disponible.</string>
<string name="wadb_computer_group">3 · ADB sur PC (en dernier recours)</string>
<string name="ctrl_osc_hint">Lors de l\'affichage des commandes à l\'écran en session</string>
<string name="ctrl_color">Couleur</string>
<string name="ctrl_color_hint">Teinte des boutons à l\'écran</string>
<string name="ctrl_custom">Personnalisée</string>
<string name="ctrl_opacity">Opacité</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Taille des boutons</string>
<string name="ctrl_size_hint" formatted="false">100 % correspond à la taille standard</string>
<string name="ctrl_stick_click">Clic du joystick</string>
<string name="ctrl_stick_click_hint">Double-cliquez et maintenez le joystick pour déclencher L3 ou R3</string>
<string name="ctrl_adaptive">Joystick adaptatif</string>
<string name="ctrl_rumble">Vibration du jeu</string>
<string name="ctrl_steam_button">Bouton Steam</string>
<string name="ctrl_qam_button">Bouton QAM</string>
<string name="ctrl_keyboard_button">Bouton clavier</string>
<string name="setup_animations">Animations</string>
<string name="ctrl_adaptive_hint">Le joystick apparaîtra là où vous touchez et se masquera une fois relâché</string>
<string name="ctrl_layout">Disposition</string>
<string name="ctrl_layout_custom">Position personnalisée enregistrée</string>
<string name="ctrl_layout_default">Adapté à cet écran</string>
<string name="ctrl_edit">Modifier</string>
<string name="ctrl_reset">Réinitialiser</string>
<string name="ctrl_mapping">Mappage des boutons</string>
<string name="ctrl_mapping_default">Chaque bouton envoie l\'entrée d\'origine correspondante</string>
<string name="ctrl_mapping_count">%1$d/%2$d boutons remappés</string>
<string name="ctrl_configure">Configurer</string>
<string name="ctrl_reset_all">Réinitialiser la manette</string>
<string name="ctrl_reset_all_hint">Restaurer tous les paramètres et dispositions de la manette</string>
<string name="ctrl_mapping_lede">Définissez l\'entrée que chaque bouton à l\'écran envoie au jeu. « Masquer » supprimera ce bouton.</string>
<string name="ctrl_onscreen_buttons">Boutons à l\'écran</string>
<string name="ctrl_defaults">Valeurs par défaut</string>
<string name="ctrl_reset_mapping">Réinitialiser le mappage</string>
<string name="content_android_apps">Applications Android</string>
<string name="content_apps_count">%1$d applications</string>
<string name="content_no_android_apps">Aucune application Android lançable trouvée.</string>
<string name="content_desktop_installed">● Bureau installé</string>
<string name="content_desktop_first_open">Installé à la première ouverture</string>
<string name="content_available">Installable</string>
<string name="content_not_installed">Non installé.</string>
<string name="content_not_installed_suffix">%1$s · Non installé</string>
<string name="content_open_named">Ouvrir %1$s</string>
<string name="content_games">Jeux</string>
<string name="content_choose_roms">Sélectionnez un dossier ROM.</string>
<string name="content_retroarch_roms">Dans RetroArch, naviguez vers /root/ROMs.</string>
<string name="content_add_roms">Ajout de %1$s jeux à ROMs/%2$s.</string>
<string name="content_install_named">Installer %1$s</string>
<string name="content_stop_to_install">Arrêter la session pour installer</string>
<string name="content_runtime_required">Environnement d\'exécution requis</string>
<string name="content_loading_details">Chargement des détails d\'installation…</string>
<string name="content_details_unavailable">Détails d\'installation non disponibles. Veuillez réessayer plus tard.</string>
<string name="content_needs_runtime">Veuillez d\'abord installer l\'environnement d\'exécution Linux depuis les « Paramètres ».</string>
<string name="content_stop_first">Veuillez arrêter la session en cours avant d\'installer une application de bureau.</string>
<string name="content_install_into">Installer %1$s sur le bureau Linux.</string>
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
<string name="content_hide_note">Masquer le retirera du bureau. Les fichiers resteront dans l\'environnement d\'exécution Linux.</string>
<string name="content_rom_gone">Ce jeu n\'est plus dans le dossier ROM.</string>
<string name="content_desktop_system">Bureau · %1$s</string>
<string name="content_launch_in">Lancer dans %1$s</string>
<string name="content_also_in">Également dans %1$s</string>
<string name="content_page_gone">Cette page n\'existe plus. Veuillez sélectionner une section à gauche.</string>
<string name="content_open_desktop">Ouvrir le bureau</string>
<string name="content_install_open_desktop">Installer et ouvrir le bureau</string>
<string name="content_linux_desktop">Bureau Linux</string>
<string name="content_linux_desktop_hint">LXQt, Firefox et vos émulateurs</string>
<string name="games_empty">Les jeux installés sur Steam apparaîtront ici après la fin de la session.</string>
<string name="games_play_steam">Lancer Steam</string>
<string name="games_launch_settings">Paramètres de lancement</string>
<string name="games_launch">Lancer</string>
<string name="games_files">Fichiers du jeu</string>
<string name="games_prefix">Préfixe Proton</string>
<string name="games_saves_looking">Recherche des sauvegardes…</string>
<string name="games_saves_none">Aucun dossier de sauvegarde trouvé par nom. L\'exportation inclura l\'intégralité du dossier utilisateur.</string>
<string name="games_saves_folders">%1$d dossiers de sauvegarde</string>
<string name="games_saves_summary">%1$s · %2$d fichiers · %3$s</string>
<string name="games_kb">%1$d Ko</string>
<string name="games_manage_saves">Gérer les sauvegardes</string>
<string name="games_saves">Sauvegardes de jeu</string>
<string name="games_import_saves">Importer des sauvegardes…</string>
<string name="games_import_saves_hint">Archive GameHub ou Winlator. Les sauvegardes actuelles seront sauvegardées avant l\'importation.</string>
<string name="games_export_gamehub">Exporter en tant qu\'archive GameHub…</string>
<string name="games_export_gamehub_hint">Compatible avec GameHub et BannerHub</string>
<string name="games_export_winlator">Exporter en tant qu\'archive Winlator…</string>
<string name="games_export_winlator_hint">Compatible avec Winlator, WinNative et Bannerlator</string>
<string name="games_open_save_folder">Ouvrir le dossier de sauvegarde</string>
<string name="games_never_played">Jamais lancé</string>
<string name="games_runtime_busy_percent">Environnement d\'exécution occupé · %1$d%%</string>
<string name="games_runtime_busy">Environnement d\'exécution occupé</string>
<string name="games_runtime_first_play">Installation de l\'environnement d\'exécution lors du premier lancement du jeu</string>
<string name="games_runtime_update">Mise à jour de l\'environnement d\'exécution disponible</string>
<string name="games_runtime_ready">● Environnement d\'exécution prêt</string>
<string name="games_last_played">Dernier lancement %1$s</string>
<string name="games_internal">Stockage interne</string>
<string name="games_added">Jeux ajoutés</string>
<string name="gpu_auto_hint">Sélectionne la meilleure combinaison de pilotes pour ce GPU et les maintient à jour</string>
<string name="gpu_manual_hint">Choisir manuellement la combinaison de pilotes</string>
<string name="gpu_checking">Vérification des pilotes…</string>
<string name="gpu_none_listed">Aucun pilote répertorié pour le moment. Actualisez pour rechercher des pilotes.</string>
<string name="gpu_hide_other">Masquer les pilotes des autres GPU</string>
<string name="gpu_show_other">Afficher les pilotes des autres GPU</string>
<string name="gpu_advanced">Avancé</string>
<string name="gpu_set_with_display">Configuré avec le pilote d\'affichage : %1$s inclut les deux.</string>
<string name="gpu_set_with_runtime">Configuré avec le pilote d\'exécution : %1$s inclut les deux.</string>
<string name="gpu_runtime_hint">Utilisé par les jeux et le bureau.</string>
<string name="gpu_runtime_default">Par défaut de l\'environnement d\'exécution</string>
<string name="gpu_display_hint">Responsable de la sortie vidéo vers l\'écran. Prendra effet après le redémarrage de DroidDeck.</string>
<string name="gpu_auto_by_gpu">Auto - Sélection par GPU</string>
<string name="gpu_manage">Télécharger, importer ou supprimer…</string>
<string name="gpu_in_use_caps">Utilisé</string>
<string name="gpu_builtin">Pilote intégré à DroidDeck</string>
<string name="gpu_matched_pair">Le pilote d\'exécution et le pilote d\'affichage sont configurés ensemble en tant que paire correspondante.</string>
<string name="gpu_recommended_for">Recommandé pour ce GPU : %1$s.</string>
<string name="gpu_refresh_hint">Actualisez pour rechercher les pilotes adaptés à ce GPU.</string>
<string name="gpu_recommended_caps">Recommandé</string>
<string name="gpu_unavailable">Non disponible</string>
<string name="load_runtime">Environnement d\'exécution Linux</string>
<string name="load_runtime_detail">Préparation de l\'environnement d\'exécution Linux</string>
<string name="load_session">Session</string>
<string name="load_session_detail">Démarrage de l\'affichage et de l\'audio</string>
<string name="load_steam">Client Steam</string>
<string name="load_steam_detail">Vérification de Steam et de ses outils de compatibilité</string>
<string name="load_opening_steam">Ouverture de Steam</string>
<string name="load_opening_steam_detail">En attente de la première image de Steam</string>
<string name="load_desktop">Bureau</string>
<string name="load_desktop_detail">Préparation du bureau</string>
<string name="load_opening">Ouverture</string>
<string name="load_opening_detail">En attente de la première image</string>
<string name="load_starting_steam">Démarrage de Steam</string>
<string name="load_ended">Session terminée</string>
<string name="load_step">Étape %1$d sur %2$d</string>
<string name="load_steam_paused">Steam en pause</string>
<string name="deck_tab_grips">Poignées</string>
<string name="deck_tab_left">Pavé tactile gauche</string>
<string name="deck_tab_right">Pavé tactile droit</string>
<string name="deck_tab_both">Les deux</string>
<string name="deck_close">Fermer les commandes du deuxième écran</string>
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
<string name="deck_left_upper">Gauche · Haut</string>
<string name="deck_left_lower">Gauche · Bas</string>
<string name="deck_right_upper">Droite · Haut</string>
<string name="deck_right_lower">Droite · Bas</string>
<string name="deck_click">Cliquer</string>
<string name="deck_right_trackpad">Pavé tactile droit</string>
<string name="deck_left_trackpad">Pavé tactile gauche</string>
<string name="second_trackpad">Pavé tactile</string>
<string name="second_show_keyboard">Afficher le clavier</string>
<string name="second_hide_keyboard">Masquer le clavier</string>
<string name="second_terminal">Terminal Linux</string>
<string name="second_terminal_not_ready">La session Linux n\'est pas prête, le terminal ne peut pas être utilisé.</string>
<string name="rail_steam">Steam</string>
<string name="rail_games">Jeux</string>
<string name="rail_desktop">Bureau</string>
<string name="rail_store">Boutique</string>
<string name="rail_components">Composants</string>
<string name="rail_apps">Applications</string>
<string name="rail_setup">Configuration</string>
<string name="rail_updates">Mises à jour</string>
<string name="rail_percent">%1$d%%</string>
<string name="rail_resume_named">Reprendre %1$s</string>
<string name="roms_text">Affiché comme /root/ROMs dans chaque session. Le stockage interne se trouve dans /root/Storage.</string>
<string name="roms_none">Aucun dossier sélectionné</string>
<string name="roms_applies">Prendra effet à la prochaine session. Prend en charge les cartes SD.</string>
<string name="roms_choose">Choisir un dossier</string>
<string name="roms_change">Modifier le dossier</string>
<string name="proton_lede">Installez une version ARM64 de Proton, puis sélectionnez-la pour chaque jeu dans Steam → Propriétés → Compatibilité.</string>
<string name="proton_stop_first">Veuillez arrêter la session en cours avant d\'installer ou de supprimer des outils de compatibilité.</string>
<string name="proton_available">Versions disponibles</string>
<string name="proton_installed">%1$s installé</string>
<string name="proton_queued">Élément en attente d\'une demande précédente</string>
<string name="proton_not_installed">Non installé</string>
<string name="proton_install_now">Installer maintenant</string>
<string name="proton_cancel_queued">Annuler la file d\'attente</string>
<string name="pckb_android">Clavier Android</string>
<string name="pckb_hide">Masquer ✕</string>
<string name="main_launch_named">Lancer %1$s</string>
<string name="main_choose_screen">Choisir l\'écran. Écran secondaire : %1$s.</string>
<string name="main_secondary_gone">L\'écran secondaire n\'est plus disponible.</string>
<string name="main_primary">Écran principal</string>
<string name="main_secondary">Écran secondaire</string>
<string name="widgets_prev_tab">Onglet précédent</string>
<string name="widgets_next_tab">Onglet suivant</string>
<string name="widgets_on">Activé</string>
<string name="widgets_all_n">Tout %1$d</string>
<string name="widgets_none">Aucun</string>
<string name="widgets_n_of_m">%1$d/%2$d</string>
<string name="widgets_off">Désactivé</string>
<string name="screen_bottom">Écran inférieur</string>
<string name="game_env_applies">Prendra effet au prochain lancement du jeu. Le profil du jeu remplacera la configuration partagée.</string>
<string name="game_env_other">Sélectionner un autre jeu par ID d\'application…</string>
<string name="game_env_done">Terminé</string>
<string name="game_env_default">Par défaut</string>
<string name="game_env_off">Désactivé</string>
<string name="game_env_change">Modifier…</string>
<string name="game_env_change_title">Modifier la variable</string>
<string name="game_env_restore_default">Restaurer les valeurs par défaut</string>
<string name="game_env_eyebrow">Jeux Proton</string>
<string name="pip_title">Picture-in-Picture (PiP)</string>
<string name="pip_auto">Passer automatiquement en PiP</string>
<string name="pip_suspended">En pause</string>
<string name="pip_not_ready">Le mode PiP sera disponible une fois la session prête.</string>
<string name="pip_permission">Veuillez autoriser DroidDeck à utiliser le mode PiP dans les paramètres Android.</string>
<string name="pip_unavailable">Android ne peut pas ouvrir le mode PiP.</string>
<string name="game_shortcut">Raccourci</string>
<string name="game_shortcut_add">Ajouter à l\'écran d\'accueil</string>
<string name="game_shortcut_choose">Choisir un jeu</string>
<string name="game_shortcut_scanning">Analyse des jeux installés…</string>
<string name="game_link_copy">Copier le lien de lancement</string>
<string name="game_link_copied">Lien de lancement copié</string>
<string name="game_shortcut_unsupported">Ce lanceur ne peut pas ajouter de raccourci. Utilisez « Copier le lien de lancement » à la place.</string>
<string name="game_link_invalid">Lien de lancement de jeu invalide.</string>
<string name="game_link_missing">Ce jeu n\'est plus dans votre bibliothèque DroidDeck.</string>
<string name="game_link_session_busy">Veuillez arrêter la session en cours avant de lancer un jeu Steam.</string>
<string name="game_link_failed">Impossible d\'envoyer le jeu à Steam. Veuillez réessayer de le lancer.</string>
<string name="setup_retry_removal">Réessayer la suppression</string>
<string name="runtime_removing">Suppression…</string>
<string name="runtime_removal_incomplete">Suppression incomplète</string>
<string name="game_file_export">Exporter les fichiers du jeu</string>
<string name="game_file_sync">Synchroniser tous les fichiers du jeu dans un dossier</string>
<string name="game_file_stop_sync">Arrêter la synchronisation des fichiers du jeu</string>
<string name="game_file_folder">Choisir un dossier pour les fichiers du jeu frontal</string>
<string name="game_file_exported">Fichiers du jeu enregistrés dans %1$s</string>
<string name="game_frontend_files">Fichiers frontaux</string>
</resources>
-3
View File
@@ -279,8 +279,6 @@
<string name="mode_added_launches_in">%1$sを起動 · %2$s内</string>
<string name="mode_added_choose">別のファイルを選択…</string>
<string name="mode_added_exe_note">デフォルトではフォルダと同じ名前の.exeを使用し、ない場合は最大の.exeを使用します。</string>
<string name="mode_fill">ゲームを引き伸ばして画面を埋める</string>
<string name="mode_fill_hint">自身でサイズ変更できるゲームを全画面に保ちます。ゲームが小さく表示される場合はこれをオフにしてください。次回のセッションで有効になります。</string>
<string name="mode_stretch169">16:9ゲームを引き伸ばす</string>
<string name="mode_stretch169_hint">アスペクト比が16:9未満の画面で、黒帯を表示する代わりにゲームを引き伸ばして画面を埋めます。次回のセッションで有効になります。</string>
<string name="mode_audio">オーディオ</string>
@@ -442,7 +440,6 @@
<string name="drawer_prev_page">前のページ</string>
<string name="drawer_next_page">次のページ</string>
<string name="drawer_hud">パフォーマンスHUD</string>
<string name="drawer_fill">ゲームを引き伸ばして画面を埋める</string>
<string name="drawer_touch_auto">自動(%1$s)</string>
<string name="drawer_keyboard">キーボード</string>
<string name="drawer_pc_keyboard">PCキーボード</string>
-3
View File
@@ -279,8 +279,6 @@
<string name="mode_added_launches_in">%1$s 실행 · %2$s에 위치</string>
<string name="mode_added_choose">다른 파일 선택…</string>
<string name="mode_added_exe_note">기본적으로 폴더와 이름이 같은 .exe를 사용하고, 없으면 가장 큰 .exe를 사용합니다.</string>
<string name="mode_fill">게임을 늘려 화면 채우기</string>
<string name="mode_fill_hint">자체적으로 크기를 조절하는 게임을 전체 화면으로 유지합니다. 게임이 작게 표시되면 이 옵션을 끄세요. 다음 세션에 적용됩니다.</string>
<string name="mode_stretch169">16:9 게임 늘리기</string>
<string name="mode_stretch169_hint">가로세로 비율이 16:9 미만인 화면에서 검은색 여백 대신 게임을 늘려 화면을 채웁니다. 다음 세션에 적용됩니다.</string>
<string name="mode_audio">오디오</string>
@@ -442,7 +440,6 @@
<string name="drawer_prev_page">이전 페이지</string>
<string name="drawer_next_page">다음 페이지</string>
<string name="drawer_hud">성능 HUD</string>
<string name="drawer_fill">게임을 늘려 화면 채우기</string>
<string name="drawer_touch_auto">자동(%1$s)</string>
<string name="drawer_keyboard">키보드</string>
<string name="drawer_pc_keyboard">PC 키보드</string>
+957
View File
@@ -0,0 +1,957 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">DroidDeck</string>
<string-array name="loading_hints">
<item>При первом запуске будет загружен Steam. Это может занять несколько минут.</item>
<item>По умолчанию при выходе из приложения Steam приостанавливается; при возвращении работа возобновляется с того же места.</item>
<item>Ваш контроллер будет отображаться как геймпад Xbox 360. В противном случае используйте экранные элементы управления.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>При первом запуске будет настроен рабочий стол. Это может занять несколько минут.</item>
<item>По умолчанию при выходе из приложения сессия приостанавливается; при возвращении работа возобновляется с того же места.</item>
<item>Сенсорный ввод работает как тачпад: перетаскивайте для перемещения курсора, нажимайте для клика.</item>
<item>Ваш контроллер будет отображаться как геймпад Xbox 360. В противном случае используйте экранные элементы управления.</item>
</string-array>
<string name="session_channel">Сессия</string>
<string name="session_channel_description">Отображается при открытии сессии Steam или рабочего стола</string>
<string name="session_notification">Steam запущен</string>
<string name="session_notification_paused">Сессия приостановлена - нажмите, чтобы продолжить</string>
<string name="resume_session">Продолжить</string>
<string name="stop_session">Завершить сессию</string>
<string name="frame_gen_title">Генерация кадров</string>
<string name="frame_gen_hint">Выберите режим генерации кадров</string>
<string name="frame_gen_off">Выкл.</string>
<string name="frame_gen_winfg">Win-FG %1$d×</string>
<string name="frame_gen_lsfg">LSFG %1$d×</string>
<string name="frame_gen_lsfg_adaptive">LSFG Адаптивный %1$d</string>
<string name="lsfg_needs_steam">Требуется Lossless Scaling из Steam</string>
<string name="lsfg_needs_install">Установите Lossless Scaling в Steam или импортируйте Lossless.dll</string>
<string name="lsfg_import">Импорт Lossless.dll…</string>
<string name="lsfg_using_steam">Используется версия из Steam</string>
<string name="lsfg_using_import">Используется импортированная версия</string>
<string name="lsfg_pick_title">Выберите файл Lossless.dll</string>
<string name="lsfg_imported">Lossless.dll импортирован</string>
<string name="lsfg_import_unchanged">Этот Lossless.dll уже используется</string>
<string name="lsfg_import_not_owned">В этой учетной записи Steam нет Lossless Scaling</string>
<string name="lsfg_import_invalid">Этот файл не является Lossless.dll от Lossless Scaling</string>
<string name="lsfg_import_failed">Не удалось подготовить LSFG из этого Lossless.dll</string>
<string name="lsfg_problem_missing">Для LSFG требуется Lossless Scaling из Steam или импортированный файл Lossless.dll</string>
<string name="lsfg_problem_failed">LSFG не может подготовить свои шейдеры из этого Lossless.dll</string>
<string name="hud_fg_unsupported">%1$s: не поддерживается</string>
<string name="hud_fg_failed">%1$s: сбой движка</string>
<string name="hud_fg_needs_lossless">%1$s: требуется Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · Запуск</string>
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam.</string>
<string name="second_library_import_hint">Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam.</string>
<string name="added_games_forget_hint">«Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске.</string>
<string name="game_env_title">Игровая среда</string>
<string name="game_env_hint">Переменные среды для игр Proton.</string>
<string name="game_env_edit">Изменить</string>
<string name="game_env_shared">Все игры Proton</string>
<string name="game_env_scope">Профиль</string>
<string name="game_env_app_id">ID приложения Steam</string>
<string name="game_env_open">Открыть</string>
<string name="game_env_profile">Игра %1$s</string>
<string name="game_env_add">Добавить переменную</string>
<string name="game_env_name">Имя переменной</string>
<string name="game_env_value">Значение</string>
<string name="game_env_save">Сохранить</string>
<string name="game_env_remove">Удалить</string>
<string name="game_env_restore">Восстановить наследование</string>
<string name="game_env_reset">Сбросить профиль</string>
<string name="game_env_unset">Снять настройку для профиля</string>
<string name="game_env_empty">Переменные не заданы.</string>
<string name="game_env_inherited">Унаследовано</string>
<string name="game_env_custom">Пользовательская переменная</string>
<string name="game_env_error">Не удалось загрузить или сохранить. Проверьте свободное место и повторите попытку.</string>
<string name="game_env_invalid">Введите допустимое имя и значение, до 8192 символов.</string>
<string name="game_env_unused">Стандартная среда выполнения Proton не считывает эту переменную.</string>
<string name="game_env_loading">Загрузка…</string>
<string name="game_env_feature">Переопределение уровня функций D3D12. 12_2 сообщает о возможностях DirectX 12 Ultimate; неподдерживаемые функции могут вызвать сбой. Оставьте пустым для автоопределения.</string>
<string name="game_env_shader">Переопределение модели шейдеров D3D12. 6_9 требует достаточно нового VKD3D и соответствующих функций GPU. Оставьте пустым для автоопределения.</string>
<string name="game_env_cache">Поддерживать кэш шейдеров Mesa включенным для уменьшения повторной компиляции. Установите false, чтобы включить кэш.</string>
<string name="game_env_cache_size">Максимальный размер кэша шейдеров Mesa, например 1G. Больший кэш занимает больше места.</string>
<string name="game_env_vkd3d_config">Параметры VKD3D для D3D12. nodxr отключает трассировку лучей; используйте только при необходимости в игре.</string>
<string name="game_env_proton_log">Установите 1 для создания диагностического журнала Proton. Ведение журнала может снизить производительность и занять место.</string>
<string name="game_env_wined3d">Установите 1, чтобы использовать WineD3D на базе OpenGL вместо DXVK для тестирования совместимости с D3D9–11.</string>
<string name="game_env_xalia">Установите 0, чтобы отключить помощник навигации геймпада Proton для этой игры.</string>
<string name="game_env_hud">Оверлей DXVK для D3D9–11. fps показывает частоту кадров; снимите настройку, чтобы скрыть оверлей.</string>
<string name="game_env_dxvk_config">Настройки DXVK, разделенные точкой с запятой. В последних версиях используется dxvk.maxFrameRate для ограничения частоты кадров; для повседневной игры рекомендуется использовать ограничитель частоты кадров сессии.</string>
<string name="game_env_vkd3d_limit">Ограничение частоты кадров D3D12. 0 отключает ограничение.</string>
<string name="game_env_glthread">Многопоточность Mesa OpenGL. Для игр OpenGL с ограничением CPU значение true может помочь; тестируйте отдельно для каждой игры.</string>
<string name="fex_preset_title">Пресеты FEX</string>
<string name="fex_next_launch">Вступит в силу при следующем запуске игры.</string>
<string name="game_settings_title">Игра</string>
<string name="fex_default_label">FEX по умолчанию</string>
<string name="fex_default_detail">Использовать настройки FEX по умолчанию.</string>
<string name="fex_stability_label">Стабильность</string>
<string name="fex_stability_detail">Самые безопасные и медленные настройки.</string>
<string name="fex_compatibility_label">Совместимость</string>
<string name="fex_compatibility_detail">Полный TSO + multiblock.</string>
<string name="fex_intermediate_label">Средний</string>
<string name="fex_intermediate_detail">Сбалансированные настройки TSO со сниженной точностью x87.</string>
<string name="fex_performance_label">Производительность</string>
<string name="fex_performance_detail">Отключить все TSO; включить multiblock.</string>
<string name="fex_performance_tso_label">Производительность + TSO</string>
<string name="fex_performance_tso_detail">Настройки производительности с включенным основным TSO.</string>
<string name="fex_extreme_label">Экстрим</string>
<string name="fex_extreme_detail">Отключить проверку самомодифицирующегося кода. Игры JIT и DRM могут не работать.</string>
<string name="fex_extreme_tso_label">Экстрим + TSO</string>
<string name="fex_extreme_tso_detail">Настройки экстрим с включенным основным TSO.</string>
<string name="fex_extreme_gn_label">Экстрим-gn</string>
<string name="fex_extreme_gn_detail">Настройки производительности с включенными TSC и подсказками метаданных. Рекомендуется сначала попробовать этот режим, а затем «Экстрим».</string>
<string name="fex_denuvo_label">Denuvo</string>
<string name="fex_denuvo_detail">Настройки производительности с включенной полной проверкой кода и скрытием битов гипервизора.</string>
<string name="game_env_custom_value">Пользовательское значение</string>
<string name="game_env_select_value">Выберите значение</string>
<string name="user_apps_section">Эмуляторы и приложения</string>
<string name="user_apps_add">Добавить</string>
<string name="user_apps_add_detail">Ваше приложение или игра</string>
<string name="user_apps_kind_script">Скрипт</string>
<string name="user_apps_kind_appimage">AppImage</string>
<string name="user_apps_kind_flatpak">Flatpak</string>
<string name="user_apps_progress">%1$s · %2$s</string>
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
<string name="user_apps_starting">Запуск…</string>
<string name="user_apps_added">%1$s добавлено</string>
<string name="user_apps_removed">%1$s удалено</string>
<string name="user_apps_back">Рабочий стол</string>
<string name="user_apps_gone">Приложение не существует. Выберите его заново на рабочем столе.</string>
<string name="user_apps_open">Открыть %1$s</string>
<string name="user_apps_remove">Удалить</string>
<string name="user_apps_remove_confirm">Нажмите еще раз для удаления</string>
<string name="user_apps_stop_session">Сначала завершите сессию</string>
<string name="user_apps_script_linked">Запускает %1$s из своей папки и связывает эту папку с сессией. Удаление приложения не повлияет на папку.</string>
<string name="user_apps_script_copied">Запускает %1$s из папки, скопированной в DroidDeck. Удаление приложения удалит копию, но не оригинал.</string>
<string name="user_apps_runtime_required">Сначала установите среду выполнения Linux</string>
<string name="user_apps_file_gone">Файл не существует</string>
<string name="user_apps_no_space">Недостаточно места: для копирования папки требуется %1$s</string>
<string name="user_apps_copying">Копирование %1$s</string>
<string name="user_apps_failed">Ошибка добавления приложения</string>
<string name="user_apps_github_checking">Поиск выпусков %1$s</string>
<string name="user_apps_github_unreachable">Не удалось прочитать выпуски %1$s</string>
<string name="user_apps_github_no_appimage">%1$s не предоставляет выпуск ARM64 AppImage</string>
<string name="user_apps_downloading">Загрузка %1$s</string>
<string name="user_apps_download_failed">Ошибка загрузки</string>
<string name="user_apps_update_failed">Ошибка обновления приложения</string>
<string name="user_apps_icon_failed">Не удалось прочитать иконку</string>
<string name="user_apps_saved">%1$s сохранено</string>
<string name="user_apps_edit">Изменить</string>
<string name="user_apps_check_updates">Проверить обновления</string>
<string name="user_apps_checking_updates">Проверка обновлений…</string>
<string name="user_apps_update_to">Обновить до %1$s</string>
<string name="user_apps_up_to_date">Актуально · %1$s</string>
<string name="user_apps_updated">%1$s обновлено</string>
<string name="user_apps_github_version">%1$s · %2$s</string>
<string name="add_app_title">Добавить приложение</string>
<string name="add_app_tab_script">Скрипт</string>
<string name="add_app_tab_appimage">AppImage</string>
<string name="add_app_tab_github">GitHub</string>
<string name="add_app_tab_flatpak">Flatpak</string>
<string name="add_app_script_hint">Файл .sh. Запускается из своей папки и отображается в полноэкранном режиме через gamescope.</string>
<string name="add_app_appimage_hint">ARM64 (aarch64) AppImage. Распаковывается в среду выполнения Linux при первом использовании.</string>
<string name="add_app_github_hint">Установить ARM64 AppImage из последнего выпуска репозитория.</string>
<string name="add_app_flatpak_hint">ID приложения Flathub или его страница Flathub. Если Flatpak не настроен, он будет настроен сначала.</string>
<string name="add_app_file">Файл</string>
<string name="add_app_no_file">Файл не выбран</string>
<string name="add_app_choose_file">Выбрать файл</string>
<string name="add_app_change_file">Изменить</string>
<string name="add_app_pick_script">Выбрать скрипт</string>
<string name="add_app_pick_appimage">Выбрать AppImage</string>
<string name="add_app_pick_icon">Выбрать иконку</string>
<string name="add_app_repository">Репозиторий</string>
<string name="add_app_repository_placeholder">owner/repo или ссылка github.com</string>
<string name="add_app_repository_invalid">Не является репозиторием GitHub</string>
<string name="add_app_flatpak_id">ID приложения</string>
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
<string name="add_app_flatpak_invalid">Недействительный ID приложения Flathub</string>
<string name="add_app_source">Источник</string>
<string name="add_app_appearance">Показывать на рабочем столе</string>
<string name="add_app_preview_name">Новое приложение</string>
<string name="add_app_name">Название</string>
<string name="add_app_name_placeholder">Из приложения</string>
<string name="add_app_icon">Иконка</string>
<string name="add_app_icon_default">Иконка по умолчанию</string>
<string name="add_app_choose_icon">Выбрать иконку</string>
<string name="add_app_clear_icon">Очистить</string>
<string name="add_app_icon_suggestions">Из репозитория</string>
<string name="add_app_icon_searching">Поиск иконок…</string>
<string name="add_app_icon_reset">По умолчанию</string>
<string name="edit_app_title">Изменить %1$s</string>
<string name="edit_app_save">Сохранить</string>
<string name="add_app_checking_folder">Проверка папки…</string>
<string name="add_app_folder_linked">Папка связана с сессией: %1$s</string>
<string name="add_app_folder_copied">Эта папка содержит программы Linux, которые не могут работать из общего хранилища, поэтому она будет скопирована в DroidDeck (%1$s).</string>
<string name="add_app_confirm">Добавить</string>
<string name="add_app_cancel">Отмена</string>
<string name="add_app_runtime_required">Сначала установите среду выполнения Linux в «Настройках».</string>
<string name="add_app_busy">Подождите, пока завершится %1$s.</string>
<string name="common_cancel">Отмена</string>
<string name="common_auto">Авто</string>
<string name="common_never">Никогда</string>
<string name="common_always">Всегда</string>
<string name="common_uninstall">Удалить</string>
<string name="common_check">Проверить</string>
<string name="common_update">Обновить</string>
<string name="common_checking">Проверка…</string>
<string name="common_working">Обработка…</string>
<string name="common_forget">Забыть</string>
<string name="common_add_ellipsis">Добавить…</string>
<string name="common_use">Использовать</string>
<string name="common_applies_next_session">Вступит в силу в следующей сессии.</string>
<string name="mode_steam_title">Сессия Steam</string>
<string name="mode_desktop_title">Сессия рабочего стола</string>
<string name="mode_display">Экран</string>
<string name="mode_resolution">Разрешение</string>
<string name="mode_res_720">До 720p</string>
<string name="mode_res_900">До 900p</string>
<string name="mode_res_1080">До 1080p</string>
<string name="mode_res_native">Нативное</string>
<string name="mode_res_default">%1$s (по умолчанию)</string>
<string name="mode_res_custom_value">Пользовательское · %1$d×%2$d</string>
<string name="mode_res_custom">Пользовательское…</string>
<string name="mode_res_note">720p может ускорить отклик меню.</string>
<string name="mode_ratio">Соотношение сторон</string>
<string name="mode_ratio_custom">Задается пользовательским разрешением.</string>
<string name="mode_ratio_auto">Автоматический режим использует не менее 16:9.</string>
<string name="mode_fps">Ограничение частоты кадров</string>
<string name="mode_fps_note">Ограничивает частоту кадров всей сессии. Монитор переключится на частоту обновления, кратную этому пределу.</string>
<string name="mode_hdr">HDR</string>
<string name="mode_hdr10">Вывод HDR10</string>
<string name="mode_hdr_unavailable">Недоступно: %1$s.</string>
<string name="mode_hdr_restart">Вступит в силу после перезапуска приложения.</string>
<string name="mode_drivers">Драйверы</string>
<string name="mode_gpu_drivers">Драйверы GPU</string>
<string name="mode_gpu_drivers_hint">Общие для Steam и рабочего стола. Настройте на странице «Компоненты».</string>
<string name="mode_touch_controls">Сенсорный ввод и управление</string>
<string name="mode_touch">Сенсорный</string>
<string name="mode_touch_touchpad">Тачпад</string>
<string name="mode_touch_direct">Прямой</string>
<string name="mode_touch_note">Авто: тачпад для рабочего стола, прямой сенсорный ввод для Steam.</string>
<string name="mode_osc">Экранные элементы управления</string>
<string name="mode_osc_qam">Steam + QAM</string>
<string name="mode_osc_note">Автоматический режим показывает экранные элементы управления, если контроллер не подключен.</string>
<string name="mode_controller">Контроллер</string>
<string name="mode_controller_hint">Как Steam распознает ваш контроллер. Вступит в силу в следующей сессии.</string>
<string name="mode_controller_deck">Контроллер Steam Deck</string>
<string name="mode_controller_x360">Контроллер Xbox 360</string>
<string name="mode_controller_note">Deck: кнопка быстрого доступа и гироскоп. Xbox 360: используйте Guide+A для быстрого доступа.</string>
<string name="mode_back">Назад</string>
<string name="mode_session">Сессия</string>
<string name="mode_suspend">Поведение в фоне</string>
<string name="mode_suspend_hint">Поведение при скрытии приложения или выключении экрана.</string>
<string name="mode_suspend_manual">Вручную</string>
<string name="mode_suspend_note">Автоматический режим приостанавливает работу в фоне и возобновляет при отображении. Ручной режим требует нажатия «Продолжить». Никогда не остается запущенным.</string>
<string name="mode_startup">Запуск</string>
<string name="mode_steam_startup">Запускать Steam при открытии DroidDeck</string>
<string name="mode_steam_startup_hint">Открывает сессию Steam при запуске DroidDeck.</string>
<string name="mode_decky">Decky</string>
<string name="mode_decky_checking">Проверка выпусков…</string>
<string name="mode_decky_ready">Готово к установке %1$s.</string>
<string name="mode_decky_none">Совместимая версия не найдена. Повторите попытку позже.</string>
<string name="mode_decky_installed">Установлено · %1$s</string>
<string name="mode_decky_update">Доступно обновление · %1$s</string>
<string name="mode_decky_current">Актуально · %1$s</string>
<string name="mode_decky_loader">Загрузчик</string>
<string name="mode_decky_install_latest">Установить последнюю версию</string>
<string name="mode_decky_on">Запускается вместе со Steam. После включения другие приложения на этом устройстве могут управлять Steam.</string>
<string name="mode_decky_off">Выкл. Порт удаленной отладки Steam остается закрытым.</string>
<string name="mode_decky_remove_title">Удалить Decky Loader?</string>
<string name="mode_decky_remove_text">Удаляет загрузчик. Ваши плагины и настройки сохранятся.</string>
<string name="mode_client">Клиент</string>
<string name="mode_deck_mode">Режим Steam Deck</string>
<string name="mode_deck_mode_hint">Интерфейс Steam Deck и управление производительностью через быстрый доступ. Вступит в силу в следующей сессии.</string>
<string name="mode_mangoapp">Оверлей производительности</string>
<string name="mode_mangoapp_hint">Отключите, если игры падают или появляется черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
<string name="mode_branch">Ветка клиента</string>
<string name="mode_branch_deck">Бета-версия Steam Deck (требуется для режима Deck).</string>
<string name="mode_branch_hint">Вступит в силу в следующей сессии. Steam может обновиться самостоятельно один раз.</string>
<string name="mode_branch_public">Бета</string>
<string name="mode_branch_deck_beta">Бета-версия Steam Deck</string>
<string name="mode_branch_note">Режим Deck всегда использует бета-версию Steam Deck.</string>
<string name="mode_added_games">Добавленные игры</string>
<string name="mode_added_none">Папки с играми .exe не найдены</string>
<string name="mode_games_folder">Папка с играми</string>
<string name="mode_games_folder_another">Другая папка с играми</string>
<string name="mode_added_art">Изображения из Steam</string>
<string name="mode_added_art_hint">Дополняет отсутствующие изображения по имени папки. Файлы cover.jpg, hero.jpg, logo.png или icon.png в папке с игрой имеют приоритет.</string>
<string name="mode_added_launches">Запустить %1$s</string>
<string name="mode_added_launches_in">Запустить %1$s · в %2$s</string>
<string name="mode_added_choose">Выбрать другой файл…</string>
<string name="mode_added_exe_note">По умолчанию используется .exe с именем папки, иначе самый большой .exe.</string>
<string name="mode_stretch169">Растянуть игры 16:9</string>
<string name="mode_stretch169_hint">На экранах с соотношением сторон менее 16:9 растягивает игру на весь экран вместо черных полос. Вступит в силу в следующей сессии.</string>
<string name="mode_audio">Аудио</string>
<string name="mode_directaudio">Использовать DirectAudio в игре</string>
<string name="mode_directaudio_hint">Обходит PulseAudio для снижения задержки.</string>
<string name="mode_client_audio">Аудио клиента Steam</string>
<string name="mode_client_audio_hint">Вступит в силу в следующей сессии.</string>
<string name="mode_client_audio_classic">Классический</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Микрофон</string>
<string name="mode_mic_hint">Используется для голосового чата.</string>
<string name="mode_network">Сеть</string>
<string name="mode_wifi_discovery">Обнаружение Wi-Fi</string>
<string name="mode_wifi_discovery_hint">Показывает названия Wi-Fi и ближайшие сети в Steam.</string>
<string name="mode_wifi_location_off">Службы геолокации отключены.</string>
<string name="mode_wifi_permission_denied">Разрешение на определение местоположения не предоставлено.</string>
<string name="mode_wifi_open_settings">Открыть настройки</string>
<string name="mode_wifi_continue">Продолжить</string>
<string name="mode_wifi_explain_title">Включить обнаружение Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения.</string>
<string name="mode_storage">Хранилище игр</string>
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии</string>
<string name="mode_storage_auto_none">Авто - Использовать SD-карту при наличии (сейчас нет SD-карты)</string>
<string name="mode_storage_internal">Только внутреннее хранилище</string>
<string name="mode_storage_folder">Папка: %1$s</string>
<string name="mode_second_library">Вторая библиотека</string>
<string name="mode_storage_note">Игры, читающие ресурсы с SD-карты или из общего хранилища, могут подтормаживать.</string>
<string name="mode_choose_folder">Выбрать папку…</string>
<string name="mode_renderer">Рендерер</string>
<string name="mode_desktop_renderer">Рендерер рабочего стола</string>
<string name="mode_renderer_hint">Отвечает за отрисовку рабочего стола.</string>
<string name="mode_renderer_vulkan">vulkan - GPU</string>
<string name="mode_renderer_gles2">gles2 - GPU (экспериментально)</string>
<string name="mode_renderer_pixman">pixman - Программный</string>
<string name="mode_renderer_note">Vulkan использует GPU для запуска программ рабочего стола, при сбое переключается на pixman. При использовании pixman игры по-прежнему запускаются в полноэкранном режиме на GPU.</string>
<string name="mode_custom_res_title">Пользовательское разрешение</string>
<string name="mode_custom_res_text">Заменяет предел разрешения и соотношение сторон. Размеры, не соответствующие экрану, будут отображаться с черными полосами.</string>
<string name="mode_width">Ширина</string>
<string name="mode_height">Высота</string>
<string name="mode_custom_res_range">Диапазон: от 320×240 до 3840×2160.</string>
<plurals name="mode_added_count">
<item quantity="one">%1$d игра</item>
<item quantity="few">%1$d игры</item>
<item quantity="many">%1$d игр</item>
<item quantity="other">%1$d игр</item>
</plurals>
<string name="common_ok">ОК</string>
<string name="common_delete">Удалить</string>
<string name="common_restore">Восстановить</string>
<string name="common_download">Скачать</string>
<string name="common_delete_named">Удалить %1$s</string>
<string name="common_size_mb">%1$.1f МБ</string>
<string name="comp_runtime_driver">Драйвер среды выполнения</string>
<string name="comp_runtime_driver_hint">Используется Steam, играми и рабочим столом. Вступит в силу в следующей сессии.</string>
<string name="comp_import_turnip">Импорт Turnip zip…</string>
<string name="comp_display_driver">Драйвер дисплея</string>
<string name="comp_display_driver_hint">Отвечает за вывод изображения на экран в обоих режимах. Вступит в силу после перезапуска приложения.</string>
<string name="comp_import_adrenotools">Импорт AdrenoTools zip…</string>
<string name="comp_never">Никогда</string>
<string name="comp_nightlies_checked">Nightlies проверено %1$s</string>
<string name="comp_title">Компоненты</string>
<string name="comp_about">О компонентах</string>
<string name="comp_check_drivers">Проверить новые драйверы</string>
<string name="comp_check_nightlies">Проверить новые пакеты в Nightlies</string>
<string name="comp_proton">Proton</string>
<string name="comp_proton_note">При обновлении Steam заменяет Proton от Valve; оригинальные файлы каждой сборки сохраняются.</string>
<string name="comp_swapped">%1$s · заменено %2$d</string>
<string name="comp_all_original">%1$s · все оригинальные файлы</string>
<string name="comp_game_running_suffix">%1$s · игра запущена</string>
<string name="comp_gpu_drivers">Драйверы GPU</string>
<string name="comp_reapplied">%1$s · повторно применено при запуске %2$s</string>
<string name="comp_next_queued">Далее: %1$s · после закрытия игры</string>
<string name="comp_in_use">Используется %1$s: %2$s</string>
<string name="comp_waits">Ожидание закрытия игры</string>
<string name="comp_applies_next_game">Вступит в силу при следующем запуске игры</string>
<string name="comp_import_zip">Импорт .zip</string>
<string name="comp_reading">Чтение Proton…</string>
<string name="comp_no_proton">Proton не установлен. Запустите Steam один раз для загрузки ARM64 Proton или добавьте GE-Proton или CachyOS в «Настройках».</string>
<string name="comp_original">Оригинал · %1$s</string>
<string name="comp_this_build">%1$s · текущая сборка</string>
<string name="comp_earlier_build">%1$s · более ранняя сборка</string>
<string name="comp_installed_count">Установлено · %1$d</string>
<string name="comp_nothing_stored">Для %1$s пока ничего не сохранено. Файлы, поставляемые с этим Proton, будут сохранены как оригинальные.</string>
<string name="comp_waits_long">Игра использует этот Proton, поэтому нужно дождаться ее закрытия.</string>
<string name="comp_applies_long">Вступит в силу при следующем запуске игры.</string>
<string name="comp_swap_title">Заменить %1$s?</string>
<string name="comp_swap_body">Поместить %1$s в %2$s? Оригинальные файлы продолжат сохраняться. %3$s</string>
<string name="comp_restore_title">Восстановить %1$s?</string>
<string name="comp_restore_body">Вернуть оригинальные файлы %1$s в %2$s? %3$s</string>
<string name="comp_delete_package_body">Удалить пакет из приложения. Его можно будет скачать снова.</string>
<string name="comp_delete_original_title">Удалить оригинальную версию?</string>
<string name="comp_delete_original_body">Эта оригинальная версия %1$s из более ранней сборки %2$s. Оригинальная версия текущей сборки всегда сохраняется.</string>
<string name="comp_nightlies_count">В Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Обновить, чтобы показать пакеты в Nightlies.</string>
<string name="comp_nothing_new">С момента последней проверки нового нет.</string>
<string name="comp_import_wcp">Импорт .wcp</string>
<string name="comp_restore_original">Восстановить оригинальные файлы этого Proton</string>
<string name="comp_restore_original_body">Вернуть оригинальные файлы %2$s из %1$s?</string>
<string name="comp_swap">Переключить</string>
<string name="comp_about_text">FEX, DXVK и VKD3D-Proton: для каждого Proton, используемого Steam для запуска игр.\n\nВыберите установленный элемент, чтобы заменить его. Это вступит в силу при следующем запуске игры или после закрытия текущей запущенной игры.\n\nВаш выбор будет перепроверяться перед каждым запуском; если он был изменен, он будет восстановлен. Оригинальные файлы каждого Proton сохраняются, поэтому обновления Steam никогда их не потеряют.</string>
<string name="comp_tag_original">Оригинал</string>
<string name="comp_tag_in_use">Используется</string>
<string name="comp_tag_new">Новый</string>
<string name="comp_tag_stored">Сохранено</string>
<string name="perf_title">Производительность</string>
<string name="perf_lede">Ядра CPU, исправления сессии и имя хоста. Вступит в силу в следующей сессии.</string>
<string name="perf_client_cores_group">Ядра клиента Steam</string>
<string name="perf_override">Переопределить выбор ядер Steam</string>
<string name="perf_override_hint">Закрепить Steam, помощник интерфейса и gamescope за выбранными ниже ядрами.</string>
<string name="perf_client_cores">Ядра клиента</string>
<string name="perf_client_cores_on">Ядра, используемые Steam.</string>
<string name="perf_client_cores_off">Включите переопределение для выбора.</string>
<string name="perf_game_cores_group">Ядра игры</string>
<string name="perf_game_cores">Ядра игры</string>
<string name="perf_game_cores_hint">Ядра, используемые играми. При выборе всех ядер Android планирует свободно.</string>
<string name="perf_client_ui">Интерфейс клиента</string>
<string name="perf_glthread">Многопоточность GL</string>
<string name="perf_glthread_hint">Может ускорить отклик меню Steam.</string>
<string name="perf_zink">Zink: отложенные дескрипторы</string>
<string name="perf_zink_hint">Рекомендуется для драйверов без буфера дескрипторов.</string>
<string name="perf_noglerror">Пропустить проверку ошибок GL</string>
<string name="perf_noglerror_hint">Отключить проверку GL для каждого вызова.</string>
<string name="perf_gsrealtime">gamescope: очередь GPU в реальном времени</string>
<string name="perf_gsrealtime_hint">Повышает приоритет работы GPU композитора. Может улучшить стабильность времени кадра, но потребляет часть производительности игры.</string>
<string name="perf_gpu">GPU</string>
<string name="perf_gpuclock">Поддерживать максимальную частоту GPU</string>
<string name="perf_gpuclock_hint">Только для Adreno. Меньше подтормаживаний, но больше энергопотребления и нагрева.</string>
<string name="perf_fixes">Исправления сессии</string>
<string name="perf_sysmem">Turnip: рендеринг sysmem</string>
<string name="perf_sysmem_hint">Steam и большинство игр будут быстрее. Требуется для Adreno 710/720/722.</string>
<string name="perf_xalia">Пропустить помощник xalia в Steam</string>
<string name="perf_xalia_hint">Отключает помощник навигации геймпада Proton, который потребляет CPU в каждой игре.</string>
<string name="perf_seccomp">Запускать proot без seccomp</string>
<string name="perf_seccomp_hint">Может исправить ошибки отсутствующих системных вызовов на некоторых ядрах, но может снизить производительность.</string>
<string name="perf_fastpath">Быстрый путь proot</string>
<string name="perf_fastpath_needs_seccomp">Требуется фильтр seccomp для proot.</string>
<string name="perf_fastpath_hint">Быстрый поиск файлов, меньше подтормаживаний. Отключите, если программа не находит файлы.</string>
<string name="perf_identity">Идентификация сессии</string>
<string name="perf_hostname">Имя хоста</string>
<string name="perf_hostname_hint">Имя, под которым Steam видит это устройство. Оставьте пустым, чтобы вернуть %1$s.</string>
<string name="perf_hostname_invalid">Только буквы, цифры и дефисы посередине, до 63 символов.</string>
<string name="perf_phantom_warning">Android настроен на завершение этой сессии</string>
<string name="driver_installed">Установлен</string>
<string name="driver_available">Доступен для скачивания</string>
<string name="driver_nothing_new">С момента последней проверки нового нет.</string>
<string name="driver_restore_builtin">Восстановить встроенный драйвер</string>
<string name="driver_delete_builtin">Встроенный драйвер приложения: удалит распакованные файлы и скроет драйвер. Используйте «Восстановить встроенный драйвер» для восстановления.</string>
<string name="driver_delete_bundle">Половина пакета Android + Linux: обе части будут удалены. Используемый драйвер вернется к драйверу по умолчанию.</string>
<string name="driver_delete_plain">Файлы будут удалены из приложения.</string>
<string name="driver_delete_in_use">Файлы будут удалены. Поскольку драйвер используется, будет применен драйвер по умолчанию.</string>
<string name="drawer_page_display">Экран</string>
<string name="drawer_page_controls">Управление</string>
<string name="drawer_page_components">Компоненты</string>
<string name="drawer_page_settings">Настройки</string>
<string name="drawer_page_desc">Страница %1$s</string>
<string name="drawer_steam">Steam</string>
<string name="drawer_desktop">Рабочий стол</string>
<string name="drawer_steam_menu">Меню Steam</string>
<string name="drawer_quick_access">Быстрый доступ</string>
<string name="drawer_open_qam">Открыть меню быстрого доступа</string>
<string name="drawer_prev_page">Предыдущая страница</string>
<string name="drawer_next_page">Следующая страница</string>
<string name="drawer_hud">HUD производительности</string>
<string name="drawer_touch_auto">Авто (%1$s)</string>
<string name="drawer_keyboard">Клавиатура</string>
<string name="drawer_pc_keyboard">Клавиатура ПК</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Второй экран</string>
<string name="drawer_second_screen_shows">Показывать</string>
<string name="drawer_android_apps">Приложения Android</string>
<string name="drawer_launch_app">Запустить приложение</string>
<string name="common_hide">Скрыть</string>
<string name="common_show">Показать</string>
<string name="drawer_no_apps">Нет приложений для запуска</string>
<string name="drawer_session_behavior">Поведение сессии</string>
<string name="drawer_suspend_hint">Применяется сразу и действует для будущих сессий этого режима.</string>
<string name="drawer_next_session">Следующая сессия</string>
<string name="drawer_support">Поддержка</string>
<string name="drawer_logs">Журналы сессии</string>
<string name="drawer_logs_hint">Прикрепить к отчету об ошибке</string>
<string name="drawer_share_logs">Поделиться журналами</string>
<string name="drawer_background">Фон</string>
<string name="drawer_stop">Остановить</string>
<string name="drawer_stop_title">Остановить сессию?</string>
<string name="drawer_running_now">Сейчас выполняется</string>
<string name="drawer_no_game">Сейчас нет игр, запущенных через Proton</string>
<string name="drawer_no_proton">Proton не установлен.</string>
<string name="drawer_game_running">Игра запущена</string>
<string name="drawer_reapplied">Повторно применено при запуске %1$s</string>
<string name="drawer_original">Оригинал</string>
<string name="drawer_components_note">Скачивайте, импортируйте и удаляйте на странице «Компоненты».</string>
<string name="setup_check_done">Готово</string>
<string name="setup_check_warn">Требует внимания</string>
<string name="setup_check_busy">Обработка</string>
<string name="setup_limit_adb_failed">Проверьте IP-адрес и порт отладки по Wi-Fi; если Android удалил устройство, выполните повторное сопряжение.</string>
<string name="setup_install">Установить</string>
<string name="setup_manage">Управление</string>
<string name="setup_tab_overview">Обзор</string>
<string name="setup_tab_controller">Контроллер</string>
<string name="setup_tab_session">Сессия</string>
<string name="setup_tab_launcher">Лаунчер</string>
<string name="setup_title">Настройка</string>
<string name="setup_all_set">● Все готово</string>
<string name="setup_n_ready">%1$d/%2$d готово</string>
<string name="setup_system_check">Проверка системы</string>
<string name="setup_gpu_supported">Устройство поддерживается</string>
<string name="setup_gpu_untested">Непроверенный GPU</string>
<string name="setup_gpu_unsupported">GPU не поддерживается</string>
<string name="setup_gpu_detail">%1$s · %2$s</string>
<string name="setup_gpu_untested_detail">%1$s: %2$s</string>
<string name="setup_gpu_unsupported_detail">Steam требует драйвер Adreno; на %1$s возможен черный экран.</string>
<string name="setup_runtime">Среда выполнения Linux</string>
<string name="setup_runtime_progress">%1$s · %2$d%%</string>
<string name="setup_runtime_missing">Не установлено · около 3 ГБ, устанавливается при первом запуске игры</string>
<string name="setup_installed">Установлено</string>
<string name="setup_runtime_update">%1$s · доступно обновление</string>
<string name="setup_runtime_current">%1$s · актуально</string>
<string name="setup_limit">Ограничение дочерних процессов</string>
<string name="setup_limit_on">Включено · Android может закрыть Steam. Отключение занимает около минуты.</string>
<string name="setup_limit_unset">Не задано · Android может закрыть Steam. Отключение занимает около минуты.</string>
<string name="setup_limit_unknown">Невозможно проверить · отключение занимает около минуты.</string>
<string name="setup_fix_it">Исправить</string>
<string name="setup_details">Подробности</string>
<string name="setup_dev_options">Для разработчиков</string>
<string name="setup_use_wireless">Использовать отладку по Wi-Fi</string>
<string name="setup_check_again">Проверить снова</string>
<string name="setup_limit_turn_on">Включить ограничение</string>
<string name="setup_account">Аккаунт Steam</string>
<string name="setup_signed_in_offline">Вход выполнен как %1$s · автономный режим</string>
<string name="setup_signed_in">Вход выполнен как %1$s</string>
<string name="setup_sign_in">Нажмите Play и войдите в Steam</string>
<string name="setup_tools">Инструменты</string>
<string name="setup_controller">Контроллер</string>
<string name="setup_controller_unavailable">Настройки контроллера недоступны.</string>
<string name="setup_session">Сессия</string>
<string name="setup_logs">Журналы сессии</string>
<string name="setup_logs_hint">Сохраняется после каждой сессии</string>
<string name="setup_latest_logs">Последние журналы сессии</string>
<string name="setup_saved_logs">Сохраненные журналы сессии</string>
<string name="setup_saved_logs_hint">Хранить последние %1$d</string>
<string name="setup_clear_logs">Очистить журналы</string>
<string name="setup_offline">Автономный режим</string>
<string name="setup_sign_in_first">Сначала войдите в Steam</string>
<string name="setup_launcher">Лаунчер</string>
<string name="setup_theme">Тема</string>
<string name="setup_theme_hint">Внешний вид лаунчера</string>
<string name="setup_home">Использовать как главный экран</string>
<string name="setup_home_on">DroidDeck может использоваться как приложение главного экрана</string>
<string name="setup_home_off">DroidDeck не будет доступен как приложение главного экрана</string>
<string name="setup_fullscreen">Полноэкранный режим</string>
<string name="setup_fullscreen_on">Скрыть строку состояния и навигации Android</string>
<string name="setup_fullscreen_off">Показать строку состояния и навигации Android</string>
<string name="setup_default_home">Приложение главного экрана по умолчанию</string>
<string name="setup_choose_home">Выбрать приложение главного экрана</string>
<string name="setup_choose">Выбрать</string>
<string name="setup_linux_apps">Приложения Linux (бета)</string>
<string name="setup_store">Магазин Flathub</string>
<string name="setup_store_on">Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck.</string>
<string name="setup_store_off">Устанавливать приложения и игры Linux из Flathub</string>
<string name="setup_tool_files">Файлы</string>
<string name="setup_tool_files_hint">Просмотр и управление файлами</string>
<string name="setup_tool_protons">Версии Proton</string>
<string name="setup_tool_protons_hint">Установить сборки ARM64 Proton</string>
<string name="setup_tool_performance">Производительность</string>
<string name="setup_tool_performance_hint">Распределение ядер CPU</string>
<string name="setup_tool_roms">Папки ROM</string>
<string name="setup_tool_roms_hint">Место хранения игр эмулятора</string>
<string name="setup_card_components">Компоненты</string>
<string name="setup_card_components_hint">FEX, DXVK, VKD3D</string>
<string name="setup_card_controls">Управление</string>
<string name="setup_card_controls_hint">Сопоставление клавиш</string>
<string name="store_tab_discover">Обзор</string>
<string name="store_tab_search">Поиск</string>
<string name="store_tab_installed">Установлено</string>
<string name="store_tab_count">%1$s · %2$d</string>
<string name="store_title">Магазин</string>
<string name="store_chip_flathub">Flathub · ARM64</string>
<string name="store_chip_ready">● Flatpak готов</string>
<string name="store_needs_runtime">Сначала установите среду выполнения Linux в «Настройках».</string>
<string name="store_setup_title">Настройка Flatpak</string>
<string name="store_setup_text">Добавление Flatpak и подключение к Flathub (около 20 МБ). Первое приложение также загрузит свою среду выполнения.</string>
<string name="store_setting_up">Настройка…</string>
<string name="store_flatpak">Flatpak</string>
<string name="store_updates">Обновления</string>
<string name="store_busy">%1$s · %2$s</string>
<string name="store_busy_percent">%1$s · %2$s · %3$d%%</string>
<string name="store_starting">Запуск…</string>
<string name="store_categories">Категории</string>
<string name="store_unreachable">Не удалось подключиться к Flathub. Проверьте подключение к сети и повторите попытку.</string>
<string name="store_try_again">Повторить</string>
<string name="store_search_label">Поиск в Flathub</string>
<string name="store_search_placeholder">Firefox, игры, эмуляторы…</string>
<string name="store_search">Поиск</string>
<string name="store_all">Все</string>
<string name="store_searching">Поиск…</string>
<string name="store_search_hint">Ищите по названию или выберите категорию. Здесь перечислены только приложения ARM64.</string>
<string name="store_no_results">В Flathub нет подходящих приложений ARM64.</string>
<string name="store_results">Результаты</string>
<string name="store_check_updates">Проверить обновления</string>
<string name="store_update_all">Обновить все (%1$d)</string>
<string name="store_all_current">● Все актуально</string>
<string name="store_no_apps">Приложений пока нет. Ищите в «Обзоре» или «Поиске».</string>
<string name="store_setup_first_note">Настройте Flatpak, чтобы устанавливать приложения.</string>
<string name="store_update_available_prefix">Доступно обновление · %1$s</string>
<string name="store_open">Открыть</string>
<string name="store_details">Подробности</string>
<string name="store_remove">Удалить</string>
<string name="store_press_again">Нажмите еще раз для удаления</string>
<string name="store_installing">Установка…</string>
<string name="store_install">Установить</string>
<string name="store_setup_first">Сначала настройте Flatpak</string>
<string name="store_download_size">Скачать %1$s</string>
<string name="store_installed_chip">● Установлено</string>
<string name="store_details_failed">Не удалось загрузить страницу Flathub этого приложения.</string>
<string name="store_loading">Загрузка из Flathub…</string>
<string name="store_no_arm">В Flathub нет сборки ARM64, поэтому установка здесь невозможна.</string>
<string name="store_screenshots">Скриншоты</string>
<string name="store_about">О приложении</string>
<string name="store_app_id">ID приложения</string>
<string name="store_license">Лицензия</string>
<string name="store_runtime">Среда выполнения</string>
<string name="store_installed_size">Установленный размер</string>
<string name="store_installed_size_value">%1$s (плюс общая среда выполнения)</string>
<string name="store_website">Веб-сайт</string>
<string name="store_verified">Проверено</string>
<string name="upd_title">Обновления</string>
<string name="upd_build">DroidDeck %1$s · %2$s</string>
<string name="upd_checked">Проверено %1$s</string>
<string name="upd_not_checked">Не проверено</string>
<string name="upd_perm_title">Разрешить DroidDeck самообновление</string>
<string name="upd_perm_text">Включите «Разрешить из этого источника» на следующей странице и вернитесь. Обновление продолжится автоматически.</string>
<string name="upd_open_settings">Открыть настройки</string>
<string name="upd_not_now">Не сейчас</string>
<string name="upd_signed_status">Другая подпись</string>
<string name="upd_signed_headline">Невозможно обновить на месте</string>
<string name="upd_signed_detail">Эта копия подписана не ключом выпуска DroidDeck, поэтому ее нельзя перезаписать напрямую. Удалите и переключитесь.</string>
<string name="upd_available">Доступно обновление</string>
<string name="upd_blocked_status">Невозможно установить</string>
<string name="upd_switch">Можно переключить</string>
<string name="upd_ahead_status">Опережает Stable</string>
<string name="upd_ahead_headline">Вы опережаете Stable</string>
<string name="upd_ahead_detail">Эта сборка новее последней Stable. Вы переключитесь на Stable после выпуска следующей версии Stable.</string>
<string name="upd_gone_status">Тест завершен</string>
<string name="upd_gone_headline">Этот тест завершен</string>
<string name="upd_gone_detail">Исправление было объединено или отменено. Следите за Preview, чтобы получать последние исправления.</string>
<string name="upd_current_status">Актуально</string>
<string name="upd_current_headline">У вас последняя версия %1$s</string>
<string name="upd_dot_ago">· %1$s</string>
<string name="upd_stage_percent">%1$s… %2$d%%</string>
<string name="upd_stage">%1$s…</string>
<string name="upd_update_button">Обновить %1$s</string>
<string name="upd_install_button">Установить %1$s</string>
<string name="upd_install_anyway">Все равно установить Stable %1$s</string>
<string name="upd_follow_preview">Следить за Preview</string>
<string name="upd_stop_session">Остановите текущую сессию перед обновлением.</string>
<string name="upd_less">Свернуть</string>
<string name="upd_more">Еще</string>
<string name="upd_new_stable">DroidDeck %1$s</string>
<string name="upd_new_preview">Новая сборка Preview</string>
<string name="upd_new_test">Тестовая сборка PR #%1$d</string>
<string name="upd_mb">%1$d МБ</string>
<string name="upd_channel">Канал обновлений</string>
<string name="upd_stable">Stable</string>
<string name="upd_stable_hint">Протестированная версия, подходит большинству</string>
<string name="upd_version_ago">%1$s · %2$s</string>
<string name="upd_preview">Preview</string>
<string name="upd_preview_hint">Последние исправления перед Stable</string>
<string name="upd_preview_current">Актуально</string>
<string name="upd_tests">Тестовые сборки</string>
<string name="upd_tests_hint">Попробовать исправление до официального выпуска</string>
<string name="upd_tests_none">Сейчас нет</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Не собрано для этой копии DroidDeck</string>
<string name="upd_updated">%1$s обновлено</string>
<string name="upd_test_name">Тестовая версия PR #%1$d</string>
<string name="upd_while_ago">Недавно</string>
<string name="upd_just_now">Только что</string>
<plurals name="upd_tests_count">
<item quantity="one">%1$d доступный тест</item>
<item quantity="few">%1$d доступных теста</item>
<item quantity="many">%1$d доступных тестов</item>
<item quantity="other">%1$d доступных тестов</item>
</plurals>
<plurals name="upd_preview_behind">
<item quantity="one">Изменения с момента вашей сборки · Отставание на %1$d сборку</item>
<item quantity="few">Изменения с момента вашей сборки · Отставание на %1$d сборки</item>
<item quantity="many">Изменения с момента вашей сборки · Отставание на %1$d сборок</item>
<item quantity="other">Изменения с момента вашей сборки · Отставание на %1$d сборки</item>
</plurals>
<plurals name="upd_preview_recent">
<item quantity="one">Недавние изменения · %1$d сборка</item>
<item quantity="few">Недавние изменения · %1$d сборки</item>
<item quantity="many">Недавние изменения · %1$d сборок</item>
<item quantity="other">Недавние изменения · %1$d сборки</item>
</plurals>
<string name="gate_title">Одна настройка Android, которую нужно изменить</string>
<string name="gate_eyebrow">Steam</string>
<string name="gate_lede">Android останавливает приложения, запускающие множество фоновых процессов, а Steam запускает десятки. До изменения этой настройки игры могут закрываться без сообщения об ошибке.</string>
<string name="gate_fix_for_me">Исправить за меня</string>
<string name="gate_toggle_steps">Включите «Отключить ограничение дочерних процессов» и вернитесь. Оставьте параметры разработчика включенными, иначе переключатель снова отключится.</string>
<string name="gate_open_dev">Открыть параметры разработчика</string>
<string name="gate_no_toggle">В этой версии Android нет такого переключателя, поэтому DroidDeck внесет изменения через отладку по Wi-Fi. Это займет около минуты, компьютер не нужен.</string>
<string name="gate_needs_wifi">Для отладки по Wi-Fi требуется Wi-Fi. Подойдет любая сеть, интернет не нужен.</string>
<string name="gate_needs_notifications">Код сопряжения отобразится в уведомлении. Разрешите DroidDeck отправлять уведомления или используйте «Другой способ».</string>
<string name="gate_allow_notifications">Разрешить уведомления</string>
<string name="gate_step1">Откройте Настройки → Для разработчиков → Отладка по Wi-Fi и включите ее</string>
<string name="gate_step2">Нажмите «Сопряжение устройства с помощью кода»</string>
<string name="gate_step3">Введите код сопряжения в уведомлении DroidDeck</string>
<string name="gate_waiting">Ожидание окна сопряжения…</string>
<string name="gate_code_needed">Окно найдено. Введите код сопряжения из него в уведомление.</string>
<string name="gate_status">Статус</string>
<string name="gate_polling">Проверка каждые 2 секунды. После отключения этого переключателя страница закроется автоматически.</string>
<string name="gate_other_ways">Другие способы</string>
<string name="gate_other_toggle">Отладка по Wi-Fi, ручной ввод адреса, ADB на ПК</string>
<string name="gate_other_no_toggle">Ручной ввод адреса, ADB на ПК, параметры разработчика</string>
<string name="gate_use_wireless_instead">Использовать отладку по Wi-Fi</string>
<string name="gate_some_roms">Некоторые ROM все еще предоставляют этот переключатель. Найдите «дочерние процессы» в параметрах разработчика.</string>
<string name="gate_manual_hint">Если способ с уведомлением не работает, введите адрес и код сопряжения из окна вручную.</string>
<string name="gate_enter_manually">Ввести адрес вручную</string>
<string name="gate_from_computer">Использовать ADB с компьютера:</string>
<string name="gate_copy_command">Копировать команду</string>
<string name="gate_choose_display">Выберите экран для открытия настроек Android.</string>
<string name="gate_main_screen">Главный экран</string>
<string name="wadb_finding_port">Сопряжено. Поиск порта отладки по Wi-Fi…</string>
<string name="wadb_enter_port">Сопряжено. Введите порт из настроек.</string>
<string name="wadb_applying">Устройство найдено. Применение настроек…</string>
<string name="wadb_apply_failed">Не удалось применить автоматически. Проверьте адрес ниже и повторите попытку. %1$s</string>
<string name="wadb_title_wireless">Отладка по Wi-Fi</string>
<string name="wadb_title_done">Ограничение дочерних процессов обновлено</string>
<string name="wadb_title">Изменить ограничение дочерних процессов</string>
<string name="wadb_lede_compact0">Если опции нет, выполните сопряжение через отладку по Wi-Fi.</string>
<string name="wadb_lede_compact1">Сопряжено. Проверьте IP-адрес и порт в отладке по Wi-Fi.</string>
<string name="wadb_lede_done">Ограничение дочерних процессов обновлено.</string>
<string name="wadb_lede0">Если в параметрах разработчика нет настройки дочерних процессов, используйте шаг 2. Откройте Для разработчиков → Отладка по Wi-Fi → Сопряжение устройства с помощью кода.</string>
<string name="wadb_lede1">Сопряжено. Используйте адрес ниже или обновите его в разделе Отладка по Wi-Fi → IP-адрес и порт.</string>
<string name="wadb_lede_done_long">Ограничение дочерних процессов обновлено через отладку по Wi-Fi.</string>
<string name="wadb_confirmed_on">Android подтвердил, что ограничение включено.</string>
<string name="wadb_confirmed_off">Android подтвердил, что ограничение отключено.</string>
<string name="wadb_pair_group">2 · Сопряжение отладки по Wi-Fi</string>
<string name="wadb_pair_address">IP-адрес и порт из окна сопряжения</string>
<string name="wadb_pin">Код сопряжения (PIN)</string>
<string name="wadb_pin_placeholder">6 цифр</string>
<string name="wadb_pair">Сопряжение</string>
<string name="wadb_apply_group">2 · Применение через отладку по Wi-Fi</string>
<string name="wadb_connect_address">Подключить IP-адрес и порт</string>
<string name="wadb_pair_again">Сопряжение заново</string>
<string name="wadb_turn_off">Отключить ограничение</string>
<string name="wadb_other_ways">Другие способы изменения</string>
<string name="wadb_dev_try_first">1 · Для разработчиков (попробовать первым)</string>
<string name="wadb_restrict_on">Включите «Ограничение дочерних процессов».</string>
<string name="wadb_restrict_off">Отключите «Ограничение дочерних процессов».</string>
<string name="wadb_computer_last">3 · ADB на ПК (крайняя мера)</string>
<string name="wadb_only_if">Используйте только если отладка по Wi-Fi недоступна.</string>
<string name="wadb_computer_group">3 · ADB на ПК (крайняя мера)</string>
<string name="ctrl_osc_hint">При показе экранных элементов управления в сессии</string>
<string name="ctrl_color">Цвет</string>
<string name="ctrl_color_hint">Оттенок экранных кнопок</string>
<string name="ctrl_custom">Пользовательский</string>
<string name="ctrl_opacity">Непрозрачность</string>
<string name="ctrl_percent">%1$d%%</string>
<string name="ctrl_size">Размер кнопок</string>
<string name="ctrl_size_hint" formatted="false">100% — стандартный размер</string>
<string name="ctrl_stick_click">Нажатие стика</string>
<string name="ctrl_stick_click_hint">Дважды нажмите и удерживайте стик для L3 или R3</string>
<string name="ctrl_adaptive">Адаптивный стик</string>
<string name="ctrl_rumble">Вибрация игры</string>
<string name="ctrl_steam_button">Кнопка Steam</string>
<string name="ctrl_qam_button">Кнопка QAM</string>
<string name="ctrl_keyboard_button">Кнопка клавиатуры</string>
<string name="setup_animations">Анимации</string>
<string name="ctrl_adaptive_hint">Стик появится в месте касания и скроется после отпускания</string>
<string name="ctrl_layout">Раскладка</string>
<string name="ctrl_layout_custom">Сохранена пользовательская позиция</string>
<string name="ctrl_layout_default">Адаптировано под этот экран</string>
<string name="ctrl_edit">Изменить</string>
<string name="ctrl_reset">Сбросить</string>
<string name="ctrl_mapping">Сопоставление кнопок</string>
<string name="ctrl_mapping_default">Каждая кнопка отправляет соответствующий исходный ввод</string>
<string name="ctrl_mapping_count">%1$d/%2$d кнопок переназначено</string>
<string name="ctrl_configure">Настроить</string>
<string name="ctrl_reset_all">Сбросить контроллер</string>
<string name="ctrl_reset_all_hint">Восстановить все настройки и раскладки контроллера</string>
<string name="ctrl_mapping_lede">Настройте ввод, отправляемый каждой экранной кнопкой в игру. «Скрыть» удалит кнопку.</string>
<string name="ctrl_onscreen_buttons">Экранные кнопки</string>
<string name="ctrl_defaults">По умолчанию</string>
<string name="ctrl_reset_mapping">Сбросить сопоставление</string>
<string name="content_android_apps">Приложения Android</string>
<string name="content_apps_count">%1$d приложений</string>
<string name="content_no_android_apps">Не найдено приложений Android для запуска.</string>
<string name="content_desktop_installed">● Рабочий стол установлен</string>
<string name="content_desktop_first_open">Устанавливается при первом открытии</string>
<string name="content_available">Доступно для установки</string>
<string name="content_not_installed">Не установлено.</string>
<string name="content_not_installed_suffix">%1$s · не установлено</string>
<string name="content_open_named">Открыть %1$s</string>
<string name="content_games">Игры</string>
<string name="content_choose_roms">Выберите папку ROM.</string>
<string name="content_retroarch_roms">В RetroArch перейдите в /root/ROMs.</string>
<string name="content_add_roms">Добавлено %1$s игр в ROMs/%2$s.</string>
<string name="content_install_named">Установить %1$s</string>
<string name="content_stop_to_install">Остановите сессию для установки</string>
<string name="content_runtime_required">Требуется среда выполнения</string>
<string name="content_loading_details">Загрузка подробностей установки…</string>
<string name="content_details_unavailable">Подробности установки недоступны. Повторите попытку позже.</string>
<string name="content_needs_runtime">Сначала установите среду выполнения Linux в «Настройках».</string>
<string name="content_stop_first">Остановите текущую сессию перед установкой приложения рабочего стола.</string>
<string name="content_install_into">Установить %1$s на рабочий стол Linux.</string>
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
<string name="content_hide_note">Скрытие удалит его с рабочего стола. Файлы останутся в среде выполнения Linux.</string>
<string name="content_rom_gone">Этой игры больше нет в папке ROM.</string>
<string name="content_desktop_system">Рабочий стол · %1$s</string>
<string name="content_launch_in">Запустить в %1$s</string>
<string name="content_also_in">Также в %1$s</string>
<string name="content_page_gone">Эта страница больше не существует. Выберите раздел слева.</string>
<string name="content_open_desktop">Открыть рабочий стол</string>
<string name="content_install_open_desktop">Установить и открыть рабочий стол</string>
<string name="content_linux_desktop">Рабочий стол Linux</string>
<string name="content_linux_desktop_hint">LXQt, Firefox и ваши эмуляторы</string>
<string name="games_empty">Игры, установленные в Steam, появятся здесь после завершения сессии.</string>
<string name="games_play_steam">Запустить Steam</string>
<string name="games_launch_settings">Настройки запуска</string>
<string name="games_launch">Запустить</string>
<string name="games_files">Файлы игры</string>
<string name="games_prefix">Префикс Proton</string>
<string name="games_saves_looking">Поиск сохранений…</string>
<string name="games_saves_none">Папка сохранений не найдена по имени. При экспорте будет включена вся папка пользователя.</string>
<string name="games_saves_folders">%1$d папок сохранений</string>
<string name="games_saves_summary">%1$s · %2$d файлов · %3$s</string>
<string name="games_kb">%1$d КБ</string>
<string name="games_manage_saves">Управление сохранениями</string>
<string name="games_saves">Сохранения игры</string>
<string name="games_import_saves">Импорт сохранений…</string>
<string name="games_import_saves_hint">Архив GameHub или Winlator. Перед импортом текущие сохранения будут сохранены в резервной копии.</string>
<string name="games_export_gamehub">Экспорт как архив GameHub…</string>
<string name="games_export_gamehub_hint">Подходит для GameHub и BannerHub</string>
<string name="games_export_winlator">Экспорт как архив Winlator…</string>
<string name="games_export_winlator_hint">Подходит для Winlator, WinNative и Bannerlator</string>
<string name="games_open_save_folder">Открыть папку сохранений</string>
<string name="games_never_played">Никогда не запускалась</string>
<string name="games_runtime_busy_percent">Среда выполнения занята · %1$d%%</string>
<string name="games_runtime_busy">Среда выполнения занята</string>
<string name="games_runtime_first_play">Установка среды выполнения при первом запуске игры</string>
<string name="games_runtime_update">Доступно обновление среды выполнения</string>
<string name="games_runtime_ready">● Среда выполнения готова</string>
<string name="games_last_played">Последний запуск %1$s</string>
<string name="games_internal">Внутреннее хранилище</string>
<string name="games_added">Добавленные игры</string>
<string name="gpu_auto_hint">Выбирает лучшую комбинацию драйверов для этого GPU и поддерживает ее в актуальном состоянии</string>
<string name="gpu_manual_hint">Выбрать комбинацию драйверов вручную</string>
<string name="gpu_checking">Проверка драйверов…</string>
<string name="gpu_none_listed">Драйверы пока не перечислены. Обновите, чтобы найти драйверы.</string>
<string name="gpu_hide_other">Скрыть драйверы других GPU</string>
<string name="gpu_show_other">Показать драйверы других GPU</string>
<string name="gpu_advanced">Дополнительно</string>
<string name="gpu_set_with_display">Настраивается вместе с драйвером дисплея: %1$s включает оба.</string>
<string name="gpu_set_with_runtime">Настраивается вместе с драйвером среды выполнения: %1$s включает оба.</string>
<string name="gpu_runtime_hint">Используется играми и рабочим столом.</string>
<string name="gpu_runtime_default">По умолчанию среды выполнения</string>
<string name="gpu_display_hint">Отвечает за вывод изображения на экран. Вступит в силу после перезапуска DroidDeck.</string>
<string name="gpu_auto_by_gpu">Авто - Выбор по GPU</string>
<string name="gpu_manage">Скачать, импортировать или удалить…</string>
<string name="gpu_in_use_caps">Используется</string>
<string name="gpu_builtin">Встроенный драйвер DroidDeck</string>
<string name="gpu_matched_pair">Драйвер среды выполнения и драйвер дисплея настраиваются вместе как совместимая пара.</string>
<string name="gpu_recommended_for">Рекомендуется для этого GPU: %1$s.</string>
<string name="gpu_refresh_hint">Обновите, чтобы найти драйверы, подходящие для этого GPU.</string>
<string name="gpu_recommended_caps">Рекомендуется</string>
<string name="gpu_unavailable">Недоступно</string>
<string name="load_runtime">Среда выполнения Linux</string>
<string name="load_runtime_detail">Подготовка среды выполнения Linux</string>
<string name="load_session">Сессия</string>
<string name="load_session_detail">Запуск дисплея и аудио</string>
<string name="load_steam">Клиент Steam</string>
<string name="load_steam_detail">Проверка Steam и его инструментов совместимости</string>
<string name="load_opening_steam">Открытие Steam</string>
<string name="load_opening_steam_detail">Ожидание первого кадра Steam</string>
<string name="load_desktop">Рабочий стол</string>
<string name="load_desktop_detail">Подготовка рабочего стола</string>
<string name="load_opening">Открытие</string>
<string name="load_opening_detail">Ожидание первого кадра</string>
<string name="load_starting_steam">Запуск Steam</string>
<string name="load_ended">Сессия завершена</string>
<string name="load_step">Шаг %1$d из %2$d</string>
<string name="load_steam_paused">Steam приостановлен</string>
<string name="deck_tab_grips">Грипсы</string>
<string name="deck_tab_left">Левый тачпад</string>
<string name="deck_tab_right">Правый тачпад</string>
<string name="deck_tab_both">Оба</string>
<string name="deck_close">Закрыть управление вторым экраном</string>
<string name="deck_open_steam_menu">Открыть меню Steam</string>
<string name="deck_left_upper">Слева · Сверху</string>
<string name="deck_left_lower">Слева · Снизу</string>
<string name="deck_right_upper">Справа · Сверху</string>
<string name="deck_right_lower">Справа · Снизу</string>
<string name="deck_click">Нажать</string>
<string name="deck_right_trackpad">Правый тачпад</string>
<string name="deck_left_trackpad">Левый тачпад</string>
<string name="second_trackpad">Тачпад</string>
<string name="second_show_keyboard">Показать клавиатуру</string>
<string name="second_hide_keyboard">Скрыть клавиатуру</string>
<string name="second_terminal">Терминал Linux</string>
<string name="second_terminal_not_ready">Сессия Linux не готова, терминал недоступен.</string>
<string name="rail_steam">Steam</string>
<string name="rail_games">Игры</string>
<string name="rail_desktop">Рабочий стол</string>
<string name="rail_store">Магазин</string>
<string name="rail_components">Компоненты</string>
<string name="rail_apps">Приложения</string>
<string name="rail_setup">Настройка</string>
<string name="rail_updates">Обновления</string>
<string name="rail_percent">%1$d%%</string>
<string name="rail_resume_named">Продолжить %1$s</string>
<string name="roms_text">Отображается как /root/ROMs в каждой сессии. Внутреннее хранилище находится в /root/Storage.</string>
<string name="roms_none">Папка не выбрана</string>
<string name="roms_applies">Вступит в силу в следующей сессии. Поддерживаются SD-карты.</string>
<string name="roms_choose">Выбрать папку</string>
<string name="roms_change">Изменить папку</string>
<string name="proton_lede">Установите сборку ARM64 Proton, затем выберите ее для каждой игры в Steam → Свойства → Совместимость.</string>
<string name="proton_stop_first">Остановите текущую сессию перед установкой или удалением инструментов совместимости.</string>
<string name="proton_available">Доступные сборки</string>
<string name="proton_installed">%1$s установлено</string>
<string name="proton_queued">Ожидающий элемент от предыдущего запроса</string>
<string name="proton_not_installed">Не установлено</string>
<string name="proton_install_now">Установить сейчас</string>
<string name="proton_cancel_queued">Отменить очередь</string>
<string name="pckb_android">Клавиатура Android</string>
<string name="pckb_hide">Скрыть ✕</string>
<string name="main_launch_named">Запустить %1$s</string>
<string name="main_choose_screen">Выберите экран. Дополнительный экран: %1$s.</string>
<string name="main_secondary_gone">Дополнительный экран недоступен.</string>
<string name="main_primary">Главный экран</string>
<string name="main_secondary">Дополнительный экран</string>
<string name="widgets_prev_tab">Предыдущая вкладка</string>
<string name="widgets_next_tab">Следующая вкладка</string>
<string name="widgets_on">Вкл.</string>
<string name="widgets_all_n">Все %1$d</string>
<string name="widgets_none">Нет</string>
<string name="widgets_n_of_m">%1$d/%2$d</string>
<string name="widgets_off">Выкл.</string>
<string name="screen_bottom">Нижний экран</string>
<string name="game_env_applies">Вступит в силу при следующем запуске игры. Профиль игры переопределит общие настройки.</string>
<string name="game_env_other">Выбрать другую игру по ID приложения…</string>
<string name="game_env_done">Готово</string>
<string name="game_env_default">По умолчанию</string>
<string name="game_env_off">Выкл.</string>
<string name="game_env_change">Изменить…</string>
<string name="game_env_change_title">Изменить переменную</string>
<string name="game_env_restore_default">Восстановить по умолчанию</string>
<string name="game_env_eyebrow">Игры Proton</string>
<string name="pip_title">Картинка в картинке</string>
<string name="pip_auto">Автоматически переходить в режим PiP</string>
<string name="pip_suspended">Приостановлено</string>
<string name="pip_not_ready">PiP станет доступен после готовности сессии.</string>
<string name="pip_permission">Разрешите DroidDeck использовать PiP в настройках Android.</string>
<string name="pip_unavailable">Android не может открыть PiP.</string>
<string name="game_shortcut">Ярлык</string>
<string name="game_shortcut_add">Добавить на главный экран</string>
<string name="game_shortcut_choose">Выбрать игру</string>
<string name="game_shortcut_scanning">Сканирование установленных игр…</string>
<string name="game_link_copy">Копировать ссылку запуска</string>
<string name="game_link_copied">Ссылка запуска скопирована</string>
<string name="game_shortcut_unsupported">Этот лаунчер не может добавлять ярлыки. Используйте «Копировать ссылку запуска».</string>
<string name="game_link_invalid">Недействительная ссылка запуска игры.</string>
<string name="game_link_missing">Этой игры больше нет в вашей библиотеке DroidDeck.</string>
<string name="game_link_session_busy">Остановите текущую сессию перед запуском игры Steam.</string>
<string name="game_link_failed">Не удалось отправить игру в Steam. Попробуйте запустить снова.</string>
<string name="setup_retry_removal">Повторить удаление</string>
<string name="runtime_removing">Удаление</string>
<string name="runtime_removal_incomplete">Удаление не завершено</string>
<string name="game_file_export">Экспорт файлов игры</string>
<string name="game_file_sync">Синхронизировать все файлы игры в папку</string>
<string name="game_file_stop_sync">Остановить синхронизацию файлов игры</string>
<string name="game_file_folder">Выбрать папку для файлов игры фронтенда</string>
<string name="game_file_exported">Файлы игры сохранены в %1$s</string>
<string name="game_frontend_files">Файлы фронтенда</string>
</resources>
+62 -3
View File
@@ -74,11 +74,21 @@
<string name="game_env_vkd3d_config">D3D12 的 VKD3D 选项。nodxr 禁用光线追踪;仅供需要它的游戏使用。</string>
<string name="game_env_proton_log">设为 1 可生成 Proton 诊断日志。记录日志会降低性能并占用存储。</string>
<string name="game_env_wined3d">设为 1 可对 D3D9–11 使用基于 OpenGL 的 WineD3D 代替 DXVK 进行兼容性测试。</string>
<string name="game_env_no_ntsync">设为 1 可让此游戏禁用 ntsync(droiddeck-ntsync 或内核驱动);存在对应包时使用 droiddeck-esync,否则使用 droiddeck-fsync。</string>
<string name="game_env_no_esync">设为 1 可为此游戏禁用 droiddeck-esync;改用 droiddeck-fsync,若 fsync 也已禁用则使用 droiddeck-ntsync。</string>
<string name="game_env_no_fsync">设为 1 可让此游戏禁用 droiddeck-fsync;存在对应包时使用 droiddeck-esync,否则使用 droiddeck-ntsync。</string>
<string name="game_env_fsync_first">设为 1 可让此游戏使用 droiddeck-fsync;设为 0 则在有匹配其 Proton 的包时优先使用 droiddeck-esync。</string>
<string name="game_env_xalia">设为 0 可为此游戏禁用 Proton 的手柄导航助手。</string>
<string name="game_env_hud">针对 D3D9–11 的 DXVK 覆盖层。fps 显示帧率;不设置则隐藏。</string>
<string name="game_env_dxvk_config">以英文分号(;)分隔的 DXVK 设置。新版本使用 dxvk.maxFrameRate 限制帧率;日常游戏建议优先使用会话帧率限制。</string>
<string name="game_env_vkd3d_limit">D3D12 帧率限制。0 表示不限制。</string>
<string name="game_env_glthread">Mesa OpenGL 多线程。true 可能有助于受 CPU 限制的 OpenGL 游戏;请逐个游戏测试。</string>
<string name="sync_backend_title">Wine 同步</string>
<string name="sync_backend_hint">游戏保持线程同步的方式。将在下次会话生效。</string>
<string name="sync_backend_esync">esync</string>
<string name="sync_backend_ntsync">ntsync</string>
<string name="sync_backend_fsync">fsync</string>
<string name="sync_backend_wineserver">wineserver</string>
<string name="fex_preset_title">FEX 预设</string>
<string name="fex_next_launch">将在下次启动游戏时生效。</string>
<string name="game_settings_title">游戏</string>
@@ -151,6 +161,15 @@
<string name="add_app_script_hint">一个 .sh 文件。它在自己的文件夹中运行,全屏显示在 gamescope 下。</string>
<string name="add_app_appimage_hint">ARM64(aarch64)AppImage。只会解包一次到 Linux 运行时中。</string>
<string name="add_app_github_hint">从仓库的最新发布版本安装 ARM64 AppImage。</string>
<string name="app_fex_title">使用 FEX</string>
<string name="app_fex_auto">自动</string>
<string name="app_fex_on">开启</string>
<string name="app_fex_off">关闭</string>
<string name="app_fex_hint">FEX 在此 ARM64 设备上运行 x86 Linux 程序。自动模式会在程序为 x86 构建时启用。</string>
<string name="app_fex_x86">%1$s 经由 FEX</string>
<string name="app_fex_not_ready">此程序面向 %1$s PC 构建,需经由 FEX 运行,而 FEX 尚未配置。请在登录状态下打开一次 Steam:DroidDeck 会让其下载 FEX 及所需的 Steam Linux Runtime,之后即可打开。</string>
<string name="session_end_program">此程序</string>
<string name="session_end_fex_missing">%1$s 面向 x86 PC 构建,需经由 FEX 运行,而 FEX 尚未安装。请在登录状态下启动一次 Steam,让其下载 FEX 与 Steam Linux Runtime,然后重新启动。</string>
<string name="add_app_flatpak_hint">一个 Flathub 应用 ID 或其 Flathub 页面。若尚未设置 Flatpak 会先进行设置。</string>
<string name="add_app_file">文件</string>
<string name="add_app_no_file">未选择文件</string>
@@ -186,6 +205,9 @@
<string name="add_app_cancel">取消</string>
<string name="add_app_runtime_required">请先在“配置”中安装 Linux 运行时。</string>
<string name="add_app_busy">请等待 %1$s 完成。</string>
<string name="steam_compat_label">兼容</string>
<string name="proton_compatible_note">在 Steam 兼容性菜单中标记为“%1$s”的构建带有 DroidDeck 的前缀修复,可在此设备启动游戏。默认为 Proton Experimental ARM64(%1$s),安装更新构建后则为 Proton 11.0 ARM64(%1$s)。未带标记的 Valve 官方 Proton 条目无法在此设备启动游戏。</string>
<string name="proton_installed_compatible">已安装 %1$s · %2$s</string>
<string name="common_cancel">取消</string>
<string name="common_auto">自动</string>
<string name="common_never">从不</string>
@@ -279,8 +301,6 @@
<string name="mode_added_launches_in">启动 %1$s · 位于 %2$s</string>
<string name="mode_added_choose">选择其他文件…</string>
<string name="mode_added_exe_note">默认为与文件夹同名的 .exe,否则为最大的那个。</string>
<string name="mode_fill">拉伸游戏以填满屏幕</string>
<string name="mode_fill_hint">使会自行缩放的游戏保持全屏。若游戏显示过小,请关闭此项。将在下次会话生效。</string>
<string name="mode_stretch169">拉伸 16:9 比例的游戏</string>
<string name="mode_stretch169_hint">在比 16:9 更方的屏幕上,拉伸游戏填满屏幕而不是显示黑边。将在下次会话生效。</string>
<string name="mode_audio">音频</string>
@@ -308,6 +328,8 @@
<string name="mode_storage_folder">文件夹:%1$s</string>
<string name="mode_second_library">第二库</string>
<string name="mode_storage_note">从 SD 卡或共享存储流式读取资源的游戏可能出现卡顿。</string>
<string name="mode_storage_diagnostics">存储诊断</string>
<string name="mode_storage_diagnostics_hint">在分享日志中记录分配结果和存储采样耗时。启用期间可能减慢安装。将在下次会话生效。</string>
<string name="mode_choose_folder">选择文件夹…</string>
<string name="mode_renderer">渲染器</string>
<string name="mode_desktop_renderer">桌面渲染器</string>
@@ -344,9 +366,16 @@
<string name="comp_check_nightlies">检查 Nightlies 上的新软件包</string>
<string name="comp_proton">Proton</string>
<string name="comp_proton_note">Steam 更新时会替换 Valve 的 Proton;每个构建的原件都会保留。</string>
<string name="comp_default_live">%1$s 现在将运行所有未在 Steam 中单独选择 Proton 的游戏。</string>
<string name="comp_default_next">从下次 Steam 启动起,%1$s 将运行所有未单独选择 Proton 的游戏。</string>
<string name="comp_default_not_runnable">%1$s 无法在此设备启动游戏,因此不能设为默认。</string>
<string name="comp_default_failed">无法保存默认 Proton:%1$s</string>
<string name="comp_swapped">%1$s · 已替换 %2$d 项</string>
<string name="comp_all_original">%1$s · 全部为原件</string>
<string name="comp_game_running_suffix">%1$s · 游戏运行中</string>
<string name="comp_esync_pack">%1$s · droiddeck-esync 包</string>
<string name="comp_esync_wanted">%1$s · 尚无 droiddeck-esync 包</string>
<string name="comp_esync_builtin">%1$s · 内置 esync,无需包</string>
<string name="comp_gpu_drivers">GPU 驱动</string>
<string name="comp_reapplied">%1$s · 于启动 %2$s 时重新应用</string>
<string name="comp_next_queued">下一个:%1$s · 在游戏关闭后</string>
@@ -409,6 +438,12 @@
<string name="perf_sysmem">Turnip:sysmem 渲染</string>
<string name="perf_sysmem_hint">对 Steam 和大多数游戏更快。Adreno 710/720/722 必需。</string>
<string name="perf_xalia">跳过 Steam 的 xalia 助手</string>
<string name="perf_fastsync">droiddeck-ntsync(实验性)</string>
<string name="perf_fastsync_hint">Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。</string>
<string name="perf_esync">droiddeck-esync</string>
<string name="perf_fsync">droiddeck-fsync 优先</string>
<string name="perf_fsync_hint">在原始 Proton 文件上运行 Proton 自带的 fsync,由用户态提供 futex_waitv,因此无需任何包,且 Steam 更新 Proton 后立即继续可用。开启时,除 Proton 自身禁用 fsync 的少数游戏外,各游戏优先使用它而非 droiddeck-esync。将在下次会话生效。</string>
<string name="perf_esync_hint">游戏的 Windows 线程通过 eventfd 和共享内存相互等待,而非经由 wineserver 往返。使用为该 Proton 构建精确制作的 droiddeck-esync 包;包会自动获取。在新 Proton 构建尚无对应包之前,由 droiddeck-fsync 顶替。将在下次会话生效。</string>
<string name="perf_xalia_hint">禁用 Proton 的手柄导航助手,它会在每个游戏中消耗 CPU。</string>
<string name="perf_seccomp">不启用 seccomp 运行 proot</string>
<string name="perf_seccomp_hint">可能修复某些内核上的缺失系统调用错误。可能降低性能。</string>
@@ -429,6 +464,7 @@
<string name="driver_delete_plain">它的文件会从应用中移除。</string>
<string name="driver_delete_in_use">文件将被移除。该驱动正在使用,将由默认驱动接管。</string>
<string name="drawer_page_display">显示</string>
<string name="drawer_page_effects">画面效果</string>
<string name="drawer_page_controls">控制</string>
<string name="drawer_page_components">组件</string>
<string name="drawer_page_settings">设置</string>
@@ -441,7 +477,30 @@
<string name="drawer_prev_page">上一页</string>
<string name="drawer_next_page">下一页</string>
<string name="drawer_hud">性能 HUD</string>
<string name="drawer_fill">拉伸游戏填满屏幕</string>
<string name="drawer_scaling">缩放模式</string>
<string name="drawer_scaling_sharpness">缩放锐度</string>
<string name="drawer_effects">屏幕效果</string>
<string name="drawer_effects_hint">实时生效。任何效果开启期间会占用少量 GPU 时间。</string>
<string name="drawer_look">观感</string>
<string name="drawer_look_custom">自定义</string>
<string name="drawer_cas">锐化(CAS)</string>
<string name="drawer_cas_level">锐化级别</string>
<string name="drawer_fake_hdr">伪 HDR</string>
<string name="drawer_deband">去色带</string>
<string name="drawer_deband_strength">去色带强度</string>
<string name="drawer_brightness">亮度</string>
<string name="drawer_contrast">对比度</string>
<string name="drawer_gamma">伽马</string>
<string name="drawer_saturation">饱和度</string>
<string name="drawer_fxaa">FXAA(边缘平滑)</string>
<string name="drawer_toon">卡通描边</string>
<string name="drawer_crt">CRT</string>
<string name="drawer_ntsc">NTSC</string>
<string name="drawer_texture">纹理过滤</string>
<string name="drawer_texture_hint">DirectX 9–11 游戏,下次启动时生效。</string>
<string name="drawer_anisotropy">各向异性过滤</string>
<string name="drawer_texture_sharpness">纹理锐度</string>
<string name="drawer_texture_sharpness_note">负 mipmap 偏移:纹理更锐利,可能有轻微闪烁。自动模式会匹配会话放大到屏幕的程度。</string>
<string name="drawer_touch_auto">自动(%1$s)</string>
<string name="drawer_keyboard">键盘</string>
<string name="drawer_pc_keyboard">PC 键盘</string>
@@ -279,8 +279,6 @@
<string name="mode_added_launches_in">啟動 %1$s · 位於 %2$s</string>
<string name="mode_added_choose">選擇其他檔案…</string>
<string name="mode_added_exe_note">默認使用與文件夾同名的 .exe,否則使用最大的 .exe。</string>
<string name="mode_fill">拉伸遊戲以填滿屏幕</string>
<string name="mode_fill_hint">讓支持自行調整大小的遊戲保持全屏幕。如果遊戲顯示得很小,請關閉此項。下次會話生效。</string>
<string name="mode_stretch169">拉伸 16:9 遊戲</string>
<string name="mode_stretch169_hint">在寬高比小於 16:9 的屏幕上,將遊戲拉伸以填滿屏幕,而不是顯示黑邊。下次會話生效。</string>
<string name="mode_audio">音頻</string>
@@ -442,7 +440,6 @@
<string name="drawer_prev_page">上一頁</string>
<string name="drawer_next_page">下一頁</string>
<string name="drawer_hud">性能 HUD</string>
<string name="drawer_fill">拉伸遊戲以填滿屏幕</string>
<string name="drawer_touch_auto">自動(%1$s)</string>
<string name="drawer_keyboard">鍵盤</string>
<string name="drawer_pc_keyboard">電腦鍵盤</string>
@@ -279,8 +279,6 @@
<string name="mode_added_launches_in">啟動 %1$s · 位於 %2$s</string>
<string name="mode_added_choose">選擇其他檔案…</string>
<string name="mode_added_exe_note">預設使用與資料夾同名的 .exe,否則使用最大的 .exe。</string>
<string name="mode_fill">拉伸遊戲以填滿螢幕</string>
<string name="mode_fill_hint">讓支援自行調整大小的遊戲保持全螢幕。如果遊戲顯示得很小,請關閉此項。下次工作階段生效。</string>
<string name="mode_stretch169">拉伸 16:9 遊戲</string>
<string name="mode_stretch169_hint">在長寬比小於 16:9 的螢幕上,將遊戲拉伸以填滿螢幕,而不是顯示黑邊。下次工作階段生效。</string>
<string name="mode_audio">音訊</string>
@@ -442,7 +440,6 @@
<string name="drawer_prev_page">上一頁</string>
<string name="drawer_next_page">下一頁</string>
<string name="drawer_hud">效能 HUD</string>
<string name="drawer_fill">拉伸遊戲以填滿螢幕</string>
<string name="drawer_touch_auto">自動(%1$s)</string>
<string name="drawer_keyboard">鍵盤</string>
<string name="drawer_pc_keyboard">電腦鍵盤</string>
+65 -4
View File
@@ -263,6 +263,8 @@
<string name="mode_suspend">Background behavior</string>
<string name="mode_suspend_hint">When the app is hidden or the screen turns off.</string>
<string name="mode_suspend_manual">Manual</string>
<string name="mode_suspend_native">Native</string>
<string name="mode_suspend_steam_note">Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running.</string>
<string name="mode_suspend_note">Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running.</string>
<string name="mode_startup">Startup</string>
<string name="mode_steam_startup">Start Steam with DroidDeck</string>
@@ -291,6 +293,10 @@
<string name="mode_branch_public">Public beta</string>
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
<string name="mode_steam_repair">Repair Steam client</string>
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
<string name="mode_steam_repair_button">Repair</string>
<string name="mode_added_games">Added games</string>
<string name="mode_added_none">no game folders with a .exe found</string>
<string name="mode_games_folder">Games folder</string>
@@ -301,8 +307,29 @@
<string name="mode_added_launches_in">Launches %1$s · in %2$s</string>
<string name="mode_added_choose">Choose another file…</string>
<string name="mode_added_exe_note">Defaults to the .exe named after the folder, or else the largest.</string>
<string name="mode_fill">Stretch games to fill the screen</string>
<string name="mode_fill_hint">Keeps self-resizing games full screen. Turn off if a game shows up small. Applies next session.</string>
<string name="wincomp_title">Windows components</string>
<string name="wincomp_pad_hint">LB / RB jump between the sections.</string>
<string name="wincomp_card_none">None</string>
<string name="wincomp_applies">Windows DLLs Proton does not include, from Bannerlator\'s component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
<string name="wincomp_offline">Could not reach the component list. Components already downloaded can still be switched.</string>
<string name="wincomp_recommended">Recommended for this game</string>
<string name="wincomp_found_bundled">The game ships %1$s</string>
<string name="wincomp_found_shipped">The game has %1$s (often works as is)</string>
<string name="wincomp_found_steam">Steam installs %1$s with this game</string>
<string name="wincomp_already_here">Already in this game</string>
<string name="wincomp_no_recommendation">Nothing in the game\'s folder points at a component.</string>
<string name="wincomp_all">All components (%1$d)</string>
<string name="wincomp_installers">Need a Windows installer (%1$d)</string>
<string name="wincomp_installers_note">These run a real installer inside the prefix (.NET, PhysX, XNA, the full Visual C++ installers…). DroidDeck cannot do that yet; it is coming next.</string>
<string name="wincomp_show">Show</string>
<string name="wincomp_hide">Hide</string>
<string name="wincomp_needs_installer">Needs a Windows installer (coming)</string>
<string name="wincomp_unsupported">Not supported here yet</string>
<string name="wincomp_loading">Loading the components…</string>
<string name="wincomp_failed">Could not install: %1$s</string>
<string name="wincomp_downloaded">Downloaded</string>
<string name="wincomp_download_size">%1$s download</string>
<string name="wincomp_next_launch">Takes effect the next time the game starts.</string>
<string name="mode_stretch169">Stretch 16:9 games</string>
<string name="mode_stretch169_hint">On screens squarer than 16:9, stretches games to fill instead of showing bars. Applies next session.</string>
<string name="mode_audio">Audio</string>
@@ -331,6 +358,8 @@
<string name="mode_second_library">Second library</string>
<string name="mode_storage_note">Games that stream assets from SD or shared storage may stutter.</string>
<string name="mode_storage_diagnostics">Storage diagnostics</string>
<string name="mode_skip_preallocation">Skip space reservation (experimental)</string>
<string name="mode_skip_preallocation_hint">Steam starts downloading to the game storage folder straight away instead of reserving every file\'s space first, which can take many minutes on an SD card. If the card fills up, the download stops with a disk write error. Applies next session.</string>
<string name="mode_storage_diagnostics_hint">Record allocation results and sampled storage timings in Share logs. May slow installs while enabled. Applies next session.</string>
<string name="mode_choose_folder">Choose a folder…</string>
<string name="mode_renderer">Renderer</string>
@@ -470,6 +499,7 @@
<string name="drawer_page_effects">Effects</string>
<string name="drawer_page_controls">Controls</string>
<string name="drawer_page_components">Components</string>
<string name="drawer_page_controller">Controller</string>
<string name="drawer_page_settings">Settings</string>
<string name="drawer_page_desc">%1$s page</string>
<string name="drawer_steam">Steam</string>
@@ -480,13 +510,15 @@
<string name="drawer_prev_page">Previous page</string>
<string name="drawer_next_page">Next page</string>
<string name="drawer_hud">Performance HUD</string>
<string name="drawer_fill">Stretch games to fill</string>
<string name="drawer_scaling">Scaling mode</string>
<string name="drawer_scaling_sharpness">Scaling sharpness</string>
<string name="drawer_effects">Screen effects</string>
<string name="drawer_effects_hint">Live. Costs a little GPU time while any effect is on.</string>
<string name="drawer_look">Look</string>
<string name="drawer_look">Presets</string>
<string name="drawer_look_custom">Custom</string>
<string name="drawer_effects_advanced">Advanced</string>
<string name="widgets_expanded">Expanded</string>
<string name="widgets_collapsed">Collapsed</string>
<string name="drawer_cas">Sharpening (CAS)</string>
<string name="drawer_cas_level">Sharpening level</string>
<string name="drawer_fake_hdr">Fake HDR</string>
@@ -510,6 +542,7 @@
<string name="drawer_pc_keyboard">PC keyboard</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Second screen</string>
<string name="drawer_second_screen_mode">Mode</string>
<string name="drawer_second_screen_shows">Shows</string>
<string name="drawer_android_apps">Android apps</string>
<string name="drawer_launch_app">Launch an app</string>
@@ -586,6 +619,9 @@
<string name="setup_offline">Offline mode</string>
<string name="setup_sign_in_first">Sign in to Steam first</string>
<string name="setup_launcher">Launcher</string>
<string name="setup_app_scale">App scale</string>
<string name="setup_app_scale_hint">DroidDeck text and controls</string>
<string name="setup_app_scale_default">%1$d%% (Default)</string>
<string name="setup_theme">Theme</string>
<string name="setup_theme_hint">Launcher appearance</string>
<string name="setup_home">Use as Home screen</string>
@@ -1011,4 +1047,29 @@
<string name="game_file_folder">Choose a folder for frontend game files</string>
<string name="game_file_exported">Game files saved to %1$s</string>
<string name="game_frontend_files">Frontend files</string>
<string name="display_session">Session display</string>
<string name="display_resolution">Resolution</string>
<string name="display_game_fit">16:9 game fit</string>
<string name="display_game_fit_hint">How 16:9 games fit this display. Next session.</string>
<string name="display_game_fit_unavailable">Choose Match screen or a custom size narrower than 16:9.</string>
<string name="display_preserve">Keep proportions</string>
<string name="display_stretch">Stretch to display</string>
<string name="display_image_scaling">Image scaling</string>
<string name="display_filter">Scaling filter</string>
<string name="display_filter_hint">Shared by Steam and Desktop.</string>
<string name="display_filter_live">Applies live. Shared by Steam and Desktop.</string>
<string name="display_filter_note">FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement.</string>
<string name="display_sharpness">Filter sharpness</string>
<string name="display_frame_rate">Frame rate</string>
<string name="display_look_filter">%1$s Sets scaling to %2$s.</string>
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
<string name="mode_tab_steam">Steam</string>
<string name="device_info_show">Show device details</string>
<string name="device_info_hide">Hide device details</string>
<string name="device_info_loading">Reading the device…</string>
<string name="device_info_copy">Copy</string>
<string name="device_info_copy_hint">Copy these details into a bug report.</string>
<string name="device_info_copied">Copied.</string>
<string name="force_ssbs_title">Force SSBS</string>
<string name="force_ssbs_hint">Keeps a CPU speed setting on that Wine keeps turning off. Can make Windows games faster on Snapdragon 8 Elite; little change seen on older chips. Applies on the next game launch.</string>
</resources>
+5 -1
View File
@@ -4,4 +4,8 @@
<locale android:name="en" />
<locale android:name="es" />
<locale android:name="zh" />
</locale-config>
<locale android:name="ko" />
<locale android:name="fr" />
<locale android:name="ja" />
<locale android:name="ru" />
</locale-config>
@@ -11,6 +11,14 @@ class GameEnvironmentTest {
assertEquals("6_9", values["VKD3D_SHADER_MODEL"])
}
@Test fun forceSsbsIsADefaultAGameCanTurnOff() {
assertFalse(GameEnvironment.defaults("").containsKey(GameEnvironment.FORCE_SSBS))
assertEquals("1", GameEnvironment.defaults("", forceSsbs = true)[GameEnvironment.FORCE_SSBS])
val config = GameEnvironment.Config().withEntries("42", mapOf(GameEnvironment.FORCE_SSBS to "0"))
assertEquals("0", GameEnvironment.effective(config, "", "42", forceSsbs = true)[GameEnvironment.FORCE_SSBS])
assertEquals("1", GameEnvironment.effective(config, "", "43", forceSsbs = true)[GameEnvironment.FORCE_SSBS])
}
@Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() {
val config = GameEnvironment.Config(
shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"),
@@ -27,6 +27,7 @@ class GameFileSyncTest {
}
val folder = File(context.filesDir, "exports").apply { mkdirs() }
GameFileSync.enable(context, folder)
assertEquals("8400", File(folder, "Geometry Wars.steam").readText())
assertEquals(folder.path, GameFileSync.folder(context))
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
// An unavailable source must leave the existing export intact.
@@ -35,6 +36,8 @@ class GameFileSyncTest {
assertThrows(IllegalStateException::class.java) { GameFileSync.syncConfigured(context) }
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
assertTrue(unavailable.renameTo(steamapps))
GameFileSync.syncConfigured(context)
assertTrue(File(folder, "Geometry Wars.steam").exists())
// A genuine uninstall removes the owned export at the next tick.
assertTrue(manifest.delete())
GameFileSync.syncConfigured(context)
@@ -44,6 +47,7 @@ class GameFileSyncTest {
manifest.writeText("\"name\" \"Geometry Wars\"\n\"StateFlags\" \"4\"")
GameFileSync.syncConfigured(context)
assertNull(GameFileSync.folder(context))
assertFalse(File(folder, "Geometry Wars.steam").exists())
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
}
}
@@ -28,8 +28,8 @@ class GameFilesTest {
@Test fun exportsPortableFilesAndAcceptsFileUriRawPathAndContentUri() {
val folder = tmp.newFolder()
val file = GameFiles.export(folder, game("18446744073709551615", "A/B: C"))
assertEquals("A_B_ C (18446744073709551615).droiddeck", file.name)
assertEquals("droiddeck://game/18446744073709551615\n", file.readText())
assertEquals("A_B_ C.steam", file.name)
assertEquals("18446744073709551615", file.readText())
val context = RuntimeEnvironment.getApplication()
listOf(Uri.fromFile(file), Uri.parse(file.path)).forEach { uri ->
assertEquals("18446744073709551615", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, uri)))
@@ -44,15 +44,15 @@ class GameFilesTest {
@Test fun rejectsCommandsMalformedIdsLargeFilesAndUnsupportedIntents() {
listOf("", "0", "01", "-1", "18446744073709551616", "steam://rungameid/8400",
"8400\n8401", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
"8400\n8401", "droiddeck://game/8400", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
assertNull(value, GameFiles.read(ByteArrayInputStream(value.toByteArray())))
}
assertEquals("8400", GameFiles.read(ByteArrayInputStream("8400\n".toByteArray())))
val context = RuntimeEnvironment.getApplication()
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_SEND, Uri.parse("droiddeck://game/8400"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.droiddeck"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.droiddeck"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.droiddeck")))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.steam"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.steam"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.steam")))))
assertEquals("8400", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("droiddeck://game/8400"))))
}
@@ -71,7 +71,8 @@ class GameFilesTest {
assertEquals("custom artwork", image.readText())
GameFiles.sync(folder, emptyList())
assertEquals("custom artwork", image.readText())
assertEquals(0, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
assertEquals("8400", File(folder, GameFiles.filename(game)).readText())
assertEquals(1, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
icon.delete()
val fallback = tmp.newFolder()
GameFiles.export(fallback, game)
@@ -80,7 +81,7 @@ class GameFilesTest {
@Test fun syncAddsRenamesAndRemovesOwnedFilesAndPreservesUnrelatedAndEditedFiles() {
val folder = tmp.newFolder()
val unrelated = File(folder, "manual.droiddeck").apply { writeText("droiddeck://game/1\n") }
val unrelated = File(folder, "manual.steam").apply { writeText("1") }
val note = File(folder, "notes.txt").apply { writeText("Keep me") }
val first = game()
val second = game("620", "Portal 2")
@@ -110,10 +111,12 @@ class GameFilesTest {
GameFiles.sync(folder, listOf(first))
val second = game("620", "Portal 2")
val collision = File(folder, GameFiles.filename(second)).apply { writeText("personal file") }
val fallback = File(folder, GameFiles.filename(second, disambiguate = true)).apply { writeText("another personal file") }
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(folder, listOf(second)) }
assertEquals("personal file", collision.readText())
assertTrue(File(folder, GameFiles.filename(first)).isFile)
collision.delete()
fallback.delete()
GameFiles.sync(folder, listOf(second))
assertFalse(File(folder, GameFiles.filename(first)).exists())
assertTrue(collision.isFile)
@@ -132,10 +135,10 @@ class GameFilesTest {
@Test fun refusesSymlinksLeavingTheExportFolder() {
val folder = tmp.newFolder()
val outside = tmp.newFile()
outside.writeText("droiddeck://game/8400\n")
outside.writeText("8400")
java.nio.file.Files.createSymbolicLink(File(folder, GameFiles.filename(game())).toPath(), outside.toPath())
assertThrows(IllegalArgumentException::class.java) { GameFiles.export(folder, game()) }
assertEquals("droiddeck://game/8400\n", outside.readText())
assertEquals("8400", outside.readText())
}
@Test fun caseOnlyRenameKeepsTheLiveExportOnCaseInsensitiveStorage() {
@@ -148,6 +151,8 @@ class GameFilesTest {
assertTrue(file.isFile)
assertEquals("8400", file.inputStream().use(GameFiles::read))
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
GameFiles.sync(folder, emptyList())
assertFalse(file.exists())
}
@Test fun unrelatedInventoryIsPreserved() {
@@ -158,6 +163,52 @@ class GameFilesTest {
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
}
@Test fun steamExportContainsOnlyIdForSteamAndAddedGames() {
val folder = tmp.newFolder()
val file = GameFiles.export(folder, game("620", "Portal 2"))
assertEquals("Portal 2.steam", file.name)
assertEquals("620", file.readText())
assertEquals("620", GameFiles.readIntent(RuntimeEnvironment.getApplication(), Intent(Intent.ACTION_VIEW, Uri.fromFile(file))))
val added = Library.SteamGame(1, "Added Game", null, Library.ADDED, -1L)
val addedFile = GameFiles.export(folder, added)
assertEquals("Added Game.steam", addedFile.name)
assertEquals("18446744073709551615", addedFile.readText())
}
@Test fun steamSyncDisambiguatesTitlesAndPreservesEditedAndForeignFiles() {
val folder = tmp.newFolder()
val first = game("620", "A/B")
val second = game("8400", "A:B")
val foreign = File(folder, "Existing.steam").apply { writeText("1") }
val existing = game("1", "Existing")
GameFiles.sync(folder, listOf(first, second, existing))
val owned = File(folder, "A_B (620).steam")
assertEquals("620", owned.readText())
assertEquals("8400", File(folder, "A_B (8400).steam").readText())
owned.writeText("user edit")
GameFiles.sync(folder, listOf(first, second, existing))
assertEquals("user edit", owned.readText())
GameFiles.sync(folder, emptyList())
assertEquals("user edit", owned.readText())
assertEquals("1", foreign.readText())
assertFalse(File(folder, "A_B (8400).steam").exists())
}
@Test fun steamCollisionFallbackDoesNotClaimAnExistingFile() {
val folder = tmp.newFolder()
val collision = File(folder, "Geometry Wars.steam").apply { writeText("other file") }
val existing = File(folder, "Geometry Wars (8400).steam").apply { writeText("8400") }
GameFiles.sync(folder, listOf(game()))
GameFiles.sync(folder, emptyList())
assertEquals("other file", collision.readText())
assertEquals("8400", existing.readText())
existing.writeText("another file")
assertThrows(IllegalArgumentException::class.java) {
GameFiles.sync(folder, listOf(game()))
}
assertEquals("another file", existing.readText())
}
private class LaunchFileProvider(private val file: File) : ContentProvider() {
override fun onCreate() = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.frontend
import android.content.Intent
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GameLaunchIntentTest {
@Test fun acceptsNativeAndExplicitlyRetargetedGameNativeProfiles() {
listOf(GameLaunchIntent.ACTION, "app.gamenative.LAUNCH_GAME").forEach { action ->
val request = Intent(action).putExtra("app_id", 620).putExtra("game_source", "STEAM")
assertEquals("620", GameLaunchIntent.read(RuntimeEnvironment.getApplication(), request))
assertEquals("8400", GameLaunchIntent.readId(Intent(action).putExtra("app_id", "8400")))
assertEquals("620", GameLaunchIntent.readId(request.putExtra("game_source", "steam")))
}
}
@Test fun stringIdsPreserveUnsignedShortcutIds() {
listOf("2147483648", "18446744073709551615").forEach { id ->
assertEquals(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
}
}
@Test fun rejectsUnsupportedSourcesTypesAndIdsWithoutFallingBackToData() {
listOf("", "0", "01", "+1", "-1", "18446744073709551616", "620\n", "steam://rungameid/620").forEach { id ->
assertNull(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
}
listOf("EPIC", "GOG", "CUSTOM_GAME", "", "STEAM ").forEach { source ->
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", source)))
}
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620L)))
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", -1)))
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", 1)))
assertNull(GameLaunchIntent.readId(Intent(Intent.ACTION_MAIN).putExtra("app_id", 620)))
assertNull(GameLaunchIntent.read(RuntimeEnvironment.getApplication(), Intent(GameLaunchIntent.ACTION,
android.net.Uri.parse("droiddeck://game/620"))))
}
@Test fun deepLinksStillAcceptUnsignedShortcutIds() {
val context = RuntimeEnvironment.getApplication()
assertEquals("18446744073709551615", GameLaunchIntent.read(context,
Intent(Intent.ACTION_VIEW, android.net.Uri.parse("droiddeck://game/18446744073709551615"))))
assertFalse(GameLaunchIntent.accepts(Intent.ACTION_SEND))
assertTrue(GameLaunchIntent.accepts(GameLaunchIntent.ACTION))
}
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.frontend
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class WinComponentDetectorsTest {
@get:Rule val tmp = TemporaryFolder()
@Test fun steamsSharedDepotsBecomeRecommendations() {
val steamapps = tmp.newFolder("steamapps")
val game = File(steamapps, "common/BioShock Remastered").apply { mkdirs() }
File(steamapps, "appmanifest_409710.acf").writeText(
"""
"AppState"
{
"appid" "409710"
"SharedDepots"
{
"228983" "228980"
"228984" "228980"
"228988" "228980"
"228987" "228980"
"228990" "228980"
"999999" "228980"
}
}
""".trimIndent(),
)
val found = SteamRedists.detect(game, 409710)
assertEquals(listOf("vcredist2010_dll", "vcredist2012_dll", "vcredist2019_dll", "d3dx9"), found.map { it.componentName })
assertTrue(found.all { it.kind == DependencyDetector.Kind.STEAM })
assertEquals("VC++ 2010", found.first().reason)
assertEquals(emptyList<DependencyDetector.Recommendation>(), SteamRedists.detect(game, 1))
}
@Test fun thePrefixCountsOnlyWhatDroidDeckDidNotPutThere() {
val compat = tmp.newFolder("compatdata", "409710")
val pfx = File(compat, "pfx")
File(pfx, "drive_c/windows/syswow64").mkdirs()
File(pfx, "drive_c/windows/syswow64/OpenAL32.dll").writeText("x")
File(pfx, "system.reg").writeText("[Software\\\\Microsoft\\\\Games for Windows Live]\n")
assertEquals(setOf("oalinst", "XLiveRedist"), PrefixInstalledDetector.detect(compat))
File(compat, ".droiddeck-wincomponents.json").writeText("""{"files": {"drive_c/windows/syswow64/openal32.dll": "oalinst_dll"}}""")
assertEquals(setOf("XLiveRedist"), PrefixInstalledDetector.detect(compat))
assertEquals(emptySet<String>(), PrefixInstalledDetector.detect(File(tmp.root, "missing")))
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.gpu
import org.junit.Assert.assertEquals
import org.junit.Test
class GpuModelFallbackTest {
@Test fun aPlatformCodeNameGivesItsAdreno() {
// AYANEO's Pocket FIT: KGSL says "Adreno33v2", the platform says pineapple.
assertEquals(750, GpuInfo.platformModel("pineapple"))
assertEquals(740, GpuInfo.platformModel("KALAMA"))
assertEquals(830, GpuInfo.platformModel("sun"))
assertEquals(0, GpuInfo.platformModel("somethingelse"))
assertEquals(GpuInfo.Family.A7XX, GpuInfo.familyOf(true, GpuInfo.platformModel("pineapple")))
}
}
@@ -0,0 +1,25 @@
package com.droiddeck.launcher.gpu
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SocNamesTest {
private fun props(vararg pairs: Pair<String, String>): (String) -> String = { pairs.toMap()[it].orEmpty() }
@Test fun theModelIsFoundWhereVendorsPutIt() {
// AYANEO Pocket FIT: ro.soc.model blank, the vendor property set, ro.fota.platform a branch name.
assertEquals("SG8350P", SocNames.model(props("ro.vendor.qti.soc_model" to "SG8350P", "ro.fota.platform" to "MSM_13.0")))
assertEquals("QCS8550", SocNames.model(props("ro.soc.model" to "QCS8550", "ro.vendor.qti.soc_model" to "other")))
assertEquals("", SocNames.model(props("ro.fota.platform" to "MSM_13.0")))
}
@Test fun knownModelsAndPlatformsGetTheirNames() {
assertEquals("Snapdragon 8 Gen 2", SocNames.name("QCS8550", ""))
assertEquals("Snapdragon 8 Gen 3 family", SocNames.name("SG8350P", "pineapple"))
assertNull(SocNames.name("XYZ123", "nowhere"))
assertEquals("Snapdragon 8 Gen 2 (QCS8550)", SocNames.label("QCS8550", ""))
assertEquals("Snapdragon 8 Gen 3 family (SG8350P)", SocNames.label("SG8350P", "pineapple"))
assertEquals("XYZ123", SocNames.label("XYZ123", ""))
}
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.input
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/**
* The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session
* tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the
* first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in
* for that here.
*/
class FakeInputRingPathTest {
private lateinit var base: File
private lateinit var real: File
private lateinit var link: File
@Before fun setUp() {
base = Files.createTempDirectory("ring-path").toFile()
real = File(base, "data-data").apply { mkdirs() }
link = File(base, "data-user-0")
Files.createSymbolicLink(link.toPath(), real.toPath())
}
@After fun tearDown() {
base.deleteRecursively()
}
@Test fun ringsSitBesideTheFakeInputNodes() {
val fakeInputDir = File(link, "files/session/dev/input")
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2))
}
@Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() {
val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() }
val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") }
// The link really does resolve elsewhere, as /data/user/0 does on the affected devices.
assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/"))
val exported = FakeInputRingPaths.exportPath(ring)
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported)
assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/"))
assertTrue("$exported does not open", File(exported).isFile)
}
@Test fun aRelativeInputDirIsExportedAbsolute() {
val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!)
assertTrue(exported, File(exported).isAbsolute)
assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1"))
}
}
@@ -0,0 +1,21 @@
package com.droiddeck.launcher.runtime
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.File
class ProotFastPathTest {
private val root = File("/data/data/com.droiddeck.launcher/files/linuxfs")
@Test fun aSessionsUsualBindsGetAKey() {
// 87 on a device with two Games folders: past the old limit of 64, which dropped them.
val binds = (1..87).map { "/host/$it:/guest/$it" }
assertNotNull(ProotFastPath.key(root, binds))
}
@Test fun moreBindsThanTheLibraryHoldsLeaveItOff() {
val binds = (1..ProotFastPath.MAX_BINDS + 1).map { "/host/$it:/guest/$it" }
assertNull(ProotFastPath.key(root, binds))
}
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.session
import android.content.Intent
import android.os.BatteryManager
import java.io.File
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class BatteryComponentTest {
@get:Rule val tmp = TemporaryFolder()
private var battery: BatteryComponent? = null
@After fun stop() { battery?.stop() }
@Test fun oneAggregateBatteryKeepsOverlayPowerAndLifetimeConsistentWithQam() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
start(supplies, vpower, -2_000_000L)
assertTelemetry(supplies, vpower)
}
@Test fun upgradingRemovesTheDuplicateRatherThanLeavingItForOverlayDiscovery() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
val legacy = File(supplies, "BAT0").apply { mkdirs() }
File(legacy, "power_now").writeText("8000000\n")
File(legacy, "charge_now").writeText("4000000\n")
start(supplies, vpower, 2_000_000L)
assertFalse(legacy.exists())
assertTelemetry(supplies, vpower)
}
private fun start(supplies: File, vpower: File, currentUa: Long) {
val context = RuntimeEnvironment.getApplication()
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
putExtra(BatteryManager.EXTRA_LEVEL, 50)
putExtra(BatteryManager.EXTRA_SCALE, 100)
putExtra(BatteryManager.EXTRA_VOLTAGE, 4000)
})
shadowOf(context.getSystemService(BatteryManager::class.java)).apply {
setLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER, 4_000_000L)
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW, currentUa)
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE, currentUa)
}
battery = BatteryComponent(supplies, vpower).also { it.attach(context); it.start() }
}
private fun assertTelemetry(supplies: File, vpower: File) {
val discovered = supplies.listFiles()!!.filter { it.name.contains("BAT") }
assertEquals(listOf("BAT1"), discovered.map { it.name })
fun read(name: String) = File(discovered.single(), name).readText().trim().toLong()
// One 4 Ah battery at 4 V drawing 2 A: 8 W and two hours, regardless of vendor current sign.
assertEquals(8_000_000L, read("power_now"))
assertEquals(16_000_000L, read("energy_now"))
assertEquals(7200L, read("time_to_empty_now"))
assertEquals("7200", File(vpower, "secs_until_shutdown_request").readText().trim())
assertEquals("50.00", File(vpower, "battery_percent").readText().trim())
}
}
@@ -0,0 +1,238 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class ForcedPackageTest {
private val dxvk2 = "dxvk-2.7.1-linux.wcp"
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
File(context.filesDir, "components").deleteRecursively()
LinuxRuntime.rootDir(context).deleteRecursively()
}
@Test fun adreno6xxForcesDxvk2AndNothingElseDoes() {
val rule = ComponentsManager.FORCED_PACKAGES.single { GpuInfo.Family.A6XX in it.families }
assertEquals("dxvk", rule.comp)
assertEquals(dxvk2, rule.file)
assertEquals("Dxvk-Linux", rule.release)
assertTrue(rule.models.isEmpty())
assertTrue(rule.matches(gpu(650, GpuInfo.Family.A6XX)))
assertTrue(rule.matches(gpu(610, GpuInfo.Family.A6XX)))
assertTrue(rule.matches(gpu(690, GpuInfo.Family.A6XX)))
assertFalse(rule.matches(gpu(740, GpuInfo.Family.A7XX)))
assertFalse(rule.matches(gpu(830, GpuInfo.Family.A8XX)))
assertTrue(ComponentsManager.FORCED_PACKAGES.none { GpuInfo.Family.A7XX in it.families })
}
@Test fun aModelListOnlyMatchesThoseChips() {
val rule = ComponentsManager.ForcedPackage(
setOf(GpuInfo.Family.A7XX_LOW), "dxvk", dxvk2, "Dxvk-Linux", setOf(710),
)
assertTrue(rule.matches(gpu(710, GpuInfo.Family.A7XX_LOW)))
assertFalse(rule.matches(gpu(720, GpuInfo.Family.A7XX_LOW)))
assertFalse(rule.matches(gpu(710, GpuInfo.Family.A6XX)))
assertFalse(ComponentsManager.ForcedPackage(emptySet(), "dxvk", dxvk2, "Dxvk-Linux").matches(gpu(650, GpuInfo.Family.A6XX)))
}
@Test fun aShippedComponentIsReplacedUntilTheUserChoosesOne() {
assertTrue(ComponentsManager.forcedPackageWanted(null, false))
assertTrue(ComponentsManager.forcedPackageWanted(null, true))
assertTrue(ComponentsManager.forcedPackageWanted("", false))
assertFalse(ComponentsManager.forcedPackageWanted(dxvk2, true))
assertTrue(ComponentsManager.forcedPackageWanted(dxvk2, false))
assertFalse(ComponentsManager.forcedPackageWanted("dxvk-3.1-linux.wcp", true))
assertTrue(ComponentsManager.forcedPackageWanted("dxvk-3.1-linux.wcp", false))
assertTrue(ComponentsManager.forcedPackageWanted("dxvk-gplasync-2.7.1-1-linux.wcp", false))
}
@Test fun catalogMatchPicksTheReleaseAndIgnoresAnother() {
val wanted = item(dxvk2, "Dxvk-Linux")
val otherRelease = item(dxvk2, "Dxvk-gplasync-Linux")
val otherFile = item("dxvk-3.1-linux.wcp", "Dxvk-Linux")
assertEquals(wanted, ComponentsManager.catalogMatch(listOf(otherFile, otherRelease, wanted), dxvk2, "Dxvk-Linux"))
assertNull(ComponentsManager.catalogMatch(listOf(otherFile, otherRelease), dxvk2, "Dxvk-Linux"))
assertNull(ComponentsManager.catalogMatch(emptyList(), dxvk2, "Dxvk-Linux"))
}
@Test fun thisMachineIsNotForced() {
assertNull(ComponentsManager.ensureForcedPackages(context))
}
@Test fun anotherGpuIsLeftAlone() {
proton("proton-exp")
assertNull(install(gpu(740, GpuInfo.Family.A7XX)))
}
@Test fun noInstalledProtonNeedsNothing() {
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
}
@Test fun aChosenPackageIsNotReplaced() {
proton("proton-exp")
writeState("proton-exp", "dxvk-3.1-linux.wcp", "experimental-11")
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
}
@Test fun theSamePackageOnThisProtonBuildIsNotFetchedAgain() {
proton("proton-exp")
writeState("proton-exp", dxvk2, "experimental-11")
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
}
@Test fun aMissingListingLeavesTheShippedCopy() {
proton("proton-exp")
val result = install(gpu(650, GpuInfo.Family.A6XX), lookup = { _, _ -> null })
assertEquals("$dxvk2 is not in the Nightlies listing; DXVK stays on the Proton's copy", result)
}
@Test fun aShippedCopyAndAProtonUpdateAreBothSwapped() {
proton("kept")
proton("proton-exp", "experimental-12")
proton("other", "experimental-12")
writeState("kept", "dxvk-3.1-linux.wcp", "experimental-11")
writeState("proton-exp", dxvk2, "experimental-11")
writeState("other", "dxvk-3.1-linux.wcp", "experimental-11")
val fetched = mutableListOf<String>()
val applied = mutableListOf<String>()
val result = install(
gpu(650, GpuInfo.Family.A6XX),
lookup = { file, release -> item(file, release) },
fetch = { fetched += it.file },
apply = { id, file -> applied += "$id $file"; "DXVK 2.7.1 is now in $id. It applies the next time a game starts." },
)
assertEquals(listOf(dxvk2), fetched)
assertEquals(listOf("other $dxvk2", "proton-exp $dxvk2"), applied)
assertEquals(
"DXVK 2.7.1 is now in other. It applies the next time a game starts.; " +
"DXVK 2.7.1 is now in proton-exp. It applies the next time a game starts.",
result,
)
}
@Test fun everyProtonStillOnItsShippedCopyIsSwapped() {
proton("one")
proton("two")
val applied = mutableListOf<String>()
val result = install(
gpu(650, GpuInfo.Family.A6XX),
lookup = { file, release -> item(file, release) },
fetch = {},
apply = { id, _ -> applied += id; "DXVK 2.7.1 is now in $id. It applies the next time a game starts." },
)
assertEquals(listOf("one", "two"), applied)
assertEquals(
"DXVK 2.7.1 is now in one. It applies the next time a game starts.; " +
"DXVK 2.7.1 is now in two. It applies the next time a game starts.",
result,
)
}
@Test fun aBrokenStoredPackageIsFetchedAgain() {
proton("proton-exp")
File(context.filesDir, "components/packages/$dxvk2").apply { parentFile!!.mkdirs(); writeText("not a package") }
val fetched = mutableListOf<String>()
install(
gpu(650, GpuInfo.Family.A6XX),
lookup = { file, release -> item(file, release) },
fetch = { fetched += it.file },
apply = { _, _ -> "swapped" },
)
assertEquals(listOf(dxvk2), fetched)
assertFalse(File(context.filesDir, "components/packages/$dxvk2").isFile)
}
@Test fun aFailedFetchIsLeftForTheSessionToLog() {
proton("proton-exp")
val applied = mutableListOf<String>()
val error = assertThrows(IllegalStateException::class.java) {
install(
gpu(650, GpuInfo.Family.A6XX),
lookup = { file, release -> item(file, release) },
fetch = { throw IllegalStateException("Download failed") },
apply = { id, _ -> applied += id; "" },
)
}
assertEquals("Download failed", error.message)
assertTrue(applied.isEmpty())
}
@Test fun theCachedListingIsUsedBeforeRefreshing() {
writeCatalog()
assertEquals(cachedUrl, ComponentsManager.catalogItem(context, dxvk2, "Dxvk-Linux")?.url)
}
@Test fun aCacheMissReadsTheRefreshedListingOnce() {
var refreshes = 0
val refreshed = item(dxvk2, "Dxvk-Linux")
val found = ComponentsManager.resolveCatalogItem(dxvk2, "Dxvk-Linux", { emptyList() }, { refreshes += 1; listOf(refreshed) })
assertEquals(refreshed, found)
assertEquals(1, refreshes)
assertNull(ComponentsManager.resolveCatalogItem("missing.wcp", "Dxvk-Linux", { emptyList() }, { refreshes += 1; emptyList() }))
assertEquals(2, refreshes)
val cached = item(dxvk2, "Dxvk-Linux")
assertEquals(cached, ComponentsManager.resolveCatalogItem(dxvk2, "Dxvk-Linux", { listOf(cached) }, { error("refresh") }))
}
private fun gpu(model: Int, family: GpuInfo.Family) = GpuInfo("Adreno $model", model, family, "", false)
private fun install(
gpu: GpuInfo,
lookup: (String, String) -> ComponentsManager.CatalogItem? = ::refuseLookup,
fetch: (ComponentsManager.CatalogItem) -> Unit = ::refuseFetch,
apply: (String, String) -> String = ::refuseApply,
) = ComponentsManager.ensureForcedPackages(context, gpu, lookup, fetch, apply)
private fun refuseLookup(file: String, release: String): ComponentsManager.CatalogItem? = error("catalog $file $release")
private fun refuseFetch(item: ComponentsManager.CatalogItem) { error("fetch ${item.file}") }
private fun refuseApply(protonId: String, file: String): String = error("apply $protonId $file")
private fun proton(name: String, version: String = "experimental-11") {
val dir = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/common/$name")
File(dir, "files/lib/wine").mkdirs()
File(dir, "proton").writeText("#!/bin/sh\n")
File(dir, "version").writeText("1 $version\n")
}
private fun writeState(protonId: String, file: String, protonVersion: String) {
val state = File(context.filesDir, "components/state.json")
state.parentFile!!.mkdirs()
val active = if (state.isFile) JSONObject(state.readText()).getJSONObject("active") else JSONObject()
active.put(protonId, JSONObject().put("dxvk", JSONObject().put("file", file).put("protonVersion", protonVersion)))
state.writeText(JSONObject().put("active", active).put("queued", JSONObject()).toString())
}
private val cachedUrl = "https://github.com/The412Banner/Nightlies/releases/download/Dxvk-Linux/$dxvk2?cached=1"
private fun writeCatalog() {
val cache = File(context.filesDir, "components/catalog.json")
cache.parentFile!!.mkdirs()
cache.writeText(JSONObject()
.put("fetchedAt", 1)
.put("items", JSONArray().put(JSONObject()
.put("file", dxvk2).put("comp", "dxvk").put("release", "Dxvk-Linux")
.put("url", cachedUrl).put("size", 4).put("digest", "sha256:" + "ab".repeat(32))))
.toString())
}
private fun item(file: String, release: String) = ComponentsManager.CatalogItem(
file, "dxvk", release, "https://github.com/The412Banner/Nightlies/releases/download/$release/$file", 4, "sha256:" + "ab".repeat(32),
)
}
@@ -8,6 +8,7 @@ import android.os.Looper
import android.os.Vibrator
import android.os.VibratorManager
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.IOException
import org.junit.After
import org.junit.Before
@@ -68,12 +69,74 @@ class RumbleComponentTest {
assertTrue(shadowOf(vibrator).isVibrating)
}
private fun effect() {
// Inject a decoded force-feedback packet; transport is outside this preference test.
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(rumble, 65535, 5000)
@Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() {
val pad = FakeMotors()
rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null }
activeController(PAD_ID)
effect(strong = 40000, weak = 1000)
assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played)
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
}
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
rumble.controllerMotors = { null }
activeController(PAD_ID)
effect()
assertTrue(shadowOf(vibrator).isVibrating)
}
@Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
effect()
assertTrue(pad.vibrating)
activeController(PadBridge.NO_CONTROLLER)
effect()
assertFalse("the controller kept rumbling after the switch", pad.vibrating)
assertTrue(shadowOf(vibrator).isVibrating)
}
@Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
effect()
effect(strong = 0, weak = 0)
assertFalse(pad.vibrating)
effect()
ControllerPrefs.setRumble(context, false)
shadowOf(Looper.getMainLooper()).idle()
assertFalse(pad.vibrating)
ControllerPrefs.setRumble(context, true)
shadowOf(Looper.getMainLooper()).idle()
effect()
rumble.stop()
assertFalse(pad.vibrating)
}
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
private fun activeController(id: Int) {
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
}
private fun effect(strong: Int = 65535, weak: Int = 65535) {
// Inject a decoded force-feedback packet; transport is outside these tests.
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
}
private class FakeMotors : RumbleComponent.Motors {
override val name = "fake pad"
val played = mutableListOf<Triple<Int, Int, Long>>()
var vibrating = false
override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true }
override fun cancel() { vibrating = false }
}
private companion object { const val PAD_ID = 7 }
@Implements(LocalServerSocket::class)
class NoPacketsSocket {
@Implementation fun __constructor__(name: String) = Unit
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionBackgroundPrefsTest {
private val context get() = RuntimeEnvironment.getApplication()
@Before fun clearSettings() {
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun nativeIsOptInAndDoesNotChangeDesktopOrExistingPolicies() {
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "steam"))
for (policy in listOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_MANUAL, SessionPrefs.SUSPEND_NEVER,
SessionPrefs.SUSPEND_NATIVE)) {
SessionPrefs.setSuspendPolicy(context, "steam", policy)
assertEquals(policy, SessionPrefs.suspendPolicy(context, "steam"))
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "desktop"))
}
}
@Test fun directGamesUseAutoWithoutOverwritingSteamsNativeChoice() {
SessionPrefs.setSuspendPolicy(context, SessionService.MODE_STEAM, SessionPrefs.SUSPEND_NATIVE)
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_RUN))
assertEquals(SessionPrefs.SUSPEND_NATIVE, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
}
}
@@ -0,0 +1,74 @@
package com.droiddeck.launcher.session
import org.junit.Assert.*
import org.junit.Test
class SessionDisplayTest {
@Test fun fixedSizesAndMatchScreenResolveWithoutAnyAspectSetting() {
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720"))
assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900"))
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN))
assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN))
}
@Test fun heightPresetsKeepThePanelShapeWithASixteenByNineFloor() {
// 20:9 phone: edge to edge, no bars for the client's interface.
assertEquals(1600 to 720, SessionDisplay.resolveChoice(2400 to 1080, SessionDisplay.DEFAULT_RESOLUTION))
assertEquals(1608 to 720, SessionDisplay.resolveChoice(2412 to 1080, "720p"))
// 1440p phone: bounded by the preset height, not the panel.
assertEquals(1560 to 720, SessionDisplay.resolveChoice(3120 to 1440, "720p"))
assertEquals(2340 to 1080, SessionDisplay.resolveChoice(3120 to 1440, "1080p"))
// Foldable inner panel and 4:3 handhelds: never narrower than 16:9.
assertEquals(1280 to 720, SessionDisplay.resolveChoice(2208 to 1840, "720p"))
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "720p"))
// Never taller than the panel.
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, "1080p"))
}
@Test fun optionsListTheHeightsThePanelCanShowThenThePanel() {
val thor = SessionDisplay.resolutionOptions(1920 to 1080)
assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN), thor)
assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN), SessionDisplay.resolutionOptions(2400 to 1080))
assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN), SessionDisplay.resolutionOptions(3120 to 1440))
val small = SessionDisplay.resolutionOptions(1280 to 720)
assertEquals(listOf(SessionDisplay.MATCH_SCREEN), small)
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN))
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
}
@Test fun resolutionCapsPreserveTheSelectedShapeWithoutExceedingPanelHeight() {
assertEquals(1280 to 720, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_AUTO))
assertEquals(960 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_EXACT))
assertEquals(1280 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_AUTO))
assertEquals(1280 to 720, SessionDisplay.resolve(1280 to 720, 1080, SessionPrefs.SHAPE_AUTO))
}
@Test fun noCapUsesPanelHeightWhileCustomOverridesBothCapAndAspect() {
assertEquals(1440 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_EXACT))
assertEquals(1920 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE))
assertEquals(1024 to 768, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_WIDE, 1024 to 768))
}
@Test fun orientationAndOddDimensionsKeepTheExistingEvenLandscapeDisplay() {
assertEquals(1920 to 1080, SessionDisplay.resolve(1081 to 1921, 0, SessionPrefs.SHAPE_EXACT))
assertEquals(1600 to 720, SessionDisplay.resolve(2400 to 1080, 720, SessionPrefs.SHAPE_AUTO))
}
@Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() {
assertTrue(SessionDisplay.canStretch16x9(960 to 720))
assertFalse(SessionDisplay.canStretch16x9(1280 to 720))
assertFalse(SessionDisplay.canStretch16x9(1600 to 720))
val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE)
assertFalse(SessionDisplay.canStretch16x9(wide))
}
@Test fun removedFilterAliasesRetainTheirEquivalentBehavior() {
assertEquals(0, SessionPrefs.canonicalUpscaler(1))
assertEquals(4, SessionPrefs.canonicalUpscaler(5))
assertEquals(0, SessionPrefs.canonicalUpscaler(99))
assertFalse(SessionPrefs.upscalerHasSharpness(0))
assertFalse(SessionPrefs.upscalerHasSharpness(1))
assertFalse(SessionPrefs.upscalerHasSharpness(2))
for (mode in 3..8) assertTrue(SessionPrefs.upscalerHasSharpness(mode))
}
}
@@ -0,0 +1,125 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.LogRedactor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.util.zip.ZipFile
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionLogShareTest {
@get:Rule val tmp = TemporaryFolder()
private val secret = "GET /x?sessionid=abcdef123456 200"
private val leaked = "abcdef123456"
private fun zip(folder: File): Map<String, String> =
ZipFile(SessionLogShare.zipFolder(RuntimeEnvironment.getApplication(), folder)!!).use { z ->
z.entries().toList().associate { it.name.substringAfter('/') to z.getInputStream(it).bufferedReader().readText() }
}
/** A record as the session's ending writes it, listing [files] as they stand now. */
private fun record(folder: File, vararg files: String, rules: Int = LogRedactor.RULES_VERSION) {
File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).writeText(
"rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" })
}
@Test fun aRecordedUnchangedFileGoesInAsItIs() {
val folder = tmp.newFolder("2026-10-06-01-steam")
// Stands in for a file the ending scrubbed; the share must not pass it through again.
File(folder, "session.log").writeText("$secret\n")
record(folder, "session.log")
assertTrue(zip(folder).getValue("session.log").contains(leaked))
}
@Test fun anythingTheRecordDoesNotVouchForIsScrubbed() {
val folder = tmp.newFolder("2026-10-06-02-steam")
File(folder, "app.log").writeText("ok\n")
File(folder, "session.log").writeText("ok\n")
File(folder, "steam/deep").mkdirs()
File(folder, "steam/deep/nested.txt").writeText("$secret\n")
File(folder, ".hidden.txt").writeText("$secret\n")
record(folder, "app.log", "session.log")
// Appended to after the ending (the app's log capture), and touched with an older time.
File(folder, "app.log").appendText("$secret\n")
File(folder, "session.log").apply { writeText("$secret\n".padEnd(3, ' ')); setLastModified(1_000L) }
File(folder, "events.jsonl").writeText("$secret\n")
val out = zip(folder)
for (name in listOf("app.log", "session.log", "steam/deep/nested.txt", ".hidden.txt", "events.jsonl"))
assertFalse(name, out.getValue(name).contains(leaked))
}
@Test fun aRecordUnderOtherRedactorRulesVouchesForNothing() {
val folder = tmp.newFolder("2026-10-06-03-steam")
File(folder, "session.log").writeText("$secret\n")
record(folder, "session.log", rules = LogRedactor.RULES_VERSION - 1)
assertFalse(zip(folder).getValue("session.log").contains(leaked))
}
@Test fun withoutARecordEveryFileIsScrubbed() {
val folder = tmp.newFolder("2026-10-06-04-steam")
File(folder, "session.log").writeText("$secret\n")
File(folder, "steam").mkdirs()
File(folder, "steam/console_log.txt").writeText("$secret\n")
val out = zip(folder)
assertFalse(out.getValue("session.log").contains(leaked))
assertFalse(out.getValue("steam/console_log.txt").contains(leaked))
}
@Test fun theEndingScrubsEveryFolderAndRecordsWhatItScrubbed() {
val folder = tmp.newFolder("2026-10-06-05-steam")
File(folder, "session.log").writeText("$secret\n")
File(folder, "droiddeck-esync").mkdirs()
File(folder, "droiddeck-esync/launches.log").writeText("$secret\n")
File(folder, "steam/deep").mkdirs()
File(folder, "steam/deep/nested.txt").writeText("$secret\n")
SessionArtifacts.scrubAndMark(folder)
for (name in listOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"))
assertFalse(name, File(folder, name).readText().contains(leaked))
assertEquals(setOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"),
SessionArtifacts.scrubbedFiles(folder))
assertTrue(zip(folder).values.none { it.contains(leaked) })
}
@Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() {
val folder = tmp.newFolder("2026-10-06-06-steam")
File(folder, "session.log").writeText("$secret\n")
// A folder the scrubbed copy cannot be written into: the scrub fails, the original stays.
folder.setWritable(false)
try {
SessionArtifacts.scrubAndMark(folder)
} finally {
folder.setWritable(true)
}
assertTrue(File(folder, "session.log").readText().contains(leaked))
assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists())
assertTrue(SessionArtifacts.scrubbedFiles(folder).isEmpty())
assertTrue(zip(folder).values.none { it.contains(leaked) })
}
@Test fun steamLogsPastTheSizeLimitStayOut() {
val folder = tmp.newFolder("2026-10-06-07-steam")
File(folder, "session.log").writeText("ok\n")
File(folder, "steam").mkdirs()
File(folder, "steam/console_log.txt").writeText("ok\n")
File(folder, "steam/cef_log.previous.txt").outputStream().use { out ->
val line = ByteArray(1024) { 'x'.code.toByte() }.also { it[it.size - 1] = '\n'.code.toByte() }
repeat(9 * 1024) { out.write(line) }
}
val names = zip(folder).keys
assertTrue("steam/console_log.txt" in names)
assertFalse("steam/cef_log.previous.txt" in names)
}
}
@@ -0,0 +1,65 @@
package com.droiddeck.launcher.session
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class SessionScalingPrefsTest {
@Before fun clearSettings() {
RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun explicitLegacyAspectAndCapKeepTheirSizeUntilAResolutionIsPicked() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putString("shape", SessionPrefs.SHAPE_EXACT).putInt("resolutionCap.steam", 900).commit()
assertEquals("1200x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "1600x900")
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).commit()
assertEquals("1600x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
assertTrue(SessionPrefs.resolutionChosen(context, "steam"))
}
@Test fun matchScreenOverridesLegacyCustomAndAspectWithoutAffectingTheOtherMode() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).putString("customRes.steam", "1024x768")
.putString("customRes.lxqt", "800x600").commit()
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", SessionDisplay.MATCH_SCREEN)
val choice = SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1440 to 1080, choice))
assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080))
}
@Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() {
val context = RuntimeEnvironment.getApplication()
assertEquals(SessionDisplay.DEFAULT_RESOLUTION, SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "900p")
assertEquals("900p", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
SessionPrefs.setResolutionChoice(context, "steam", "1025×769")
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
}
@Test fun legacySavedFiltersStillResolveToAVisibleEquivalentChoice() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putInt("upscaler", 1).putInt("upscaleSharpness", 65).commit()
assertEquals(0, SessionPrefs.upscaler(context))
prefs.edit().putInt("upscaler", 5).commit()
assertEquals(4, SessionPrefs.upscaler(context))
assertEquals(65, SessionPrefs.upscaleSharpness(context))
SessionPrefs.setUpscaler(context, 5)
assertEquals(4, prefs.getInt("upscaler", -1))
SessionPrefs.setUpscaler(context, 1)
assertEquals(0, prefs.getInt("upscaler", -1))
}
}
@@ -0,0 +1,17 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Test
class WinComponentNamesTest {
@Test fun catalogKeysReadAsPeopleKnowThem() {
val names = mapOf(
"oalinst_dll" to "OpenAL", "vcredist2010_dll" to "Visual C++ 2010", "vcredist2022" to "Visual C++ 2015-2022",
"dotnet48" to ".NET Framework 4.8", "dotnet452" to ".NET Framework 4.5.2", "dotnet20sp1" to ".NET Framework 2.0 SP1",
"d3dcompiler_47" to "D3DCompiler 47", "xaudio2.7" to "XAudio 2.7", "mono-10.4.1" to "Wine Mono 10.4.1",
"msxml6" to "MSXML 6", "dmsynth" to "DirectMusic (dmsynth)", "XLiveRedist" to "Games for Windows Live",
"something-new" to "something-new",
)
for ((id, name) in names) assertEquals(id, name, WinComponentNames.of(id))
}
}
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.session.WinComponents.Support
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class WinComponentsSupportTest {
private val base = "https://github.com/The412Banner/winlator-contents/releases/download/system-libraries-v1"
private fun step(action: String, vararg pairs: Pair<String, String>) =
WinComponents.Step(action, JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } })
private fun component(name: String, vararg steps: WinComponents.Step) =
WinComponents.Component(name, "", "", "ready", emptyList(), steps.toList())
private fun support(c: WinComponents.Component) = WinComponents.support(c, mapOf(c.name to c))
@Test fun windowsInstallerPackagesInstallHere() {
assertEquals(Support.READY, support(component("xna40", step("install_msi", "url" to "$base/xna40.msi"))))
// The catalog names some .msi packages install_exe.
assertEquals(Support.READY, support(component("XLiveRedist",
step("install_exe", "url" to "$base/XLiveRedist.msi", "file_name" to "XLiveRedist1.2.0238.msi"))))
assertEquals(Support.READY, support(component("msxml3", step("delete_dlls", "dest" to "win32"),
step("override_dll", "dll" to "msxml3", "type" to "native"), step("install_msi", "url" to "$base/msxml3.msi"))))
assertEquals(Support.READY, support(component("powershell_core",
step("install_msi", "url" to "$base/ps-x86.msi"), step("install_msi", "url" to "$base/ps-x64.msi"))))
}
@Test fun otherInstallersStillWait() {
assertEquals(Support.NEEDS_INSTALLER, support(component("dotnet48", step("install_exe", "url" to "$base/ndp48.exe"))))
// An uninstall step first (wine-mono) is not something done here.
assertEquals(Support.NEEDS_INSTALLER, support(component("mono", step("uninstall"),
step("install_msi", "url" to "$base/mono.msi"))))
assertEquals(Support.NEEDS_INSTALLER, support(component("plain", step("install_msi", "url" to "http://example.com/a.msi"))))
}
@Test fun whatProtonShipsIsNotOffered() {
assertEquals(Support.UNSUPPORTED, support(component("gecko", step("install_msi", "url" to "$base/gecko.msi"))))
}
}
@@ -0,0 +1,90 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.content.res.Configuration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.AppUiPrefs
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class AppScaleTest {
private val context get() = RuntimeEnvironment.getApplication()
@Before fun clearScale() {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun defaultsToSystemDensityAndCanRestoreItAfterChoosingAnotherScale() {
assertEquals(100, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 75)
assertEquals(75, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 100)
assertEquals(100, AppUiPrefs.scale(context))
val density = Density(2f, 1.3f)
val configuration = Configuration(context.resources.configuration)
assertSame(density, scaledAppDensity(density, 100))
assertSame(configuration, scaledAppConfiguration(configuration, 100))
}
@Test fun invalidStoredOrRequestedScaleFallsBackToTheDefault() {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().putInt("scale", 0).commit()
assertEquals(100, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 1000)
assertEquals(100, AppUiPrefs.scale(context))
}
@Test fun scalingDoesNotModifySessionPreferencesOrAndroidDisplayMetrics() {
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
session.edit().putString("resolution.steam", "1280x720").putInt("upscaler", 2).commit()
val before = session.all.toMap()
val density = context.resources.displayMetrics.density
val configuration = Configuration(context.resources.configuration)
AppUiPrefs.setScale(context, 150)
scaledAppConfiguration(context.resources.configuration, AppUiPrefs.scale(context))
assertEquals(before, session.all)
assertEquals(density, context.resources.displayMetrics.density, 0f)
assertEquals(configuration, context.resources.configuration)
}
@Test fun textAndControlsScaleTogetherWhilePreservingAccessibilityFontScale() {
val base = Density(2f, 1.3f)
for (percent in AppUiPrefs.scales) {
val scaled = scaledAppDensity(base, percent)
assertEquals(1.3f, scaled.fontScale, 0f)
assertEquals(base.run { 44.dp.toPx() } * percent / 100f, scaled.run { 44.dp.toPx() }, 0.001f)
assertEquals(base.run { 16.sp.toPx() } * percent / 100f, scaled.run { 16.sp.toPx() }, 0.001f)
}
}
@Test fun responsiveWidthAndPopupHeightFitTheSamePhysicalWindowAtEveryScale() {
val base = Configuration().apply {
densityDpi = 320
screenWidthDp = 960
screenHeightDp = 540
smallestScreenWidthDp = 540
fontScale = 1.3f
orientation = Configuration.ORIENTATION_LANDSCAPE
}
for (percent in AppUiPrefs.scales) {
val scaled = scaledAppConfiguration(base, percent)
val density = scaledAppDensity(Density(2f, 1.3f), percent).density
assertEquals(1920f, scaled.screenWidthDp * density, 2f)
assertEquals(1080f, scaled.screenHeightDp * density, 2f)
assertEquals(1080f, scaled.smallestScreenWidthDp * density, 2f)
assertEquals(1.3f, scaled.fontScale, 0f)
assertEquals(base.orientation, scaled.orientation)
}
assertEquals(960, base.screenWidthDp)
assertEquals(540, base.screenHeightDp)
assertEquals(320, base.densityDpi)
}
}
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.*
import org.junit.Test
class SessionDrawerPageTest {
@Test fun singleScreenNavigationStartsWithControllerAndSkipsSecondScreen() {
val pages = sessionDrawerPages(false)
assertEquals(SessionDrawerPage.CONTROLLER, pages.first())
assertFalse(SessionDrawerPage.SECOND_SCREEN in pages)
assertEquals(SessionDrawerPage.DISPLAY, pages.step(SessionDrawerPage.CONTROLLER, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.CONTROLLER, -1))
assertEquals(SessionDrawerPage.CONTROLLER, pages.step(SessionDrawerPage.SESSION, 1))
}
@Test fun connectedSecondaryDisplayAddsItsPageAfterControllerAndPreservesOtherPages() {
val connected = sessionDrawerPages(true)
assertEquals(SessionDrawerPage.SECOND_SCREEN, connected.step(SessionDrawerPage.CONTROLLER, 1))
assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1))
assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN })
for (pages in listOf(connected, sessionDrawerPages(false))) {
assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1))
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
}
@@ -0,0 +1,107 @@
Thor SD storage investigation: selected saved evidence
Recorded 2026-10-04; event timestamps are EDT.
These are excerpts, not complete logs or a continuous attribution profile.
See ../thor-sd-storage-investigation.md for context and live-only observations.
=== Reporter unsupported allocation ===
Source: Reporter diagnostic storage.log
volume_removable=true
filesystem=fuse
allocation pid=29062 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=374272 elapsed_us=212
=== Thor unsupported allocation ===
Source: candidate-storage.log
volume_removable=true
filesystem=fuse
allocation pid=9810 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=650752 elapsed_us=310
=== Native-path allocation diagnostics ===
Source: direct-storage-completed.log / session 64
DroidDeck storage diagnostics v1
selected_library=secondary
volume_removable=true
filesystem=exfat
truncate_allocation_probe=passed
block_size_bytes=131072
total_bytes=393855500288
available_bytes=101076697088
native_observation=enabled
public_symbol_interposition=enabled
direct_syscalls_and_libc_internal_calls=not_observed
pwrite_timing=sampled_1_in_32
per_process_allocation_detail_limit=64
storage_log_limit_bytes=524288
summary pid=26725 comm=python3 allocation_calls=0 allocation_failures=0 allocation_total_us=0 pwrite_sample_every=32 pwrite_samples=0 pwrite_bytes=0 pwrite_failures=0 pwrite_sample_total_us=0 pwrite_sample_max_us=0 ftruncate_calls=0 ftruncate_failures=0 ftruncate_total_us=0 ftruncate_max_us=0 fstat_calls=1 fstat_total_us=0 fstat_max_us=0 fsync_calls=0 fsync_failures=0 fsync_total_us=0 fsync_max_us=0
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=667648 elapsed_us=387
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=39 elapsed_us=32
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=156 elapsed_us=25
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=457428 elapsed_us=351
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=901928 elapsed_us=864
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=15041920 elapsed_us=15292
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=337563 elapsed_us=2783
=== Fresh real Steam install ===
Source: direct-content-completed.log / session 64
[2026-10-04 17:31:20] AppID 1675830 App update changed : Running Update,Preallocating,
[2026-10-04 17:38:04] AppID 1675830 preallocated 2216 files (7626 MB)
[2026-10-04 17:38:06] AppID 1675830 App update changed : Running Update,Downloading,Staging,
[2026-10-04 17:45:25] AppID 1675830 App update changed : Running Update,Committing,
[2026-10-04 17:45:25] AppID 1675830 starting commit from "/mnt/droiddeck-sd/steamapps/downloading/1675830" to "/mnt/droiddeck-sd/steamapps/common/1000xRESIST" : 2230 updated, 0 moved, 0 deleted files
[2026-10-04 17:47:55] AppID 1675830 state changed : Fully Installed,Update Queued,Update Running,
[2026-10-04 17:47:55] AppID 1675830 finished update, 1 mounted depots (BuildID 21498291) : 1675831 (3663189783241284849),
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,Update Queued,
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,
[2026-10-04 17:47:56] AppID 1675830 scheduler finished : removed from schedule (result No Error, state 0xc)
=== Normal-app native/FUSE directory read ===
Source: app-raw-probe-session.log / session 62
APP_RAW_PROBE uid=10195 path=/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.002288 calls=2 bytes=154920 read_error=0
APP_RAW_PROBE uid=10195 path=/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.501306 calls=112 bytes=154872 read_error=0
=== Binding regression: bind-probe-before.log ===
Source: bind-probe-before.log
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=2 pass=0 errno=2 bytes=-1 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
BIND_PROBE index=3 pass=0 errno=2 bytes=-1 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
=== Binding regression: bind-probe-after.log ===
Source: bind-probe-after.log
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
BIND_PROBE index=2 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
BIND_PROBE index=3 pass=1 errno=0 bytes=17 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
=== Root metadata/mount experiment: metadata-mount-comparison.log ===
Source: metadata-mount-comparison.log
original_mount
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
files=64 bytes_per_file=262144 open_seconds=6.707435 truncate_seconds=0.084806 wall_seconds=6.794807 error=0
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
=== Root metadata/mount experiment: metadata-syscall-comparison.log ===
Source: metadata-syscall-comparison.log
original_mount
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
files=64 bytes_per_file=262144 open_seconds=25.181255 truncate_seconds=0.309577 wall_seconds=25.493210 error=0
temporary_mount
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
files=64 bytes_per_file=262144 open_seconds=2.013194 truncate_seconds=0.095924 wall_seconds=2.112288 error=0
=== Post-install Android low-memory exit ===
Source: game-launch-exit-info.txt
ApplicationExitInfo #0:
timestamp=2026-10-04 18:13:48.454 pid=14709 realUid=10195 packageUid=10195 definingUid=10195 user=0
process=com.droiddeck.launcher reason=3 (LOW_MEMORY) subreason=0 (UNKNOWN) status=0
importance=100 pss=227MB rss=344MB description=null state=empty trace=null
=== Combined release unit-test completion ===
Source: combined-233-tests.log
> Task :app:testReleaseUnitTest
BUILD SUCCESSFUL in 10s
=== Combined release build completion ===
Source: combined-233-build.log
BUILD SUCCESSFUL in 25s
APK: /Users/danhimebauch/.codex/worktrees/steam-sd-preallocation/DroidDeck/app/build/outputs/apk/release/app-release.apk
+5
View File
@@ -23,6 +23,11 @@ and explicit removals take precedence over these defaults. FEX's own default
remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray
tracing and diagnostic logging otherwise retain the runtime's defaults.
On an Adreno 6xx, Turnip does not expose `storageBuffer8BitAccess`, which DXVK 3
requires. A Steam or desktop session installs DXVK 2.7.1 into any Proton whose
DXVK is still the copy that Proton shipped, including after a Proton update put
that copy back. A DXVK package chosen in Components stays for that Proton build.
The variable-name picker offers 24 predefined variables and a Custom entry. Like
WinNative, known variables use toggles, value dropdowns, multi-select lists, or
numeric/text fields. Feature-level and shader-model dropdowns also accept custom
+5
View File
@@ -5,6 +5,11 @@ games should keep running under proot, run directly on glibc, or a mix of the tw
fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel
6.12.38-android16, Android 16) over adb, unless it says otherwise.
For Steam SD allocation measurements on AYN Thor (Android 13), see the
[Thor storage investigation](thor-sd-storage-investigation.md). It separates
observed allocation-wrapper time, FUSE directory access and native exFAT metadata
waits; it does not establish a full-install PRoot overhead percentage.
## Short answer
- **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every
+230
View File
@@ -0,0 +1,230 @@
# Steam session display sizing: research and device tests
Started 2026-10-06. This is a working log. Findings are added as they are measured on a device, so
it reads in order.
## Question
Should the Steam session run gamescope at the panel's own size and let gamescope scale games, the
way SteamOS does, instead of one session resolution shared by the Steam client and every game
(1280x720 16:9 by default) that our compositor stretches onto the panel? What does that mean on
very high-resolution panels and on foldables?
## How the session is sized today (main @ c636eea3)
- `droiddeck-session` starts gamescope with `-W/-H` (output) = `-w/-h` (nested, what games and the
client see) = `BL_WIDTH x BL_HEIGHT`, the per-mode "Resolution" setting. The default is
`1280x720` (`SessionDisplay.DEFAULT_RESOLUTION`). One Xwayland server holds both the Steam
client and the games.
- The Android compositor (`waylandcomp`) draws gamescope's output into a panel-sized swapchain
(2414x1080 on a 20:9 phone) and fits it with bars where the shapes differ.
- "Force game windows fullscreen" (`--force-windows-fullscreen`, off by default since #276) makes
every window the nested size. A game whose own resolution differs fights it, and the resize
storm that follows piles up GPU buffers until the low-memory killer ends the session (seen
2026-10-06 on the AYN Thor with Alan Wake's American Nightmare).
Why the current choices were made, from the history:
| Choice | Reason given | Source |
|---|---|---|
| 720p default | The Steam client's CEF is the heaviest thing in a session, and 720p keeps Big Picture responsive on a regular flagship | `SessionPrefs.resolutionCap` comment |
| Never narrower than 16:9 | Games handed a near-square display (Fold inner panel) crop their own menus | `d2ea9b89` |
| 16:9 stretch option | Games on a narrow panel letterbox themselves; stretch fills it | #63, #133 |
| Custom resolution | Parity with Bannerlator, 4:3 and 16:10 presets | `ad2f9f8a` |
| Force fullscreen | A game that shrinks its window on focus loss (FlatOut) comes back small in a corner | #276, `droiddeck-session` |
## How SteamOS-style sessions do it
- gamescope output at the panel's size, `--xwayland-count 2`, and `STEAM_MULTIPLE_XWAYLANDS=1`
(ChimeraOS `gamescope-session-plus` defaults to two Xwayland servers). The client lives on
one server and games on the other.
- Per-game "Game Resolution" (Deck mode game properties): Steam writes
`GAMESCOPE_XWAYLAND_MODE_CONTROL = {server, width, height, allow_super_res}` on the root window,
and gamescope resizes that Xwayland server (`steamcompmgr.cpp`, the `gamescopeXWaylandModeControl`
handler). gamescope then scales the game to the output with the scaler and filter chosen in the QAM.
- `GAMESCOPE_STEAM_MAX_HEIGHT` on the root window caps only the Steam client's window height, and
gamescope scales it up (`steamcompmgr.cpp`, the `g_nSteamMaxHeight` branch). So a native output
need not make the client render natively.
- Our gamescope (3.16.29-p5) contains both atoms (checked in the installed binary on the Thor). We
use neither, and we don't pass `--xwayland-count`.
## Android side
- Hardware scaler: a surface whose buffer is smaller than the view is scaled by the display
hardware at no GPU cost (`SurfaceHolder.setFixedSize`; Android Developers blog 2013, NVIDIA).
Our compositor always renders a panel-sized swapchain and scales on the GPU.
- Android 16 ignores orientation and aspect-ratio locks on large screens, except for games. The
manifest's game category covers us.
- `game_mode_config.xml` already refuses OEM downscaling and FPS overrides.
## Device tests
All runs on the AYN Thor (SD 8 Gen 2, Adreno 740, 1920x1080 top panel), Deck mode on, Force
fullscreen off, with a spike build of `feat/steam-native-output`. Each run sets
`Download/droiddeck-env` and starts the session with
`am start -n com.droiddeck.launcher/.SessionActivity --es mode steam [--es steamUrl ...]`.
### Run 1: baseline (Resolution = Match screen, 1920x1080)
Steam UI idle: GPU 0%, gamescope 27 dma-bufs, Xwayland 39. Everything renders at 1920x1080.
### Run 2: client height cap (`BL_STEAM_MAX_HEIGHT=720`)
- Log: `== steam: the client's height capped at 720 (was unset)`. The client never wrote its own
value during the run, so a one-time set would hold.
- On screen: the Steam UI lays out at 1280x720 and gamescope scales it to fill 1920x1080. The
mangoapp overlay stays at native size and sharpness, so the cap affects only the client's window.
### Run 3: two Xwayland servers (`--xwayland-count 2`, `STEAM_MULTIPLE_XWAYLANDS=1`), Alan Wake's American Nightmare, Force fullscreen off
- gamescope starts Xwayland on `:0` and `:1`. The client stays on `:0`, and the game runs on `:1`:
the second Xwayland's dma-buf count rises when the game starts.
- The game runs full screen at about 60 fps with one swapchain, no resize churn, and stable buffer
counts. Nothing visibly broke in the client.
### Run 4: as run 3, Force fullscreen on
The storm still happens. The games' Xwayland went from 16 to 840 dma-bufs in about 13 s, and the
memory guard (kills the session under 2.5 GB free) stopped it. gamescope logged no mode change for
server `#1`, so it stayed at the output size (1920x1080) while the game draws at 1280x720.
### Run 5: as run 4, writing `GAMESCOPE_XWAYLAND_MODE_CONTROL = 1,1280,720,0` at session start
gamescope applied it (`Updating mode for xwayland server #1: 1280x720`), then the client set it
back to `1920x1080` when it launched the game, and the storm followed. **The client manages the games'
Xwayland size itself:** at each launch it writes the game's "Game Resolution", which defaults to
native.
### Run 6: as run 5, writing the mode 5 s after the game takes focus (what a chosen Game Resolution does)
**No storm.** Force fullscreen on, games' Xwayland at 1280x720: 10 dma-bufs on `:1` and 41 in
gamescope for the whole run, 9 frame-size changes in total (runs 4/5: 835 and 1591), memory
steady, 60 fps. gamescope scales the 1280x720 game to fill 1920x1080.
**Conclusion so far:** the resize storm comes from Force fullscreen resizing a game window to a
display size the game won't accept. With the games on their own Xwayland server at the game's own
resolution, Force fullscreen is harmless. Two Xwayland servers alone don't help, because the client
sizes the games' server to native unless the game's resolution is set.
### Run 7: two Xwayland servers, output 1920x1080, `-w 1280 -h 720` (games 720p), Alan Wake, Force fullscreen off
The client ignores gamescope's nested size. It set both servers to the output size
(`#0: 1920x1080`, `#1: 1920x1080`). **The client's "native" game resolution is gamescope's output
size.** A panel-sized output on a 3120x1440 phone would therefore make every game default to
3120x1440 until a per-game resolution is chosen.
Side finding: gamescope passed the game's 1280x720 buffers straight through to our compositor (its
frame size changed to 1280x720) instead of compositing at the output size. Our compositor does the
final scale either way.
### Default resolution history
Before #247 the default was `resolutionCap = 720` with shape `auto`: the panel's own shape at 720
lines, never narrower than 16:9 (`SessionDisplay.resolve`). On a 2412x1080 phone that is 1608x720,
which is exactly the "custom" size a tester had been using. #247 replaced it with fixed 16:9
presets (`1280x720` default, `1600x900`, `1920x1080`). Those draw the client with bars on any panel
wider than 16:9, which is the black border the tester reported after #247.
## Conclusions
1. **The output size sets the default game resolution** (run 7), so it must stay bounded on
very high-resolution panels. A native output is only safe on panels about 1080 lines or shorter.
2. **The output should keep the panel's shape**, so the client's interface is edge to edge, with
the 16:9 floor from `d2ea9b89` so a foldable's inner panel doesn't make games crop themselves.
3. **The resize storm is Force fullscreen against a display size the game won't accept** (runs
4 to 6). The SteamOS model fixes it: two Xwayland servers, with the games' server at the game's
resolution. That needs a per-game Game Resolution, so it is a follow-up, not a default.
4. `GAMESCOPE_STEAM_MAX_HEIGHT` caps only the client and works on our gamescope (run 2). It
becomes useful when the output is larger than the client should render, so it belongs with
any future native-output option.
## Change made (this branch)
The resolution presets are heights: **720p (default), 900p, 1080p**, each in the panel's shape,
never narrower than 16:9 and never taller than the panel. Then Match screen (exact panel), then
Custom. Saved fixed sizes, custom sizes and Match screen are unchanged. Only the default and the
preset list change. Resolved sizes:
| Panel | 720p | 900p | 1080p |
|---|---|---|---|
| 1920x1080 (Thor, 16:9) | 1280x720 | 1600x900 | Match screen |
| 2412x1080 (20:9 phone) | 1608x720 | 2010x900 | Match screen |
| 3120x1440 (1440p phone) | 1560x720 | 1950x900 | 2340x1080 |
| 2208x1840 (foldable inner) | 1280x720 | 1600x900 | 1920x1080 |
### Run 9: default on the Thor
With no saved resolution (the saved choice, legacy shape and cap removed from `session.xml`, then
restored): the session starts at `size=1280x720` and the device report shows `720p (1280x720)`.
## Follow-ups
- **Two Xwayland servers and per-game Game Resolution** (done, see below). Was to verify: whether the client shows
Game Resolution under game Properties in our session, the Android clipboard bridge for games on
`:1`, and the drawer's live Force fullscreen toggle (it writes `GAMESCOPE_FORCE_WINDOWS_FULLSCREEN`
only on `:0`'s root, and gamescope reads it per server). The client's devtools (port 8080 when
`.cef-enable-remote-debugging` exists) accept guest processes only, so test it from inside the
session.
- **Hardware scaler.** The compositor draws a panel-sized swapchain and scales on the GPU.
`setFixedSize` to the session size, when no compositor effect is on, would hand that scale to
the display hardware.
- **Force fullscreen per game**, the way Quake-engine titles are handled (`cbcd3f80`), instead of a
global switch.
### How runs 2 to 6 were driven
Two temporary session-script hooks, not kept on this branch: `BL_STEAM_MAX_HEIGHT=N` wrote
`GAMESCOPE_STEAM_MAX_HEIGHT` on the root window, and `BL_GAME_XWAYLAND_MODE=WxH` wrote
`GAMESCOPE_XWAYLAND_MODE_CONTROL = 1,W,H,0` five seconds after `GAMESCOPE_FOCUSED_APP` showed a
game. `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1` went through `Download/droiddeck-env`.
## Two Xwayland servers by default (`feat/steam-dual-xwayland`)
Steam sessions now start gamescope with `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1`.
`BL_XWAYLAND_COUNT=1` in `Download/droiddeck-env` goes back to one. The session script's
root-window requests that are meant for the game (the drawer's live Force fullscreen,
`GAMESCOPE_FORCE_WINDOWS_FULLSCREEN`, and the resume watcher's `GAMESCOPE_RESTORE_FOCUS_WINDOW`)
go to both displays, `$DISPLAY` and `STEAM_GAME_DISPLAY_0`. gamescope reads both per server, so
writing only to `:0` would have reached the Steam client and not the game.
Clipboard: gamescope copies a selection made on any of its Xwayland servers to all of them
(`gamescope_set_selection`), so the Android text `droiddeck-clipboard` puts on `:0` reaches games
on `:1` without changes here.
Device runs (Thor, no `droiddeck-env`):
| Run | Game | Game Resolution | Force fullscreen | Result |
|---|---|---|---|---|
| d1 | Alan Wake's American Nightmare | 1280x720 (set in Steam) | on | The client set `#1` to 1280x720 itself. 2 frame-size changes, `:1` steady at 10 dma-bufs, memory steady. No storm |
| d2 | Alan Wake's American Nightmare | 1280x720 | on → off → on, live | `stretch games to fill the screen: 0 (live)` then `1`, written to both displays. Game stable |
| d5 | 198X | default | off | The client set `#1` to native 1920x1080. Full screen, 60 fps |
Steam saves the per-game choice as `"ResolutionOverride2" "1280x720"` under the app in
`userdata/<id>/config/localconfig.vdf`.
Checked by hand (2026-10-06, Thor, Force fullscreen off):
- **FlatOut 2**, the game Force fullscreen was added for, does not shrink when the QAM opens over
it and closes again. With the client's overlay on another X server, the game keeps its own X
keyboard focus and never sees the deactivation that made it shrink.
- Resume after the Steam menu: the resume watcher's restore request reaches both servers
(`restore requested for focus window` logged once for the client's window and once for the
game's).
Still to check by hand: pasting Android text into a game.
Force fullscreen is still unsafe without a matching Game Resolution: with the default (native),
a game drawing at another size fights it as in run 4. It stays off by default.
## Force fullscreen removed (`feat/remove-force-fullscreen`)
With games on their own Xwayland server, FlatOut 2 no longer shrinks under the QAM, which was the
reason "Force game windows fullscreen" existed. What it still did was make any game whose Game
Resolution differs from what it draws fight it (runs 4 and 5). The switch is gone from Steam
settings (Games) and the session drawer (Display), along with its preference, the
`BL_GAMESCOPE_FORCE_FULLSCREEN` flag, the `~/.droiddeck-fill` live watcher and the device report
line. `BL_GAMESCOPE_ARGS=--force-windows-fullscreen` in `Download/droiddeck-env` still passes the
flag for an experiment.
Device check (Thor): Alan Wake's American Nightmare with its 1280x720 Game Resolution runs on `:1`
at 1280x720 with no `--force-windows-fullscreen` in the session, 2 frame-size changes, no crash
in logcat. The Games tab and the drawer render without the switch.
@@ -0,0 +1,379 @@
# Steam SD allocation on Thor
Investigation recorded on 2026-10-04. Device measurements and PR states below are a
snapshot of that day's work. All event times are EDT (UTC−04:00).
A fresh 1000xRESIST install on the Thor SD card completed with the native-path and
allocation-fallback patches: 2,216 files preallocated in **6m44s**, followed by a
successful download and commit, **16m36s total**. The observed allocation wrappers
reported zero failures and roughly 7.1 seconds of aggregate execution time.
That wrapper measurement does **not** account for the entire preallocation phase.
Native directory access was substantially faster than Android's FUSE view in a
small controlled probe. Kernel samples during the remaining delay showed exFAT
file-creation and size-update waits. This supports further investigation of SD
metadata writes. It does not establish that PRoot is free, that `dirsync` alone
causes the delay, or that SD now matches internal-storage install speed.
Selected source-log excerpts are preserved in
[the evidence file](evidence/thor-sd-storage-2026-10-04.txt). The original full logs
contain unrelated sessions and are not needed to interpret those excerpts.
## Device and storage
| Item | Observed configuration |
|---|---|
| Device | AYN Thor, Android 13, kernel 5.15.123, 12 GB RAM and 8 GB swap |
| App | Production-signed `com.droiddeck.launcher`, normal app UID 10195 |
| Configured SD library | `/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
| Native view of the same library | `/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
| Guest content aliases | `/mnt/droiddeck-sd` and legacy `/mnt/bannerlator-sd` |
| SD filesystem | Native exFAT; its Android `/storage` view reports FUSE |
| Native filesystem magic | `0x2011BAB0`, matching the NDK Linux header |
| Internal comparison location | App-private cache under `/data` |
The native SD mount was observed with these options:
```text
rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro
```
The device already had root and SELinux Permissive. Neither state was introduced
by this investigation. Root was used for inspection and temporary experiments.
The validated Steam session ran with the app's normal UID and existing groups,
without a root launcher or an app-specific Magisk root grant.
This distinction does not prove the native path works on an unrooted device with
enforcing SELinux. The production change tests access in the app and retains the
configured path when verification fails.
## Initial report and allocation failure
The [linked Discord report](https://discord.com/channels/1552707793247539220/1555632840388116491/1556271955076710560)
included a newer diagnostic ZIP with `storage.log`. Unlike the earlier capture
that lacked allocation instrumentation, this capture established:
- The selected secondary library was removable and its exposed filesystem was
FUSE.
- Steam called mode-zero `fallocate` and received `EOPNOTSUPP` (errno 95).
- That observed failure returned in 212 microseconds. It was not itself a
multi-minute blocked call.
A Thor baseline capture likewise showed `fallocate` failing with errno 95 in
310 microseconds. These establish an unsupported allocation API on the tested
paths. They do not attribute the later Steam delay to any particular fallback.
The suggestion that glibc's `posix_fallocate` emulation accounts for the whole
hang was not established by those logs. The tested Steam calls included
`fallocate`; directory operations and other calls occur outside that wrapper.
## Published patches and the tested build
The main base was `26cb9e2f038cdbab4cdc8670d2f64c18d1d90875`. These heads were
checked again while preparing this report; all three storage PRs were open with
successful Build APK checks and clean merge states. This is a dated snapshot,
not a statement about their future merge status.
| PR | Tested head | Behavior |
|---|---|---|
| [#221](https://github.com/Droid-Deck/DroidDeck/pull/221) | `7962c19283bfb0f18a5c7672e08a879814a3d5a1` | Verify a native SD content alias and handle safe case aliases in the path fast path |
| [#229](https://github.com/Droid-Deck/DroidDeck/pull/229) | `4628ae4c92cc5813013537165a0e95886e6de904` | Use a verified truncate fallback for unsupported selected-library allocation |
| [#233](https://github.com/Droid-Deck/DroidDeck/pull/233) | `5c6765233ae5405d7bd4c1b29f20c9e8343a1fed` | Preserve the complete fast-path binding table, including later private overrides |
| [#225](https://github.com/Droid-Deck/DroidDeck/pull/225) | `0b5908d6d63080f625b51bab3439e2e68466631e` | Repair repeated failed Steam client starts; reviewed separately, excluded from this storage build |
### Native content path and case handling: #221
For a configured `/storage/...` library, the launcher checks the corresponding
`/mnt/media_rw/...` candidate. It requires usable directory permissions and
performs a write/read/delete identity probe through both views. Failure keeps
the original configured path.
Only the shared SD content bindings change. The configured path remains the
library's identity for private prefixes, migrations and other private storage.
This avoids moving existing saves or runtime state when selecting a faster view.
Removable-volume detection also uses the original configured path because
Android's volume API need not recognize the native alias.
The C fast path permits case-only canonical-parent differences on known
FUSE/FAT/exFAT filesystems only after checking directory components without
following symlinks. It also handles the supported `F_OK` plus
`AT_SYMLINK_NOFOLLOW` case. Ambiguous paths still fall back to PRoot.
The final #221 diff was four files, 98 insertions and 20 deletions, including
28 new test lines. The experimental directory cache was removed from that diff.
### Allocation fallback: #229
The launcher first creates a temporary 1 MiB file on the selected content path,
extends it, and checks that `st_blocks` reports at least 2,048 512-byte blocks.
The file must also be cleaned up successfully. The fallback is enabled only for
a removable library that passes this reservation probe.
For file descriptors on the selected library device, the wrapper tries the real
allocation API. On mode-zero `fallocate` returning `EOPNOTSUPP`, it extends the
file with `ftruncate`. It validates the range, detects overflow and avoids
shrinking an existing file. Other modes and other errors retain their normal
behavior. The POSIX wrapper tries kernel allocation first to avoid unnecessary
libc emulation and preserves POSIX error-return and `errno` semantics.
This is a capability-tested fallback, not unconditional successful allocation
reporting. A failed extension still returns an error. The optimization operates
independently of the optional diagnostic toggle.
The reservation probe and one successful full install do not replace separate
future tests for ENOSPC, interrupted installs or other devices/filesystems.
### Complete binding table: #233
The real Steam session had **96 bindings**. The old C limit of 64 silently
dropped later mappings. Several of those were private overrides inside the
shared SD root, so truncation could send an operation to the wrong location or
return `ENOENT`.
#233 raises capacity to 256 and makes both the launcher and C implementation
disable the fast path if a complete table cannot be represented. Overlong paths
also cause fallback rather than partial mapping. This is a correctness fix as
well as a requirement for trusting the storage fast path.
Marker files existed only in private directories. A normal app-process probe
read them through these guest paths:
| Guest path under the library | Before #233 | After #233 |
|---|---|---|
| `SteamLinuxRuntime_4-arm64` | Correct private marker | Correct private marker |
| `steamlinuxruntime_4-arm64` | Correct private marker | Correct private marker |
| `proton experimental (arm64)` | `ENOENT` | Correct private marker |
| Legacy `steamapps/shadercache` | `ENOENT` | Correct private marker |
Each successful read returned the 17-byte `private-override\n` marker. The
temporary markers and helper executable were removed afterward.
### Deployment and checks
The full fresh install used #221 plus #229. #233 was then integrated locally,
tested, built and installed for the binding regression and game-launch check.
It was not merged on GitHub during this investigation.
The combined integration commit was
`f5d3e93b8eb8912ca3834f7447a6498c95250177`. The production-signed APK was installed
in place with `adb install -r`; its SHA-256 was:
```text
099a0d7e76b9462b3b238c6f1abeb03570ec9708ace3092adf178ec2bee63e2a
```
`tools/build_local.sh` completed for the combined build. The release unit tests
for `SecondaryLibraryTest` and `ProotFastPathTest` passed. The latter covers
96/256 bindings, exceeding capacity, and overlong paths. #225 was reviewed for
scope; its tests were not run as part of this storage validation.
## Fresh real Steam install
Game: **1000xRESIST**, app 1675830, depot 1675831, BuildID 21498291. Steam reported
6,582,351,824 download bytes and 7,997,085,707 staging bytes, approximately 8 GB.
| Event | Time | Evidence |
|---|---|---|
| Fresh preallocation began | 17:31:20 | Steam entered `Preallocating` |
| Preallocation completed | 17:38:04 | 2,216 files, 7,626 MB in Steam's log |
| Download/staging began | 17:38:06 | `Downloading,Staging` |
| Commit began | 17:45:25 | 2,230 updated files |
| Fully installed | 17:47:55 | `Fully Installed`, BuildID 21498291 |
| Scheduler completed | 17:47:56 | `No Error` |
Preallocation took **404 seconds**. Start to scheduler completion took
**996 seconds**. Live counters showed 2,216 allocation calls, zero allocation
failures and about **7.1 seconds aggregate allocation-wrapper time**. The saved
diagnostic detail log is capped at the first 64 allocation details per process;
that file alone cannot reproduce the live aggregate counter observation.
The final manifest had `StateFlags=4`, `UpdateResult=0`, all expected download
and staging bytes completed, and `SizeOnDisk=7997085707`. The completed executable
was under `steamapps/common/1000xRESIST`; the active staging directory was gone.
Transfer rates were mostly 117–132 Mbps, about 15–16.5 MB/s. Two root metadata
experiments ran during downloading, so this is not a clean network/card
throughput benchmark. No matched full internal-storage install was performed.
The run's `storage.log` reported native exFAT and a passed truncate reservation
probe. Recorded allocation details used `api=fallocate_ftruncate`, with
`result=0` and `api_error=0`.
## What the metadata measurements show
Metadata is filesystem bookkeeping: names, directory entries, sizes and space
allocation. Steam's `Preallocating` phase includes work outside the observed
allocation wrappers, including creating and locating files.
### Directory reads through native and FUSE paths
A helper ran in a normal app process and listed the same stable asset directory
from a saved staging tree through both host paths. It used the existing fast-path
syscall trampoline to bypass PRoot path translation while retaining the app's
Android permissions. The read buffer was 128 KiB.
| View | Elapsed | Successful `getdents64` calls | Directory bytes |
|---|---|---|---|
| Native exFAT | About 2.3 ms | 2 | 154,920 |
| Android FUSE | About 501 ms | 112 | 154,872 |
The views returned slightly different byte counts. This was a single
native-first pass, not a repeated benchmark with fully controlled cache state.
It demonstrates a large cost difference for that directory read, not a
corresponding multiplier for a full Steam install.
An earlier root listing during concurrent file creation measured roughly
5.56 seconds through FUSE and 9.25 milliseconds native. Its directory was
changing, so it is weaker evidence and is not used as a controlled comparison.
### File creation and size updates
During the fresh native-path Steam run, sampled kernel stacks included:
```text
__sync_dirty_buffer -> exfat_update_bh -> exfat_init_ext_entry
-> exfat_add_entry -> exfat_create -> path_openat
__wait_on_buffer -> exfat_update_bhs -> exfat_free_dentry_set
-> __exfat_write_inode -> exfat_truncate -> do_sys_ftruncate
```
These were live samples, not a continuous attribution profile. They show time
waiting in the native filesystem's creation/update paths during the delay.
A root-only helper created a temporary directory, then 64 long-named files using
`open(O_CREAT|O_EXCL|O_WRONLY, 0600)`, `ftruncate(fd, 262144)` and `close`. It timed
the creation and truncate calls separately and removed its files afterward.
| Location/run | Sum of open times | Sum of truncate times | Whole loop |
|---|---|---|---|
| Native SD, original mount | 6.707435 s | 0.084806 s | 6.794807 s |
| Native SD, later repeat | 25.181255 s | 0.309577 s | 25.493210 s |
| Internal app cache, live observation | About 7.88 ms | About 0.498 ms | About 9.1 ms |
The SD results varied substantially, and these root probes do not reproduce the
normal app's complete Steam workload. The measured internal probe is not a full
internal-install benchmark. Within these probes, file creation cost far more
than the observed truncate calls.
### `dirsync` and the failed remount comparison
The [Linux mount documentation](https://man7.org/linux/man-pages/man2/mount.2.html)
defines `MS_DIRSYNC` as synchronous directory changes. Combined with the observed
exFAT buffer waits, this makes synchronous SD metadata updates a strong lead.
It remains an inference about the remaining overall delay.
A toybox remount attempt returned `Invalid argument`. A direct mount-syscall
experiment changed the root view, but Steam's observed mount table still showed
`dirsync`. A subsequent 64-file probe took 2.112288 seconds; because the effective
flag change was not established and timings were variable, this is **not proof
that removing `dirsync` improves Steam allocation**. The Linux documentation also
notes that attempts to change `MS_DIRSYNC` during a remount are silently ignored.
Original effective options were restored and checked in root and app/process
views. No remount or root-launch behavior is included in the production patches.
### What remains attributable to PRoot
One sampled Steam worker was executing an `openat` through the fast-path
trampoline at `0xffff00020`, with an absolute native host path. That establishes
that some operations bypassed PRoot's path translation in the actual session.
Other syscalls still use PRoot.
There was no matched PRoot-versus-direct full preallocation comparison. The
fraction of the 404 seconds attributable to PRoot is therefore unknown. The
earlier [PRoot performance notes](proot-performance.md) concern different hardware
and workloads and cannot supply that missing percentage.
Seven seconds in public allocation wrappers does not mean the remaining
397 seconds are unrelated to allocation: libc-internal/direct-syscall bypasses,
explicit size changes and file-creation work may occur outside those counters.
## Experiments that did not become the fix
- **Directory caching:** eager scans held up directory open; creation-driven
invalidation repeatedly reread growing directories. The capped design could
also read and discard a large directory before rereading it. A lazy variant
still left 182 files taking 4m49s while the allocation wrappers accounted for
only 0.879 seconds. These were interrupted/resumed runs, not a matched A/B
benchmark. The final #221 snapshot removes the cache entirely.
- **Allocation fallback alone:** a FUSE-path attempt was still slow after
unsupported allocation calls were handled. A later eager-cache resumed run
accumulated roughly 8.36 seconds in 1,435 successful allocation calls over
about 36 minutes of wall time. Successful wrappers alone were insufficient.
- **Launching Steam through root:** experimental wrappers failed compositor
startup. They did not produce allocation measurements and were reverted.
- **Changing mount flags:** effective removal of `dirsync` from Steam's view was
not demonstrated. The apparent probe speedup is not a validated fix.
- **Replacing PRoot or reformatting storage:** no such replacement, adoptable
storage conversion or ext4 loop-image deployment was validated here.
The early FUSE runs used partial staging state. Their file counts and durations
must not be compared to the fresh 2,216-file run as a precise speedup ratio.
## Post-install launch and separate memory failure
After deploying #221 + #229 + #233, the installed SD game was launched through
Steam. First-run Proton prefix initialization preceded the game window.
At **18:10:45**, the compositor changed from Steam Big Picture to **1000xRESIST**
and received the game's 1280×720 GPU frames. A screenshot showed the game loading
screen and spinner. A main menu or sustained gameplay was not validated.
At **18:13:48.454**, Android recorded the foreground app's exit as reason 3,
`LOW_MEMORY`, with main-app PSS 227 MB and RSS 344 MB. Those figures describe that
process, not the complete native process tree or its peak memory use. Subsequent
gamescope/reaper shutdown messages followed the parent being killed.
This was after the install had completed. It is a separate unresolved launch/
memory issue and does not undo the successful allocation/download result.
Post-kill free-memory readings cannot establish peak memory pressure.
## Remaining questions and next useful tests
1. Run the same fresh game install on internal and secondary storage with the
same manifest, awake device and no competing benchmarks. Compare preallocation,
download/staging and commit separately.
2. Match the file-creation/extension workload through PRoot and through the normal
app's direct syscall path. Keep UID, filesystem, names and sizes identical,
repeat runs and record background I/O. This can quantify the remaining tracer
contribution without changing storage semantics.
3. Establish an actual controlled difference in synchronous directory behavior
before attributing a speedup to mount flags. A remount return code alone is
insufficient; this is separate from a portable app patch.
4. Validate native-path fallback under enforcing SELinux and on unrooted devices,
plus allocation behavior near ENOSPC and after interruption. Preserve the
configured-path fallback if either access or reservation verification fails.
5. Investigate the low-memory exit with process-tree/cgroup memory and Android
kill evidence during loading. Treat it separately from install performance.
At this point, one fresh SD install and the binding regression are validated.
Internal-speed parity, the exact PRoot share, a `dirsync` remedy, broad device
coverage and stable gameplay remain unproven.
## Evidence inventory and handoff state
The device captures used for the final measurements were:
- `2026-10-04-62-app-raw-probe`: normal-app native/FUSE directory probe.
- `2026-10-04-64-steam`: fresh complete allocation/download/commit.
- Probe sessions before and after #233: private binding markers.
- `2026-10-04-67-steam`: combined-build launch, followed by Android's low-memory
kill; the exit reason was obtained separately with `dumpsys activity exit-info`.
Local originals during the investigation were under
`/tmp/droiddeck-storage-live/`: `direct-content-completed.log`,
`direct-storage-completed.log`, `app-raw-probe-session.log`,
`bind-probe-before.log`, `bind-probe-after.log`,
`metadata-mount-comparison.log`, `metadata-syscall-comparison.log`,
`game-launch-exit-info.txt`, `combined-233-build.log` and
`combined-233-tests.log`. The repository evidence file preserves the selected
excerpts even if those temporary local files disappear. Live-only counters,
kernel samples and the internal metadata timing are identified above rather
than represented as saved continuous traces.
The combined signed build and completed SD install remained on Thor. Storage
diagnostics remained enabled. Temporary probe files, executables and obsolete
partial staging were removed after validation. Original effective mount options
were restored; no app-specific Magisk root policy remained. The primary checkout's
unrelated `SessionService.kt` modification was preserved.
+62
View File
@@ -0,0 +1,62 @@
# DroidDeck 0.3.1
### Steam
- Decky plugins can be imported from ZIP files, with remote binaries verified and installed copies of the same plugin replaced. (@xXJSONDeruloXx, [#79](https://github.com/Droid-Deck/DroidDeck/pull/79))
- Steam's Internet page shows Android Wi-Fi and cellular connections. Optional Wi-Fi discovery adds network names and nearby networks with Android's Location permission. (@xXJSONDeruloXx, [#150](https://github.com/Droid-Deck/DroidDeck/pull/150))
- Paste text from the Android clipboard into Steam sessions. (@xXJSONDeruloXx, [#151](https://github.com/Droid-Deck/DroidDeck/pull/151))
- The Proton selected in Components stays in sync with Steam's default compatibility tool, and runnable builds are marked **Compatible**. Windows titles with a Valve recommendation use that default unless you choose another compatible tool for the game. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#224](https://github.com/Droid-Deck/DroidDeck/pull/224))
### Compatibility and performance
- Choose **esync**, **fsync**, experimental **ntsync**, or **wineserver** for Proton games. These sync options work around Android's missing kernel support, with esync as the default for fresh installs and existing choices preserved. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#223](https://github.com/Droid-Deck/DroidDeck/pull/223))
- droiddeck-esync packs are matched to the exact Valve, GE-Proton or CachyOS Proton build and fetched automatically. droiddeck-fsync stands in when a pack is not available. (@maxjivi05, [#189](https://github.com/Droid-Deck/DroidDeck/pull/189), [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Linux-only x86 Steam titles can run through FEX using Steam's FEX and Steam Linux Runtime, with controller input and the device's graphics drivers. Missing dependencies are requested through Steam. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- .NET and Godot games get automatic launch fixes under FEX, including the settings needed for Slay the Spire 2. Per-game overrides are kept. (@magarto, [#168](https://github.com/Droid-Deck/DroidDeck/pull/168))
### Display and effects
- Live image scaling with AMD FSR 1, Snapdragon GSR and GSR quality, NVIDIA NIS, linear and nearest filtering, plus sharpening. (@magarto, @The412Banner, [#128](https://github.com/Droid-Deck/DroidDeck/pull/128), [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
- An **Effects** page adds presets, FXAA, Toon, CRT, NTSC and Fake HDR. Advanced controls cover sharpening, debanding, brightness, contrast, gamma and saturation, with choices saved between sessions. (@The412Banner, @xXJSONDeruloXx, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198), [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
- Anisotropic filtering and texture sharpness for DirectX 9–11 games, applied on the next game launch. (@The412Banner, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
- One **Resolution** menu before starting a session, with preset dimensions, Custom and **Match screen**. Resolution, image scaling and fullscreen compatibility have separate controls. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
### Controls and sessions
- Standard Android picture-in-picture, opened manually or automatically when leaving a ready session, with second-screen controls restored when you return. (@xXJSONDeruloXx, [#156](https://github.com/Droid-Deck/DroidDeck/pull/156), [#195](https://github.com/Droid-Deck/DroidDeck/pull/195))
- A quick toggle hides or restores on-screen controls. **Touch Off** ignores touches on the game picture while keeping Android controls usable. (@xXJSONDeruloXx, [#147](https://github.com/Droid-Deck/DroidDeck/pull/147), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
- Optional game vibration and on-screen Steam, Quick Access and keyboard buttons. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
- Steam session settings use tabs for Display, Controls, Games, Audio, Session and Steam. The session menu starts with Controller, gives second-screen modes their own page, and groups live display changes separately from settings for the next game launch. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
- Compact Steam Home and Quick Access shortcuts beside Stop. Switching to Desktop stays in Steam's own menu. (@xXJSONDeruloXx, [#188](https://github.com/Droid-Deck/DroidDeck/pull/188), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
- Back's double-press window is back to 500 ms, making Quick Access easier to open. (@xXJSONDeruloXx, [#193](https://github.com/Droid-Deck/DroidDeck/pull/193))
### Apps and launcher
- Android home-screen shortcuts for installed Steam games and added games. (@xXJSONDeruloXx, [#157](https://github.com/Droid-Deck/DroidDeck/pull/157))
- Export per-game `.droiddeck` launch files and Steam icons for Android frontends, with optional automatic folder sync as the library changes. (@xXJSONDeruloXx, [#179](https://github.com/Droid-Deck/DroidDeck/pull/179))
- Import x86 AppImages and run x86 Linux scripts and programs through FEX. Added apps have a **Use FEX** Auto/On/Off choice, and GitHub imports can use an x86_64 AppImage when no ARM64 build is available. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Vulkan Qt Quick AppImages such as OpenNOW use Wayland to avoid X11 startup failures. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Preview updates show recent published changes and how many builds behind you are when the installed Preview can be matched. (@xXJSONDeruloXx, [#159](https://github.com/Droid-Deck/DroidDeck/pull/159))
- A new empty-library intro and DroidDeck wordmark on the Steam page, plus an option to reduce app animations. (@xXJSONDeruloXx, [#182](https://github.com/Droid-Deck/DroidDeck/pull/182), [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
- The Linux file manager bookmarks the selected game library automatically. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
### Languages and logs
- Simplified Chinese. (@Dantalian-dev, [#202](https://github.com/Droid-Deck/DroidDeck/pull/202))
- Japanese, Korean and Traditional Chinese for Taiwan, Hong Kong and Macau. (@starwishHi, [#216](https://github.com/Droid-Deck/DroidDeck/pull/216))
- Optional **Storage diagnostics** include allocation and storage timing in shared session logs to help investigate slow Steam installs. (@xXJSONDeruloXx, [#158](https://github.com/Droid-Deck/DroidDeck/pull/158))
- Session logs retain their final output and include more detail when an AppImage or FEX program fails to start or crashes. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
### Fixed
- Steam's Deck controller not being found on handhelds with older kernels. (@xXJSONDeruloXx, [#146](https://github.com/Droid-Deck/DroidDeck/pull/146))
- Physical and on-screen controllers doing nothing on ColorOS devices with linked app data directories. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- Game vibration going to the phone instead of the active physical controller. The device vibrator remains the fallback for on-screen controls or controllers without motors. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- A physical mouse stopping camera movement at the screen edge. Games that request a pointer lock now use Android's relative pointer capture, released while menus or PiP are open. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- Steam remaining on a black screen after idle sleep or Android session resume. (@xXJSONDeruloXx, @jmarti326, [#155](https://github.com/Droid-Deck/DroidDeck/pull/155), [#172](https://github.com/Droid-Deck/DroidDeck/pull/172))
- Steam stuck at **Waiting for Steam** after an interrupted client update. After two starts without a frame, DroidDeck fetches the client again while preserving games, settings and user data; **Repair Steam client** also queues a manual repair. (@maxjivi05, [#225](https://github.com/Droid-Deck/DroidDeck/pull/225))
- Play racing runtime removal. Removal now shows progress and verifies completion, and first-run setup respects the selected Steam channel and saved UI scaling. (@xXJSONDeruloXx, [#185](https://github.com/Droid-Deck/DroidDeck/pull/185))
- GE-Proton and CachyOS Proton failing their protonfixes checks because Android blocks `/proc/sys/fs/file-max`. (@magarto, [#169](https://github.com/Droid-Deck/DroidDeck/pull/169))
- DirectAudio not being selected correctly, mismatched FEX helpers surviving component swaps, and FEX or its runtime not being executable from an SD-card Steam library. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.0...0.3.1)
+5
View File
@@ -16,6 +16,11 @@ export QT_QPA_PLATFORMTHEME=lxqt
# The prefs for this are only advisory and a profile can override them; the environment is not.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
# Firefox seals the size of the shared memory it hands a tab; on Android 16 (Galaxy S25/S26,
# RedMagic) adding the seal fails, Firefox only warns, and the tab refuses the unsealed memory
# ("Shared memory PlatformHandle is not safe to map") and dies on signal 11 - every page. Firefox's
# own switch skips the seals.
export MOZ_SHM_NO_SEALS=1
# pixman composites in software and gives programs shm buffers only, so a Vulkan program on it
# fails at its swapchain. vulkan (or gles2) composite on the GPU and give programs DMA-BUFs, but
# stock wlroots allocates through gbm on a real DRM node and the KGSL stand-in is not one ("unable
@@ -24,6 +24,8 @@ fi
# X11, which it picks from the session type.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
# Android 16 refuses Firefox the size seal on a tab's shared memory; see droiddeck-desktop.
export MOZ_SHM_NO_SEALS=1
# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and
# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads.
if [ -s /etc/ld.so.preload ]; then
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more