Compare commits

..
Author SHA1 Message Date
maxjivi05 fd69ed7c5f Ship the SteamVR driver and layer in the VR pack and let Setup uninstall it
The tracking pack becomes the vr pack: it now also carries the droiddeck-vr
driver and Vulkan layer, the Luma Ultra base calibration and the licenses of
everything inside it, so none of it ships in the APK. The guest copy is staged
from the pack before each session and removed when the pack is absent, which
also cleans up what older builds installed. Setup shows the VR module with
download and uninstall, the session drawer and Play SteamVR download it on
demand, and an older tracking or vr pack is replaced on upgrade.
2026-10-20 12:10:45 -04:00
231 changed files with 1980 additions and 7164 deletions

No files matched your search

-11
View File
@@ -321,17 +321,6 @@ jobs:
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
mkdir -p pw-pkgs
grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
rm pw-pkgs/pkg.tar.zst
done
tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
rm -rf pw-pkgs
ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
+2 -2
View File
@@ -61,7 +61,7 @@ jobs:
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
for name in proot directaudio vr xbox comet; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
@@ -71,7 +71,7 @@ jobs:
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" "$file" \
|| echo "::warning::creating $tag failed; checking whether another run published it"
digest=$(published_digest "$tag" "$name.tar.zst")
fi
+3 -3
View File
@@ -15,13 +15,13 @@ 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). 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. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier.
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. SteamVR on glasses, Quest and Cardboard and camera hand tracking use the **VR module**, a one-time download from Setup or the first **Play SteamVR** that Setup can uninstall again. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier.
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** 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.
## Smart glasses
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, and XREAL connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
## Community
@@ -49,7 +49,7 @@ Compatibility and performance vary by device; hardware validation is limited. De
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
+6 -6
View File
@@ -84,10 +84,10 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
def vrPack = rootProject.file('tools/packs/vr/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def addonPacks = ['proot', 'directaudio', 'vr', 'xbox', 'comet'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
@@ -195,7 +195,7 @@ android {
androidResources {
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>droiddeck-vr:<dir>directaudio'
}
packagingOptions {
@@ -205,7 +205,7 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
// Shipped in the proot and VR packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -313,7 +313,7 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation "org.opencv:opencv:${vrPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
@@ -333,7 +333,7 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
implementation("com.google.mediapipe:tasks-vision:${vrPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
-4
View File
@@ -39,10 +39,6 @@ target_link_libraries(deviceinfo dl)
add_library(termux SHARED termux/termux.c)
add_library(ddvenc SHARED venc/venc_server.cpp)
target_compile_features(ddvenc PRIVATE cxx_std_17)
target_link_libraries(ddvenc mediandk log)
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
if(DROIDDECK_VITURE_SDK_DIR)
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
+10 -163
View File
@@ -21,9 +21,6 @@
#include <dirent.h>
#include <dlfcn.h>
#ifdef __GLIBC__
#include <gnu/lib-names.h>
#endif
#include <fcntl.h>
#include <linux/input.h>
#include <linux/joystick.h>
@@ -3580,21 +3577,7 @@ EXPORT int close(int fd) {
static constexpr int kMaxRingWaitMs = 2;
static constexpr int kLowLatencyRingWaitUs = 250;
static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) {
size_t bytes = count * sizeof(struct input_event);
struct iovec local = {const_cast<struct input_event *>(events), bytes};
struct iovec remote = {buf, bytes};
ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0);
if (copied < 0) return -1;
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
copied -= copied % sizeof(struct input_event);
if (!copied) return -1;
}
return copied;
}
static ssize_t read_input(int fd, void *buf, size_t count) {
EXPORT ssize_t read(int fd, void *buf, size_t count) {
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto controller = controller_map().find(fd);
@@ -3639,44 +3622,42 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
if (events == 0) break;
const uint8_t *ring_events = reinterpret_cast<const uint8_t *>(fake.ring) +
FAKE_INPUT_RING_HEADER_SIZE;
std::vector<struct input_event> input_events(events);
for (size_t i = 0; i < events; ++i) {
size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY;
RingEvent stored;
memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
struct input_event &ev = input_events[i];
struct input_event ev = {};
ev.time.tv_sec = stored.sec;
ev.time.tv_usec = stored.usec;
ev.type = stored.type;
ev.code = stored.code;
ev.value = stored.value;
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
}
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED))
continue;
ssize_t copied = copy_input_events(buf, input_events.data(), events);
if (copied > 0) fake.read_seq += copied / sizeof(struct input_event);
return copied;
fake.read_seq += events;
return static_cast<ssize_t>(events * sizeof(struct input_event));
}
}
size_t events = std::min(requested, fake.keyframe_remaining);
struct input_event input_events[kNeutralEventCount] = {};
struct timeval now = {};
gettimeofday(&now, nullptr);
for (size_t i = 0; i < events; ++i) {
size_t index = kNeutralEventCount - fake.keyframe_remaining + i;
struct input_event &ev = input_events[i];
size_t index = kNeutralEventCount - fake.keyframe_remaining;
struct input_event ev = {};
ev.time = now;
ev.type = kNeutralEvents[index].type;
ev.code = kNeutralEvents[index].code;
ev.value = keyframe_value(fake, ev.type, ev.code);
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
--fake.keyframe_remaining;
}
ssize_t copied = copy_input_events(buf, input_events, events);
if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event);
return copied;
return static_cast<ssize_t>(events * sizeof(struct input_event));
}
if (nonblock) {
errno = EAGAIN;
@@ -3692,52 +3673,6 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
}
}
EXPORT ssize_t read(int fd, void *buf, size_t count) {
return read_input(fd, buf, count);
}
EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) {
if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt);
if (iovcnt < 0 || iovcnt > IOV_MAX) {
errno = EINVAL;
return -1;
}
if (iovcnt && !iov) {
errno = EFAULT;
return -1;
}
std::vector<struct iovec> copied_iov(static_cast<size_t>(iovcnt));
if (iovcnt) {
size_t bytes = copied_iov.size() * sizeof(struct iovec);
struct iovec local = {copied_iov.data(), bytes};
struct iovec remote = {const_cast<struct iovec *>(iov), bytes};
ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0);
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
return -1;
}
}
iov = copied_iov.data();
size_t size = 0;
for (int i = 0; i < iovcnt; ++i) {
if (iov[i].iov_len > static_cast<size_t>(SSIZE_MAX) - size) {
errno = EINVAL;
return -1;
}
size += iov[i].iov_len;
}
ssize_t total = 0;
for (int i = 0; i < iovcnt; ++i) {
if (!iov[i].iov_len) continue;
ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len);
if (got < 0) return total ? total : -1;
total += got;
if (static_cast<size_t>(got) != iov[i].iov_len) break;
}
return total;
}
EXPORT ssize_t write(int fd, const void *buf, size_t count) {
static auto my_write = reinterpret_cast<decltype(&::write)>(dlsym(RTLD_NEXT, "write"));
@@ -4168,92 +4103,4 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
if (!real) return fstat64(fd, buf);
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
}
#if defined(__x86_64__) || defined(__i386__)
using dlsym_fn = void *(*)(void *, const char *);
static const char *const interposed_names[] = {
"open", "open64", "openat", "openat64", "close", "read", "readv", "write", "writev", "ioctl",
"poll", "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64",
"__fxstat", "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access",
"faccessat", "opendir", "scandir", "inotify_add_watch",
};
static dlsym_fn next_dlsym() {
static const dlsym_fn fn = [] {
#ifdef __x86_64__
const char *base = "GLIBC_2.2.5";
#else
const char *base = "GLIBC_2.0";
#endif
for (const char *version : {base, "GLIBC_2.34"}) {
if (auto found = reinterpret_cast<dlsym_fn>(dlvsym(RTLD_NEXT, "dlsym", version))) return found;
}
return dlsym_fn{};
}();
return fn;
}
static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); }
static void *missing_dlsym(void *, const char *) { return nullptr; }
static bool interposed(const char *name) {
for (const char *interposed_name : interposed_names) {
if (strcmp(interposed_name, name) == 0) return true;
}
return false;
}
extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) {
dlsym_fn next = next_dlsym();
if (!next) return reinterpret_cast<void *>(missing_dlsym);
if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast<void *>(next);
static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD);
if (handle != libc || !interposed(name)) return reinterpret_cast<void *>(next);
return reinterpret_cast<void *>(global_dlsym);
}
#ifdef __x86_64__
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"pushq %rdi\n"
".cfi_adjust_cfa_offset 8\n"
"pushq %rsi\n"
".cfi_adjust_cfa_offset 8\n"
"subq $8, %rsp\n"
".cfi_adjust_cfa_offset 8\n"
"call fakeinput_dlsym_target\n"
"addq $8, %rsp\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rsi\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rdi\n"
".cfi_adjust_cfa_offset -8\n"
"jmp *%rax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#else
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"subl $12, %esp\n"
".cfi_adjust_cfa_offset 12\n"
"movl 20(%esp), %eax\n"
"movl %eax, 4(%esp)\n"
"movl 16(%esp), %eax\n"
"movl %eax, (%esp)\n"
"call fakeinput_dlsym_target\n"
"addl $12, %esp\n"
".cfi_adjust_cfa_offset -12\n"
"jmp *%eax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#endif
#endif
#endif
-10
View File
@@ -1,8 +1,5 @@
#include <errno.h>
#include <jni.h>
#include <stdatomic.h>
#include <sys/syscall.h>
#include <unistd.h>
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
@@ -10,10 +7,3 @@ Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env,
(void)clazz;
atomic_thread_fence(memory_order_release);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_session_ThreadSignals_tgkill(JNIEnv *env, jclass clazz, jint tgid, jint tid, jint signal) {
(void)env;
(void)clazz;
return syscall(SYS_tgkill, tgid, tid, signal) == 0 ? 0 : errno;
}
-55
View File
@@ -1,55 +0,0 @@
#ifndef DROIDDECK_VENC_PROTO_H
#define DROIDDECK_VENC_PROTO_H
#include <stdint.h>
/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and
* MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS;
* every frame gets one reply, touches get none. */
#define VENC_MAX_BUFFERS 16
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5 };
enum venc_touch_action { VENC_TOUCH_DOWN = 0, VENC_TOUCH_MOVE = 1, VENC_TOUCH_UP = 2 };
enum venc_codec { VENC_H264 = 0, VENC_HEVC = 1 };
struct venc_config {
uint32_t type;
uint32_t codec;
uint32_t width, height, stride, rows;
uint32_t fps_num, fps_den;
uint32_t bitrate, peak_bitrate, vbr;
uint32_t gop;
int32_t profile, level;
uint32_t inputs, outputs;
uint32_t input_size, output_size;
};
struct venc_frame {
uint32_t type;
uint32_t input, output;
uint32_t bytes;
uint32_t keyframe;
int64_t pts_us;
};
struct venc_done {
uint32_t type;
int32_t status;
uint32_t bytes;
uint32_t keyframe;
};
struct venc_bitrate {
uint32_t type;
uint32_t bitrate;
};
/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */
struct venc_touch {
uint32_t type;
uint32_t action;
uint32_t x, y;
};
#endif
-346
View File
@@ -1,346 +0,0 @@
#include <android/log.h>
#include <dlfcn.h>
#include <jni.h>
#include <media/NdkMediaCodec.h>
#include <media/NdkMediaFormat.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
#include "venc_proto.h"
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckVenc", __VA_ARGS__)
namespace {
struct Mapping {
uint8_t *data = nullptr;
size_t size = 0;
};
constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
constexpr uint32_t kBufferFlagKeyFrame = 1;
constexpr uint32_t kBufferFlagCodecConfig = 2;
constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt
constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32)
int32_t avc_profile(int32_t v4l2) {
switch (v4l2) {
case 0: return 1;
case 1: return 0x10000;
case 2: return 2;
case 3: return 4;
case 4: return 8;
default: return 0;
}
}
class Session {
public:
explicit Session(int fd) : fd_(fd) {}
~Session() {
release();
close(fd_);
}
void run() {
for (;;) {
uint8_t buf[256];
int fds[VENC_MAX_BUFFERS * 2];
int nfds = 0;
ssize_t n = receive(buf, sizeof buf, fds, &nfds);
if (n <= 0) return;
uint32_t type;
memcpy(&type, buf, sizeof type);
if (type == VENC_CONFIG && n == (ssize_t)sizeof(venc_config)) {
venc_config c;
memcpy(&c, buf, sizeof c);
bool ok = configure(c, fds, nfds);
for (int i = 0; i < nfds; i++) close(fds[i]);
reply(ok ? 0 : -1, 0, false);
} else if (type == VENC_FRAME && n == (ssize_t)sizeof(venc_frame)) {
venc_frame f;
memcpy(&f, buf, sizeof f);
uint32_t bytes = 0;
bool key = false;
bool ok = encode(f, &bytes, &key);
reply(ok ? 0 : -1, bytes, key);
} else if (type == VENC_TOUCH && n == (ssize_t)sizeof(venc_touch)) {
venc_touch t;
memcpy(&t, buf, sizeof t);
touch(t);
for (int i = 0; i < nfds; i++) close(fds[i]);
} else if (type == VENC_BITRATE && n == (ssize_t)sizeof(venc_bitrate)) {
venc_bitrate b;
memcpy(&b, buf, sizeof b);
set_bitrate(b.bitrate);
for (int i = 0; i < nfds; i++) close(fds[i]);
} else {
LOG("unexpected message %u of %zd bytes; closing", type, n);
for (int i = 0; i < nfds; i++) close(fds[i]);
return;
}
}
}
private:
ssize_t receive(uint8_t *buf, size_t len, int *fds, int *nfds) {
char control[CMSG_SPACE(sizeof(int) * VENC_MAX_BUFFERS * 2)];
iovec iov = {buf, len};
msghdr m = {};
m.msg_iov = &iov;
m.msg_iovlen = 1;
m.msg_control = control;
m.msg_controllen = sizeof control;
ssize_t n = recvmsg(fd_, &m, MSG_CMSG_CLOEXEC);
for (cmsghdr *c = CMSG_FIRSTHDR(&m); n > 0 && c; c = CMSG_NXTHDR(&m, c)) {
if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) continue;
int count = (int)((c->cmsg_len - CMSG_LEN(0)) / sizeof(int));
memcpy(fds, CMSG_DATA(c), sizeof(int) * std::min(count, VENC_MAX_BUFFERS * 2));
*nfds = std::min(count, VENC_MAX_BUFFERS * 2);
}
return n;
}
void reply(int32_t status, uint32_t bytes, bool key) {
venc_done d = {VENC_DONE, status, bytes, key ? 1u : 0u};
send(fd_, &d, sizeof d, MSG_NOSIGNAL);
}
void release() {
if (codec_) {
AMediaCodec_stop(codec_);
AMediaCodec_delete(codec_);
codec_ = nullptr;
}
for (auto &m : inputs_) munmap(m.data, m.size);
for (auto &m : outputs_) munmap(m.data, m.size);
inputs_.clear();
outputs_.clear();
header_.clear();
}
AMediaCodec *create(const venc_config &c, bool with_profile) {
const char *mime = c.codec == VENC_HEVC ? "video/hevc" : "video/avc";
AMediaCodec *codec = AMediaCodec_createEncoderByType(mime);
if (!codec) return nullptr;
float fps = c.fps_num ? (float)c.fps_den / (float)c.fps_num : 60.0f;
AMediaFormat *f = AMediaFormat_new();
AMediaFormat_setString(f, AMEDIAFORMAT_KEY_MIME, mime);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_WIDTH, (int32_t)c.width);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_HEIGHT, (int32_t)c.height);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_COLOR_FORMAT, kColorFormatYuv420SemiPlanar);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_BIT_RATE, (int32_t)std::max(c.bitrate, 500000u));
AMediaFormat_setInt32(f, "bitrate-mode", c.vbr ? 1 : 2);
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_FRAME_RATE, fps);
AMediaFormat_setFloat(f, "operating-rate", fps);
AMediaFormat_setInt32(f, "priority", 0);
AMediaFormat_setInt32(f, "latency", 1);
AMediaFormat_setInt32(f, "max-bframes", 0);
AMediaFormat_setInt32(f, "prepend-sps-pps-to-idr-frames", 1);
float gop_seconds = c.gop && c.gop < 0xffff ? (float)c.gop / fps : 3600.0f;
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, std::max(gop_seconds, 0.1f));
if (with_profile) {
int32_t profile = c.codec == VENC_HEVC ? (c.profile == 2 ? 2 : 1) : avc_profile(c.profile);
if (profile) AMediaFormat_setInt32(f, "profile", profile);
}
media_status_t s = AMediaCodec_configure(codec, f, nullptr, nullptr, AMEDIACODEC_CONFIGURE_FLAG_ENCODE);
AMediaFormat_delete(f);
if (s == AMEDIA_OK) s = AMediaCodec_start(codec);
if (s != AMEDIA_OK) {
AMediaCodec_delete(codec);
return nullptr;
}
return codec;
}
bool configure(const venc_config &c, const int *fds, int nfds) {
release();
if (c.inputs + c.outputs != (uint32_t)nfds || c.width < 64 || c.height < 64 || c.stride < c.width ||
c.rows < c.height || (uint64_t)c.stride * c.rows * 3 / 2 > c.input_size) {
LOG("bad config: %u+%u buffers for %d fds, %ux%u stride %u", c.inputs, c.outputs, nfds, c.width, c.height,
c.stride);
return false;
}
for (int i = 0; i < nfds; i++) {
bool input = (uint32_t)i < c.inputs;
size_t size = input ? c.input_size : c.output_size;
void *p = mmap(nullptr, size, input ? PROT_READ : PROT_READ | PROT_WRITE, MAP_SHARED, fds[i], 0);
if (p == MAP_FAILED) {
LOG("could not map buffer %d: %s", i, strerror(errno));
release();
return false;
}
(input ? inputs_ : outputs_).push_back({(uint8_t *)p, size});
}
codec_ = create(c, true);
if (!codec_) codec_ = create(c, false);
if (!codec_) {
LOG("no %s encoder for %ux%u", c.codec == VENC_HEVC ? "HEVC" : "H.264", c.width, c.height);
release();
return false;
}
config_ = c;
dst_stride_ = (int32_t)c.width;
dst_rows_ = (int32_t)c.height;
using InputFormat = AMediaFormat *(*)(AMediaCodec *);
static auto input_format = (InputFormat)dlsym(RTLD_DEFAULT, "AMediaCodec_getInputFormat");
if (AMediaFormat *in = input_format ? input_format(codec_) : nullptr) {
int32_t v;
if (AMediaFormat_getInt32(in, "stride", &v) && v >= (int32_t)c.width) dst_stride_ = v;
if (AMediaFormat_getInt32(in, "slice-height", &v) && v >= (int32_t)c.height) dst_rows_ = v;
AMediaFormat_delete(in);
}
LOG("%s %ux%u at %.2f fps, %u kbit/s %s, input stride %d rows %d", c.codec == VENC_HEVC ? "HEVC" : "H.264",
c.width, c.height, c.fps_num ? (double)c.fps_den / c.fps_num : 0.0, c.bitrate / 1000,
c.vbr ? "VBR" : "CBR", dst_stride_, dst_rows_);
return true;
}
static void touch(const venc_touch &t) {
using SendTouch = void (*)(int, int, int, int);
static SendTouch send_touch = [] {
void *wayland = dlopen("libdroiddeckwayland.so", RTLD_NOW | RTLD_NOLOAD);
auto fn = wayland ? (SendTouch)dlsym(wayland, "droiddeck_wayland_send_touch") : nullptr;
if (!fn) LOG("remote touch unavailable: %s", dlerror());
return fn;
}();
if (!send_touch || t.action > VENC_TOUCH_UP) return;
int x = (int)((uint64_t)std::min(t.x, 65535u) * (kTouchWidth - 1) / 65535);
int y = (int)((uint64_t)std::min(t.y, 65535u) * (kTouchHeight - 1) / 65535);
send_touch((int)t.action, kRemoteTouchId, x, y);
}
void set_bitrate(uint32_t bitrate) {
if (!codec_) return;
AMediaFormat *p = AMediaFormat_new();
AMediaFormat_setInt32(p, "video-bitrate", (int32_t)std::max(bitrate, 500000u));
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
bool encode(const venc_frame &f, uint32_t *bytes, bool *key) {
if (!codec_ || f.input >= inputs_.size() || f.output >= outputs_.size()) return false;
if (f.keyframe) {
AMediaFormat *p = AMediaFormat_new();
AMediaFormat_setInt32(p, "request-sync", 0);
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
ssize_t in = AMediaCodec_dequeueInputBuffer(codec_, 100000);
if (in < 0) return false;
size_t capacity = 0;
uint8_t *dst = AMediaCodec_getInputBuffer(codec_, (size_t)in, &capacity);
size_t need = (size_t)dst_stride_ * dst_rows_ + (size_t)dst_stride_ * ((config_.height + 1) / 2);
if (!dst || capacity < need) return false;
const uint8_t *src = inputs_[f.input].data;
for (uint32_t y = 0; y < config_.height; y++)
memcpy(dst + (size_t)y * dst_stride_, src + (size_t)y * config_.stride, config_.width);
const uint8_t *src_uv = src + (size_t)config_.stride * config_.rows;
uint8_t *dst_uv = dst + (size_t)dst_stride_ * dst_rows_;
for (uint32_t y = 0; y < (config_.height + 1) / 2; y++)
memcpy(dst_uv + (size_t)y * dst_stride_, src_uv + (size_t)y * config_.stride, config_.width);
if (AMediaCodec_queueInputBuffer(codec_, (size_t)in, 0, need, (uint64_t)std::max<int64_t>(f.pts_us, 0), 0) !=
AMEDIA_OK)
return false;
Mapping &out = outputs_[f.output];
for (int tries = 0; tries < 20; tries++) {
AMediaCodecBufferInfo info;
ssize_t o = AMediaCodec_dequeueOutputBuffer(codec_, &info, 20000);
if (o == AMEDIACODEC_INFO_TRY_AGAIN_LATER || o == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED ||
o == AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED)
continue;
if (o < 0) return false;
size_t size = 0;
uint8_t *data = AMediaCodec_getOutputBuffer(codec_, (size_t)o, &size);
const uint8_t *payload = data ? data + info.offset : nullptr;
size_t length = data ? (size_t)info.size : 0;
if (info.flags & kBufferFlagCodecConfig) {
header_.assign(payload, payload + length);
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
continue;
}
*key = (info.flags & kBufferFlagKeyFrame) != 0;
bool prepend = *key && !header_.empty() &&
(length < header_.size() || memcmp(payload, header_.data(), header_.size()) != 0);
size_t total = length + (prepend ? header_.size() : 0);
bool fits = payload && total <= out.size;
if (fits) {
if (prepend) memcpy(out.data, header_.data(), header_.size());
memcpy(out.data + (prepend ? header_.size() : 0), payload, length);
*bytes = (uint32_t)total;
}
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
return fits;
}
return false;
}
int fd_;
AMediaCodec *codec_ = nullptr;
venc_config config_ = {};
int32_t dst_stride_ = 0, dst_rows_ = 0;
std::vector<Mapping> inputs_, outputs_;
std::vector<uint8_t> header_;
};
std::atomic<int> g_listener{-1};
void serve(int listener) {
for (;;) {
int fd = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
if (fd < 0) {
if (errno == EINTR) continue;
return;
}
std::thread([fd] {
Session s(fd);
s.run();
}).detach();
}
}
} // namespace
extern "C" JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
if (g_listener.load() >= 0) return JNI_TRUE;
const char *path = env->GetStringUTFChars(jpath, nullptr);
sockaddr_un addr = {};
addr.sun_family = AF_UNIX;
bool fits = strlen(path) < sizeof addr.sun_path;
if (fits) strcpy(addr.sun_path, path);
int fd = fits ? socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0) : -1;
if (fd >= 0) {
unlink(path);
if (bind(fd, (sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 4) != 0) {
LOG("could not listen on %s: %s", path, strerror(errno));
close(fd);
fd = -1;
}
}
env->ReleaseStringUTFChars(jpath, path);
if (fd < 0) return JNI_FALSE;
g_listener.store(fd);
std::thread(serve, fd).detach();
return JNI_TRUE;
}
extern "C" JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStop(JNIEnv *, jobject) {
int fd = g_listener.exchange(-1);
if (fd >= 0) {
shutdown(fd, SHUT_RDWR);
close(fd);
}
}
@@ -30,7 +30,11 @@ class App : Application() {
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
prefetchAddonPacks()
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(this).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(this)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(this)
}
com.droiddeck.launcher.runtime.VrPack.init(this)
GpuClockPin.clearLeftover(this)
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
@@ -43,21 +47,4 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
com.droiddeck.launcher.handtracking.HandTracking.load(app)
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
}, "addon-packs").start()
}
}
@@ -11,46 +11,29 @@ import java.io.File
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyLatest by mutableStateOf<DeckyManager.Release?>(null)
var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE)
private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList())
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
var deckyDownloadingBinary by mutableStateOf(false)
var deckySupervisor by mutableStateOf(false)
var deckyPendingZip by mutableStateOf<File?>(null)
var deckyReleaseRequest = 0
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyBranch = DeckyManager.branch(activity)
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
deckyChannels = channels
deckyLatest = DeckyManager.latest(channels, deckyBranch)
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun switchDeckyBranch(branch: Int) {
if (branch == deckyBranch || deckyStage != null || SessionState.running) return
val saved = runCatching { DeckyManager.setBranch(activity, branch) }
if (saved.isFailure) {
android.widget.Toast.makeText(activity, R.string.decky_channel_failed, android.widget.Toast.LENGTH_LONG).show()
return
}
deckyBranch = branch
deckyLatest = DeckyManager.latest(deckyChannels, branch)
refreshDecky()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
@@ -66,17 +49,6 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
}
fun importPluginZip(archive: File) {
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyPendingZip = archive
}
fun cancelPluginZip() {
deckyPendingZip = null
}
fun confirmPluginZip() {
val archive = deckyPendingZip ?: return
deckyPendingZip = null
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
Thread({
@@ -124,7 +124,6 @@ class MainActivity : ComponentActivity() {
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var componentsGameKey by mutableStateOf<String?>(null)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
private var onSavePicked: ((File) -> Unit)? = null
@@ -768,9 +767,6 @@ class MainActivity : ComponentActivity() {
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
)
val page: (@Composable () -> Unit)? = when {
showPerformance -> { { PerformanceHost() } }
showComponents -> { { ComponentsHost() } }
@@ -864,7 +860,6 @@ class MainActivity : ComponentActivity() {
},
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
onGameRemoved = { g -> removeGame(g) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onSgdbKey = { key ->
Thread({
@@ -926,7 +921,6 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onGameComponents = { game -> openComponents(gameKey = game.profileKey) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
@@ -1439,9 +1433,8 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
componentsGameKey = gameKey
if (tab != null) components.compComp = tab
showPerformance = false
showMapping = false
@@ -1490,7 +1483,6 @@ class MainActivity : ComponentActivity() {
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
initialGameKey = componentsGameKey,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
@@ -1649,8 +1641,7 @@ class MainActivity : ComponentActivity() {
wifiDiscoveryAsked = wifiDiscoveryAsked,
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
deckyBranch = decky.deckyBranch,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
@@ -1733,7 +1724,6 @@ class MainActivity : ComponentActivity() {
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
@@ -1816,30 +1806,6 @@ class MainActivity : ComponentActivity() {
}, "steam-art-update").start()
}
private fun removeGame(g: Library.SteamGame) {
val folder = g.gameFiles ?: return
when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) {
com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit
com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name)
com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> {
val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g)
if (installed != null) {
com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() }
return
}
if (g.library == Library.ADDED) {
removeAddedGame(folder.path, g.gameId, g.name)
return
}
val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return
Thread({
com.droiddeck.launcher.frontend.GameRemoval.delete(target)
ui.post { refreshAddedGames(); refresh() }
}, "game-remove").start()
}
}
}
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
@@ -84,6 +84,8 @@ import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.input.TouchscreenGestures
import com.droiddeck.launcher.input.ScrollTouchGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.VrPack
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
@@ -306,11 +308,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
private var achievementsOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var hudFps by mutableStateOf(true)
private var hudBattery by mutableStateOf(true)
private var hudGraph by mutableStateOf(true)
private var hudColor by mutableStateOf(SessionPrefs.HUD_COLOR_MINT)
private var hudOpacity by mutableStateOf(SessionPrefs.HUD_OPACITY_DEFAULT)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
@@ -434,6 +431,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.steamVr = intent.getBooleanExtra(EXTRA_STEAMVR, false)
} else if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) {
SessionState.steamVr = true
if (!VrPack.installed(this)) VrPack.ensure(this)
}
if (SessionState.steamVr) Glasses.prepareSteamVrStart()
if (intent.action == SessionService.ACTION_AGENT_START) {
@@ -540,7 +538,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
val needVr = intent.getBooleanExtra(EXTRA_STEAMVR, false) && !VrPack.installed(this)
if (needRuntime || needResume || needDesktop || needProton || needVr) installThenStart(needRuntime, needDesktop, needProton, needResume, needVr)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -628,7 +627,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onDispose { com.droiddeck.launcher.glasses.GlassesMenuMirror.setSessionMenuVisible(this@SessionActivity, false) }
}
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
DroidDeckTheme {
if (pipUi) {
@@ -641,9 +639,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
} else {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model)
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
androidx.compose.ui.viewinterop.AndroidView(
@@ -741,7 +739,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
achievementsInFront = found
if (found == null) achievementsOpen = false
}
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads(); updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() }
LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) {
if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset()
}
@@ -757,11 +755,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
hudFps = hudFps,
hudBattery = hudBattery,
hudGraph = hudGraph,
hudColor = hudColor,
hudOpacity = hudOpacity,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects,
cardboard = cardboard,
@@ -789,11 +782,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onHudFps = { on -> SessionPrefs.setHudShowFps(this@SessionActivity, on); hudFps = on; hud.refresh() },
onHudBattery = { on -> SessionPrefs.setHudShowBattery(this@SessionActivity, on); hudBattery = on; hud.refresh() },
onHudGraph = { on -> SessionPrefs.setHudShowGraph(this@SessionActivity, on); hudGraph = on; hud.refresh() },
onHudColor = { color -> SessionPrefs.setHudColor(this@SessionActivity, color); hudColor = color },
onHudOpacity = { percent -> SessionPrefs.setHudOpacity(this@SessionActivity, percent); hudOpacity = percent },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
@@ -850,7 +838,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setSteamController(this@SessionActivity, id)
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
padType = id
},
backActionsInverted = backActionsInverted,
@@ -1358,11 +1345,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
unfoldedControls = SessionPrefs.unfoldedControls(this)
picturePosition = SessionPrefs.picturePosition(this)
hudOn = SessionPrefs.hudEnabled(this)
hudFps = SessionPrefs.hudShowFps(this)
hudBattery = SessionPrefs.hudShowBattery(this)
hudGraph = SessionPrefs.hudShowGraph(this)
hudColor = SessionPrefs.hudColor(this)
hudOpacity = SessionPrefs.hudOpacity(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
@@ -1403,17 +1385,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false, vr: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else if (proton) "proton" else "vr"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else if (proton) PROTON_LINES else null
loading.say(getString(first?.downloading ?: R.string.session_vr_downloading), first?.topic ?: LoadingState.Topic.RUNTIME, readable = true)
loading.percent = -1
Thread({
var failedComponent: String? = null
@@ -1442,6 +1424,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (protonProblem == null) SessionEvents.record("proton.ready")
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
}
if (problem == null && vr && !SessionState.stopRequested) {
SessionState.installing = "vr"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "vr.installing", mapOf("component" to "vr"))
problem = installVr()
if (problem == null) SessionEvents.record("vr.ready") else failedComponent = "vr"
}
uiHandler.post {
installingRuntime = false
SessionState.installing = null
@@ -1456,6 +1444,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
"vr" -> "VR_PACK_INSTALL_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
@@ -1529,6 +1518,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return problem?.let { getString(R.string.session_desktop_install_failed, it) }
}
private fun installVr(): String? {
val mb = VrPack.size / 1_000_000
uiHandler.post { loading.percent = -1; loading.say(getString(R.string.session_vr_downloading), LoadingState.Topic.RUNTIME, readable = true) }
val ok = VrPack.await(this, { !SessionState.stopRequested }) { state ->
if (state is AddonPack.State.Downloading) uiHandler.post {
val percent = (state.progress * 100).toInt()
loading.percent = percent
loading.say(getString(R.string.session_vr_downloading_mb, percent * mb / 100, mb), LoadingState.Topic.RUNTIME, readable = true)
}
}
return if (ok || SessionState.stopRequested) null else getString(R.string.session_vr_install_failed)
}
private fun installProton(): String? {
uiHandler.post { loading.percent = -1; loading.say(getString(PROTON_LINES.downloading), PROTON_LINES.topic, readable = true) }
val catalog = com.droiddeck.launcher.runtime.DesktopCatalog
@@ -1623,7 +1625,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
shownOnDisplayId,
)
}
applyFrameGen()
@@ -1674,7 +1675,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (started) SessionService.setActivityDisplay(this, shownOnDisplayId)
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
@@ -1899,22 +1899,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
var enosys = 0
var fexMissing = false
val size = log.length()
val text = java.io.RandomAccessFile(log, "r").use { f ->
java.io.RandomAccessFile(log, "r").use { f ->
val start = maxOf(0L, size - 512L * 1024)
f.seek(start)
val bytes = ByteArray((size - start).toInt())
f.readFully(bytes)
String(bytes, Charsets.ISO_8859_1)
String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line ->
if (line.contains("Function not implemented")) enosys++
if (line.contains(FEX_MISSING)) fexMissing = true
}
}
text.lineSequence().forEach { line ->
if (line.contains("Function not implemented")) enosys++
if (line.contains(FEX_MISSING)) fexMissing = true
}
val steamSetup = com.droiddeck.launcher.session.SteamClientSetupFailure.find(text.lineSequence())
if (steamSetup != null) {
val message = getString(if (steamSetup.download) R.string.session_end_steam_download else R.string.session_end_steam_setup)
EndHint(listOfNotNull(message, steamSetup.curlError).joinToString("\n"))
} else if (fexMissing) {
if (fexMissing) {
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
} else if (enosys >= 8) {
@@ -2303,13 +2298,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
private val shownOnDisplayId: Int get() =
if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId
else windowManager.defaultDisplay.displayId
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val primaryId = shownOnDisplayId
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
val candidates = SecondScreenDisplays.available(displayManager, primaryId)
.filter { glassesScreen == null || it.id != glasses.displayId }
val oldIds = secondScreenDisplays.map { it.id }.toSet()
@@ -2696,7 +2687,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi || unfoldedPanel != null || pcKeyboardOpen) {
if (pipUi || unfoldedPanel != null) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
@@ -2728,29 +2719,25 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
val drawn = show && !keyboardCoversPad()
if (steam != null) {
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!drawn) steam.releaseAll()
steam.visibility = if (drawn) View.VISIBLE else View.GONE
if (!show) steam.releaseAll()
steam.visibility = if (show) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
applyTouchRouting()
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
return
}
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
Log.i(TAG, "on-screen controls " + when {
!drawn -> "hidden"
!show -> "hidden"
buttonsOnly -> "Steam + QAM"
else -> "full pad"
})
}
private fun keyboardCoversPad() =
SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
@@ -2836,7 +2823,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
startedScreens++
SessionService.setActivityVisible(this, true, shownOnDisplayId)
SessionService.setActivityVisible(this, true)
Glasses.rescan()
updateGlassesScreen()
updateHandCamera()
@@ -2870,6 +2857,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) {
SessionState.steamVr = true
steamVrAutoTried = false
if (!VrPack.installed(this)) VrPack.ensure(this)
}
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
@@ -5,7 +5,6 @@ import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import android.os.FileObserver
import android.util.Log
@@ -20,8 +19,6 @@ object AgentGuest {
private val main by lazy { Handler(Looper.getMainLooper()) }
var focusRevision by mutableIntStateOf(0)
private set
var steamKeyboard by mutableStateOf(false)
private set
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -35,7 +32,6 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -47,7 +43,6 @@ object AgentGuest {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
}
}
}.also { it.startWatching() }
@@ -58,19 +53,9 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false }
}
}
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
val showing = snapshot.optBoolean("steamKeyboard")
synchronized(lock) {
if (source !== observer) return
main.post { steamKeyboard = showing }
}
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -19,10 +19,7 @@ import java.io.FileInputStream
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
* was made with install, whichever host serves them.
*/
internal class AddonPack(
val name: String, private val sha256: String, val size: Long, private val required: List<String>,
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
) {
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
@@ -43,20 +40,22 @@ internal class AddonPack(
private fun root(context: Context) = File(context.filesDir, "addons/$name")
/** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), version(context))
fun dir(context: Context) = File(root(context), sha256)
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
// A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed.
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
fun outdated(context: Context): Boolean = !installed(context) &&
root(context).listFiles().orEmpty().any { it.name != sha256 && File(it, COMPLETE).isFile }
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
.filter { dir ->
dir.isDirectory && runCatching { File(dir, COMPLETE).readText().trim() }.getOrNull() == dir.name &&
required.all { File(dir, it).isFile }
fun remove(context: Context): Boolean {
synchronized(lock) {
if (mutableState.value is State.Downloading) return false
root(context).deleteRecursively()
mutableState.value = State.Missing
}
.maxByOrNull { it.lastModified() }?.name
return !root(context).exists()
}
fun refresh(context: Context): State {
val now = mutableState.value
@@ -99,7 +98,7 @@ internal class AddonPack(
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, if (reuseDeviceCopy) "$name: this build has no pack and none is installed" else "$name: this build has no pack")
Log.w(TAG, "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
@@ -139,9 +138,11 @@ internal class AddonPack(
@JvmField
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
val VR = AddonPack("vr", BuildConfig.PACK_VR_SHA256, BuildConfig.PACK_VR_SIZE, listOf(
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
"libopencv_java4.so", "libmediapipe_tasks_jni.so", "luma-ultra-640x480.json",
"droiddeck/bin/linuxarm64/driver_droiddeck.so", "layer/libVkLayer_droiddeck_vr.so",
"layer/VK_LAYER_DROIDDECK_vr_implicit.json"))
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
@@ -29,7 +29,6 @@ object GameEnvironment {
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"DROIDDECK_ANTICHEAT_OFFLINE" to "1",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
"DOTNET_GCRegionRange" to "800000000",
@@ -32,7 +32,6 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
@@ -146,21 +146,16 @@ object LogRedactor {
return out
}
/** The at-rest rewrite reads a file into memory twice; larger logs are only scrubbed on the way out. */
const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024
fun isBinary(file: java.io.File): Boolean = try {
file.inputStream().use { input ->
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n > 0 && (0 until n).any { buf[it].toInt() == 0 }
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
true
false
}
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
const val RULES_VERSION = 3
private val LAN_IPV4 = Regex(
@@ -181,62 +176,8 @@ object LogRedactor {
}
}
private const val SHARE_LINE_MAX = 64 * 1024
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
src.inputStream().buffered().use { input ->
input.mark(2)
val first = input.read()
val second = input.read()
input.reset()
if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) {
out.write("<unsupported encoding; file withheld>\n")
return
}
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
java.io.InputStreamReader(input, decoder).use { reader ->
val buffer = CharArray(8192)
val line = StringBuilder()
var withheld = false
var afterCr = false
fun emit() {
out.write(if (withheld) "<unsupported or oversized line withheld>" else redactForShare(line.toString()))
out.write("\n")
line.setLength(0)
withheld = false
}
try {
while (true) {
val size = reader.read(buffer)
if (size < 0) break
for (i in 0 until size) {
val ch = buffer[i]
if (afterCr) {
afterCr = false
if (ch == '\n') continue
}
if (ch == '\r' || ch == '\n') {
emit()
afterCr = ch == '\r'
} else if (!withheld) {
// Splitting a line can separate a credential from its label.
if (ch == '\u0000' || line.length == SHARE_LINE_MAX) {
withheld = true
line.setLength(0)
} else {
line.append(ch)
}
}
}
}
if (withheld || line.isNotEmpty()) emit()
} catch (_: java.nio.charset.CharacterCodingException) {
out.write("<unsupported encoding; file withheld>\n")
}
}
}
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
}
private fun quoted(value: String): String {
@@ -85,7 +85,7 @@ class AppFilesProvider : DocumentsProvider() {
override fun deleteDocument(documentId: String) {
val file = fileFor(documentId)
if (file.canonicalPath == baseCanonical) throw FileNotFoundException("the app folder itself is not deleted")
if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
try {
deleteTree(file.toPath())
} catch (e: IOException) {
@@ -154,7 +154,7 @@ class AppFilesProvider : DocumentsProvider() {
private fun fileFor(documentId: String): File {
val f = File(documentId)
if (!f.isAbsolute || f.normalize().path != documentId || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.exists()) throw FileNotFoundException("$documentId not found")
return f
}
@@ -90,7 +90,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -152,11 +151,8 @@ fun FileManagerScreen(
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
val visibleEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
var showSearch by remember { mutableStateOf(false) }
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
@@ -178,20 +174,16 @@ fun FileManagerScreen(
var showFavorites by remember { mutableStateOf(false) }
var favTick by remember { mutableIntStateOf(0) }
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
currentDir = dir
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
listingJob?.cancel()
listingJob = scope.launch {
scope.launch {
val list = withContext(Dispatchers.IO) {
dir.listFiles()?.toList()
?.filter { if (pickDirMode) it.isDirectory else !pickMode || it.isDirectory || matchesPickExt(it) }
?.filter { showHidden || !it.name.startsWith(".") }
?.sortedWith(comparatorFor(sortBy, sortDesc)) ?: emptyList()
}
if (currentDir != dir) return@launch
entries = list
if (resetScroll) listState.scrollToItem(0)
}
@@ -228,7 +220,7 @@ fun FileManagerScreen(
fun uniqueDestination(dir: File, name: String): File {
var candidate = File(dir, name)
if (!java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return candidate
if (!candidate.exists()) return candidate
val dot = name.lastIndexOf('.')
val base = if (dot > 0) name.substring(0, dot) else name
val ext = if (dot > 0) name.substring(dot) else ""
@@ -236,7 +228,7 @@ fun FileManagerScreen(
do {
candidate = File(dir, "$base ($i)$ext")
i++
} while (java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS))
} while (candidate.exists())
return candidate
}
@@ -260,7 +252,7 @@ fun FileManagerScreen(
fun performPaste() {
val sources = clipboardFiles
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
if (sources.isEmpty()) return
val dstDir = currentDir
val cut = isCutOperation
@@ -269,7 +261,6 @@ fun FileManagerScreen(
operationDeterminate = true
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
val job = coroutineContext[kotlinx.coroutines.Job]
var applyToAll: ConflictChoice? = null
var failed = 0
@@ -287,7 +278,7 @@ fun FileManagerScreen(
}
var dst = File(dstDir, src.name)
if (java.nio.file.Files.exists(dst.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
if (dst.exists()) {
val choice = applyToAll ?: askConflict(src)?.also {
if (conflictApplyToAll) applyToAll = it
} ?: run { skipped++; null } ?: continue
@@ -305,7 +296,6 @@ fun FileManagerScreen(
}
var lastPct = -1
val onProgress = FileOps.ProgressCallback { copied, total ->
job?.ensureActive()
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
if (pct != lastPct) {
lastPct = pct
@@ -314,8 +304,8 @@ fun FileManagerScreen(
}
val target = dst
val ok = withContext(Dispatchers.IO) {
if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false }
else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false }
if (cut) FileOps.moveWithProgress(src, target, onProgress)
else FileOps.copyWithProgress(src, target, onProgress)
}
if (ok) done++ else failed++
}
@@ -801,21 +791,19 @@ fun FileManagerScreen(
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
val allVisibleSelected = visibleEntries.isNotEmpty() && visibleEntries.all { it.absolutePath in selectedPaths }
val anyVisibleSelected = visibleEntries.any { it.absolutePath in selectedPaths }
val selBarPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp)
OutlinedButton(
onClick = {
val shown = visibleEntries.map { it.absolutePath }
selectedPaths = if (allVisibleSelected) selectedPaths - shown.toSet() else selectedPaths + shown
selectedPaths = if (selectedPaths.size == entries.size) emptySet()
else entries.map { it.absolutePath }.toSet()
},
contentPadding = selBarPadding,
) { Text(if (allVisibleSelected) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = anyVisibleSelected,
enabled = selectedPaths.isNotEmpty(),
onClick = {
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
isCutOperation = false
selectionMode = false
selectedPaths = emptySet()
@@ -824,9 +812,9 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = anyVisibleSelected,
enabled = selectedPaths.isNotEmpty(),
onClick = {
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
isCutOperation = true
selectionMode = false
selectedPaths = emptySet()
@@ -835,8 +823,8 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = anyVisibleSelected,
onClick = { pendingBulkDelete = visibleEntries.filter { it.absolutePath in selectedPaths } },
enabled = selectedPaths.isNotEmpty(),
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
contentPadding = selBarPadding,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
@@ -854,7 +842,7 @@ fun FileManagerScreen(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
.clickable(enabled = !isOperationRunning) { performPaste() }
.clickable { performPaste() }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
@@ -890,7 +878,6 @@ fun FileManagerScreen(
)
if (operationJob != null) {
TextButton(onClick = {
cancelledJob = operationJob
operationJob?.cancel()
operationJob = null
isOperationRunning = false
@@ -977,7 +964,8 @@ fun FileManagerScreen(
.fillMaxSize()
.nestedScroll(pullState.nestedScrollConnection),
) {
val shownEntries = visibleEntries
val shownEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
if (showGrid && entries.isNotEmpty()) {
LazyVerticalGrid(
@@ -45,26 +45,23 @@ object FileOps {
return f.length()
}
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
if (sameFile(src, dst)) return true
if (isCancelled()) return false
if (!dst.exists() && src.renameTo(dst)) {
progress?.onProgress(1, 1)
return true
}
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
if (isCancelled()) return false
if (!copyWithProgress(src, dst, progress)) return false
return delete(src)
}
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
val total = maxOf(totalSize(src), 1L)
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
return copyWithProgress(src, dst, longArrayOf(0), total, progress)
}
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean {
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
if (isSymlink(src)) return copyLink(src, dst)
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean {
if (isSymlink(src)) return true
if (sameFile(src, dst)) return true
if (src.isDirectory && isWithin(dst, src)) {
Log.e(TAG, "Refusing to copy directory into itself: $src -> $dst")
@@ -74,7 +71,7 @@ object FileOps {
if (!dst.exists() && !dst.mkdirs()) return false
var allOk = true
src.list()?.forEach { name ->
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) {
Log.e(TAG, "Failed to copy: ${File(src, name)}")
allOk = false
}
@@ -84,41 +81,13 @@ object FileOps {
val parent = dst.parentFile
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
val chunk = 4L * 1024 * 1024
var staging: File? = null
var created = false
var output: java.nio.channels.FileChannel? = null
try {
output = try {
java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW)
.also { created = true }
} catch (e: java.nio.file.FileAlreadyExistsException) {
null
}
val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst
var permissions: Set<java.nio.file.attribute.PosixFilePermission>? = null
if (output == null) {
if (isSymlink(target) || target.parentFile?.isDirectory != true) {
throw IOException("Destination symlink target is not a writable directory entry: $dst")
}
if (target.isDirectory) throw IOException("Destination is a directory: $target")
permissions = try {
Files.getPosixFilePermissions(target.toPath())
} catch (e: java.nio.file.NoSuchFileException) {
null
} catch (e: UnsupportedOperationException) {
null
}
if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target")
val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-")
staging = directory.toFile()
output = FileOutputStream(File(staging, "file")).channel
}
FileInputStream(src).channel.use { input ->
checkNotNull(output).use { channel ->
FileOutputStream(dst).channel.use { output ->
val size = input.size()
var position = 0L
while (position < size) {
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
val transferred = input.transferTo(position, minOf(chunk, size - position), output)
if (transferred <= 0) break
position += transferred
done[0] += transferred
@@ -127,54 +96,11 @@ object FileOps {
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
}
}
staging?.let { directory ->
val copied = File(directory, "file").toPath()
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
Files.setPosixFilePermissions(copied, permissions)
}
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
}
return true
} catch (e: java.util.concurrent.CancellationException) {
if (created) dst.delete()
throw e
} catch (e: IOException) {
Log.e(TAG, "Failed to copy file: $src to $dst", e)
if (created) dst.delete()
dst.delete()
return false
} finally {
output?.close()
staging?.let { directory ->
File(directory, "file").delete()
directory.delete()
}
}
}
private fun copyLink(src: File, dst: File): Boolean {
if (src.absoluteFile == dst.absoluteFile) return true
return try {
val target = Files.readSymbolicLink(src.toPath())
val parent = dst.parentFile
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
try {
Files.createSymbolicLink(dst.toPath(), target)
} catch (e: java.nio.file.FileAlreadyExistsException) {
val staging = Files.createTempDirectory(checkNotNull(dst.absoluteFile.parentFile).toPath(), ".droiddeck-link-")
val tmp = staging.resolve("link")
try {
Files.createSymbolicLink(tmp, target)
Files.move(tmp, dst.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
} finally {
Files.deleteIfExists(tmp)
Files.deleteIfExists(staging)
}
}
true
} catch (e: Exception) {
Log.e(TAG, "Failed to copy link: $src to $dst", e)
false
}
}
@@ -41,20 +41,10 @@ object AddedGames {
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP_PREFIX = Regex("(?i)^(unins|setup|vc_?redist|dxsetup|dxwebsetup|dotnet|directx|easyanticheat|battleye).*")
private val SKIP_PART = Regex("(?i)redist|prereq|installer|uninstall|crashreport|crashhandler|crashpad|crashsender|launchhelper")
private val SKIP_WORDS = setOf(
"eac", "report", "reporter", "editor", "server", "benchmark", "helper",
"update", "updater", "config", "configure", "configurator", "configuration", "settings",
private val SKIP = Regex(
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
)
private val WORD = Regex("[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+")
internal fun skipExe(name: String): Boolean {
if (!name.endsWith(".exe", ignoreCase = true)) return false
val base = name.dropLast(4)
if (SKIP_PREFIX.matches(base) || SKIP_PART.containsMatchIn(base)) return true
return WORD.findAll(base).any { it.value.lowercase() in SKIP_WORDS }
}
const val GUEST_DIR = "/root/Games"
@@ -85,7 +75,7 @@ object AddedGames {
val key = "${folder.path}|${folder.lastModified()}"
val now = System.currentTimeMillis()
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
val guessed = GameExePicker.rank(folder) { !skipExe(it.name) }
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
val declared = declaredExes(folder)
val preferred = declared.map { it.path }.toSet()
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
@@ -138,7 +128,7 @@ object AddedGames {
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
return names.asSequence()
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) && it.isFile }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
.distinctBy { it.path }
.toList()
@@ -14,7 +14,6 @@ import java.io.InputStream
object GameFiles {
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val MAX_NAME_BYTES = 200
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
@@ -51,15 +50,8 @@ object GameFiles {
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().toArray()
var end = 0
var bytes = 0
while (end < points.size) {
bytes += String(points, end, 1).toByteArray().size
if (bytes > MAX_NAME_BYTES) break
end++
}
val title = String(points, 0, end).trimEnd { it.isWhitespace() || it == '.' }.ifBlank { "Game" }
val points = sanitized.codePoints().limit(40).toArray()
val title = String(points, 0, points.size)
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
@@ -185,7 +185,7 @@ object GameIdentifier {
}
private val JUNK_NAME_RE = Regex(
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*|bootstrappackagedgame)$"""
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
)
fun isJunkPeName(name: String): Boolean {
@@ -1,52 +0,0 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoresState
import java.io.File
object GameRemoval {
enum class Kind { STORE, SHORTCUT, LAUNCHER }
fun kind(game: Library.SteamGame): Kind = when {
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
else -> Kind.STORE
}
class SteamFiles(val dir: File, val manifest: File, val partial: File?)
fun steamFiles(game: Library.SteamGame): SteamFiles? {
val dir = game.gameFiles ?: return null
val common = dir.parentFile ?: return null
if (common.name != "common") return null
val steamapps = common.parentFile ?: return null
if (steamapps.name != "steamapps") return null
val manifest = File(steamapps, "appmanifest_${game.appId}.acf")
if (!manifest.isFile) return null
if (!declares(manifest, game.appId)) return null
return SteamFiles(dir, manifest, File(steamapps, "downloading/${game.appId}"))
}
fun delete(target: SteamFiles): Boolean {
StoreInstalls.deleteTree(target.dir)
if (target.dir.exists()) return false
target.partial?.let { StoreInstalls.deleteTree(it) }
target.manifest.delete()
return !target.manifest.exists()
}
fun storeGame(game: Library.SteamGame): InstalledStoreGame? {
val folder = game.gameFiles ?: return null
val store = Store.byId(game.source)
val id = game.storeId
if (store != null && id != null) StoresState.installedGame(store, id)?.let { return it }
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled } ?: return null
return InstalledStoreGame(sidecar, folder)
}
private fun declares(manifest: File, appId: Int): Boolean =
runCatching { manifest.readText() }.getOrNull()?.let { Regex("\"appid\"\\s+\"$appId\"").containsMatchIn(it) } == true
}
@@ -50,7 +50,6 @@ data class GlassesState(
val noSignal: Boolean = false,
val sbsSupport: Int = SBS_UNKNOWN,
val sbs90: Boolean = false,
val rates2d: List<Int> = listOf(60, 90, 120),
val experimental: Boolean = false,
val supportsScreen3dof: Boolean = false,
val supports30Hz: Boolean = false,
@@ -614,13 +613,12 @@ object Glasses {
switching3d = modeState.pendingStereo,
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock,
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel || m is RokidModel,
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel,
hasSunblock = m?.hasSunblock == true,
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
tracking = it.tracking && link != null,
noSignal = noSignal,
sbs90 = m?.sbs90 == true,
rates2d = m?.rates2d ?: listOf(60, 90, 120),
experimental = m === XrealModel,
supportsScreen3dof = link?.hasImu == true,
supports30Hz = display?.let(::supports30Hz) == true,
@@ -636,8 +634,7 @@ object Glasses {
scheduleModeCheck()
}
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) ||
vid == XrealModel.VENDOR_ID || vid == RokidModel.VENDOR_ID
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID
internal fun supports30Hz(display: Display): Boolean {
val current = display.mode
@@ -657,13 +654,11 @@ object Glasses {
?: VitureProtocol.model(device.productId, name)
}
else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel
else RokidModel.model(device.vendorId, device.productId, name)
?: RayNeoModel.model(device.vendorId, device.productId, name)
else RayNeoModel.model(device.vendorId, device.productId, name)
}
internal fun isGlassesDisplayName(name: String) =
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) ||
RokidModel.isDisplay(name)
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name)
private fun findDisplay(usbPresent: Boolean): Display? {
return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent)
@@ -728,8 +723,6 @@ object Glasses {
} else VitureLink.open(usb, device, linkListener(generation))
} else if (model === XrealModel) {
XrealLink.open(app, xrealListener(generation))
} else if (model is RokidModel) {
RokidLink.open(usb, device, rokidListener(generation))
} else {
val baseKey = calibrationKey!!
val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId)
@@ -797,33 +790,6 @@ object Glasses {
}
}
private fun rokidListener(generation: Long) = object : RokidLink.Listener {
override fun onReady() = post {
if (generation != linkGeneration) return@post
publish { it.copy(connected = true, controlError = null) }
updateImu()
applyMode(force = false)
}
override fun onValue(message: Int, value: Int) = post {
if (generation != linkGeneration) return@post
when (message) {
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) }
}
}
override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) {
if (generation != linkGeneration) return
imuPackets++
probe?.invoke(raw, pose)
if (simulating.get() == null) deliver(pose, timeNs)
}
override fun onLost() = post {
if (generation != linkGeneration) return@post
closeLink()
refresh()
}
}
private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener {
override fun onSpatial(pose: FloatArray, timeNs: Long, valid: Boolean) {
if (generation == linkGeneration) SpatialTracking.accept(pose, timeNs, generation, valid)
@@ -8,7 +8,6 @@ interface GlassesModel {
val volumeMax: Int
val diagonalFovDeg: Float
val sbs90: Boolean
val rates2d: List<Int> get() = listOf(60, 90, 120)
val hasSunblock: Boolean get() = true
val resolutions: List<GlassesResolution> get() = emptyList()
fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = mode(hz, sbs)
@@ -12,8 +12,8 @@ internal object GlassesRendering {
fun refreshRates(settings: GlassesSettings, state: GlassesState): List<Int> = buildList {
if (native30HzAvailable(settings, state)) add(30)
add(60)
if (state.sbs) { if (state.sbs90) add(90) }
else addAll(state.rates2d.filter { it > 60 })
if (!state.sbs || state.sbs90) add(90)
if (!state.sbs) add(120)
}
fun outputHz(settings: GlassesSettings, state: GlassesState): Int {
@@ -21,7 +21,6 @@ internal object GlassesRendering {
return when {
requested == 30 -> if (native30HzAvailable(settings, state)) 30 else 60
state.sbs && !state.sbs90 -> 60
!state.sbs && requested !in state.rates2d -> state.rates2d.filter { it <= requested }.maxOrNull() ?: 60
else -> requested
}
}
@@ -1,10 +1,10 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.runtime.VrPack
internal enum class HandModel(val file: String, val sha256: String) {
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
FULL(VrPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(VrPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
companion object {
val DEFAULT = FULL
@@ -1,148 +0,0 @@
package com.droiddeck.launcher.glasses
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.SystemClock
import android.util.Log
/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
internal class RokidLink private constructor(
private val connection: UsbDeviceConnection,
private val usbInterface: UsbInterface,
private val endpoint: UsbEndpoint,
private val listener: Listener,
) : GlassesLink {
interface Listener {
fun onReady()
fun onValue(message: Int, value: Int)
fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long)
fun onLost()
}
@Volatile private var running = true
@Volatile override var ready = false
private set
@Volatile private var tracking = false
private val controlLock = Any()
private lateinit var worker: Thread
override val hasImu = true
override fun send(msgId: Int, payload: ByteArray): Boolean {
if (!running) return false
val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: -1
return when (msgId) {
VitureProtocol.MSG_IMU -> (value in 0..1).also { if (it) tracking = value == 1 }
VitureProtocol.MSG_GET_DISPLAY -> display()?.let { listener.onValue(msgId, it) } != null
VitureProtocol.MSG_DISPLAY -> value in RokidModel.MODES &&
control(RokidModel.REQUEST_SET_DISPLAY, value, RokidModel.INDEX_DISPLAY) &&
true.also { listener.onValue(VitureProtocol.MSG_GET_DISPLAY, value) }
VitureProtocol.MSG_GET_BRIGHTNESS -> read(RokidModel.REQUEST_GET_BRIGHTNESS, RokidModel.INDEX_BRIGHTNESS)
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS)
else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control.
}
}
private fun display(): Int? = read(RokidModel.REQUEST_GET_DISPLAY, RokidModel.INDEX_DISPLAY)
private fun read(request: Int, index: Int): Int? = synchronized(controlLock) {
if (!running) return null
val buffer = ByteArray(64)
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS)
// Display mode is reported in the second byte, brightness in the first.
val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
if (count > offset) buffer[offset].toInt() and 0xff else null
}
private fun control(request: Int, value: Int, index: Int): Boolean = synchronized(controlLock) {
running && connection.controlTransfer(VENDOR_OUT, request, value, index, byteArrayOf(1), 1, TIMEOUT_MS) >= 0
}
private fun run() {
val buffer = ByteArray(maxOf(endpoint.maxPacketSize, 64))
val parser = RokidProtocol.Parser()
var fusion = ImuFusion()
var wasTracking = false
var brightness = -1
var lastSensor = SystemClock.elapsedRealtime()
try {
ready = true
listener.onReady()
while (running) {
val count = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
val now = SystemClock.elapsedRealtime()
val sample = if (count > 0) parser.parse(buffer, count) else null
if (sample == null) {
if (now - lastSensor > 3000) error("Rokid IMU stream stalled")
continue
}
lastSensor = now
sample.brightness?.takeIf { it != brightness }?.let {
brightness = it
listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it))
}
val enabled = tracking
if (enabled && !wasTracking) fusion = ImuFusion()
wasTracking = enabled
if (!enabled) continue
// Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ.
fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
}
}
} catch (e: Exception) {
if (running) Log.w(TAG, "Rokid stream failed", e)
} finally {
ready = false
if (running) listener.onLost()
}
}
override fun close() {
synchronized(controlLock) {
if (!running) return
running = false
}
if (Thread.currentThread() !== worker) worker.join(500)
runCatching { connection.releaseInterface(usbInterface) }
connection.close()
}
companion object {
private const val TAG = "RokidLink"
private const val TIMEOUT_MS = 250
private const val VENDOR_IN = UsbConstants.USB_DIR_IN or UsbConstants.USB_TYPE_VENDOR
private const val VENDOR_OUT = UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_VENDOR
internal fun sensorEndpoint(device: UsbDevice): Pair<UsbInterface, UsbEndpoint>? {
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue
for (e in 0 until iface.endpointCount) {
val endpoint = iface.getEndpoint(e)
if (endpoint.direction == UsbConstants.USB_DIR_IN &&
endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) return iface to endpoint
}
}
return null
}
fun open(usb: UsbManager, device: UsbDevice, listener: Listener): RokidLink? {
val (iface, endpoint) = sensorEndpoint(device) ?: return null
val conn = usb.openDevice(device) ?: return null
if (!conn.claimInterface(iface, true)) {
conn.close()
return null
}
return RokidLink(conn, iface, endpoint, listener).also { link ->
link.worker = Thread({ link.run() }, "rokid-imu").apply { isDaemon = true }
link.worker.start()
}
}
}
}
@@ -1,104 +0,0 @@
package com.droiddeck.launcher.glasses
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Rokid Air/Max share one USB identity; the product string distinguishes them. */
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
override val controllable = true
override val brightnessMax = BRIGHTNESS_STEPS
override val volumeMax = 0
override val hasSunblock = false
override val diagonalFovDeg = if (max) 50f else 43f
override val sbs90 = max
override val rates2d = if (max) listOf(60, 120) else listOf(60)
override val resolutions = if (max) listOf(GlassesResolution(1920, 1080), GlassesResolution(1920, 1200))
else listOf(GlassesResolution(1920, 1080))
override fun mode(hz: Int, sbs: Boolean): Int = mode(hz, sbs, null)
override fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = when {
!max -> if (sbs) MODE_SBS_1080_60 else MODE_2D_60
sbs && resolution?.height == 1080 -> MODE_SBS_1080_60
sbs -> if (hz >= 90) MODE_SBS_1200_90 else MODE_SBS_1200_60
hz >= 120 -> MODE_2D_120
else -> MODE_2D_60
}
override fun modeForStep(step: Int): Int? = null
override fun equals(other: Any?) = other is RokidModel && other.name == name && other.max == max
override fun hashCode() = name.hashCode()
companion object {
const val VENDOR_ID = 0x04d2
const val PRODUCT_ID = 0x162f
const val BRIGHTNESS_STEPS = 10
// Vendor control requests, device recipient.
const val REQUEST_SET_DISPLAY = 0x01
const val REQUEST_GET_DISPLAY = 0x81
const val REQUEST_SET_BRIGHTNESS = 0x02
const val REQUEST_GET_BRIGHTNESS = 0x82
const val INDEX_DISPLAY = 0x01
const val INDEX_BRIGHTNESS = 0x02
const val MODE_2D_60 = 0
const val MODE_SBS_1080_60 = 1
const val MODE_2D_120 = 3
const val MODE_SBS_1200_90 = 4
const val MODE_SBS_1200_60 = 5
val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60)
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (vid != VENDOR_ID || pid != PRODUCT_ID) return null
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
return RokidModel(product, product.contains("Max", ignoreCase = true))
}
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
/** Device brightness is 0..100; the shared slider uses ten steps. */
fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS)
fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
}
}
internal object RokidProtocol {
data class Sample(val timeNs: Long, val accel: Vec3, val gyro: Vec3, val brightness: Int?)
private const val COMBINED = 17
private const val SEPARATE = 4
private const val SENSOR_ACCEL = 1
private const val SENSOR_GYRO = 2
/** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
class Parser {
private var accel: Vec3? = null
private var accelTime = -1L
private var gyro: Vec3? = null
private var gyroTime = -2L
fun parse(packet: ByteArray, length: Int = packet.size): Sample? {
if (length < 64) return null
val b = ByteBuffer.wrap(packet, 0, length).order(ByteOrder.LITTLE_ENDIAN)
return when (packet[0].toInt() and 0xff) {
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
SEPARATE -> {
val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds.
when (packet[1].toInt() and 0xff) {
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time }
SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time }
else -> return null
}
if (accelTime != gyroTime) return null
Sample(time, accel!!, gyro!!, null).also { accelTime = -1L; gyroTime = -2L }
}
else -> null
}
}
private fun vec(b: ByteBuffer, offset: Int) = Vec3(b.getFloat(offset), b.getFloat(offset + 4), b.getFloat(offset + 8))
}
}
@@ -10,7 +10,6 @@ internal data class StereoCalibrationProfile(
val verified: Boolean, val bundledBase: Boolean = false,
) {
companion object {
const val BASE_ASSET = "calibration/luma-ultra-640x480.json"
const val BASE_IDENTITY = "luma-ultra-base-v1"
fun select(saved: String?, base: () -> String?, identity: String?, width: Int, height: Int): StereoCalibrationProfile? {
@@ -3,7 +3,7 @@ package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.runtime.VrPack
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
@@ -171,7 +171,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
} }
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
val bytes=TrackingPack.file(context,selected.file).readBytes()
val bytes=VrPack.file(context,selected.file).readBytes()
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
@@ -326,11 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
}
private fun loop() {
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
val loaded=VrPack.awaitNative(context,{ running }) { state ->
if(state is AddonPack.State.Downloading)
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
message=context.getString(R.string.setup_runtime_progress,context.getString(R.string.vr_module_downloading),(state.progress*100).toInt())
}
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
if(!loaded) { if(running) message=context.getString(R.string.vr_module_failed);return }
var grayL=Mat();var grayR=Mat();val rgba=Mat()
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
@@ -341,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
var rate=0f
try {
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
check(VrPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
joyConFlow=StereoJoyConFlow()
while(running) {
while(true) {
@@ -395,7 +395,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
calibration?.close()
val selected=StereoCalibrationProfile.select(
if(identity!=null) runCatching { profile.readText() }.getOrNull() else null,
{ runCatching { context.assets.open(StereoCalibrationProfile.BASE_ASSET).bufferedReader().use { it.readText() } }.getOrNull() },
{ runCatching { VrPack.file(context,VrPack.LUMA_CALIBRATION).readText() }.getOrNull() },
identity,w,h)
calibration=selected?.let(StereoCalibration::fromProfile)
calibrator?.close();calibrator=null
@@ -4,27 +4,20 @@ import kotlin.math.abs
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
internal class XrealOrientationFilter {
private val fusion = ImuFusion()
// 1S reports right/down/forward. Rotate 180 degrees about X into the
// shared right/up/back basis before either gravity alignment or fusion.
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z))
}
/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */
internal class ImuFusion {
private var previous = 0L
private var rotation = Quaternion.IDENTITY
private var initialized = false
private var bias = Vec3(0f, 0f, 0f)
fun update(timeNs: Long, accel: Vec3, gyro: Vec3): FloatArray? {
if (timeNs <= previous) return null
val dt = if (previous == 0L) 0f else (timeNs - previous) * 1e-9f
previous = timeNs
fun update(sample: XrealProtocol.Sample): FloatArray? {
if (sample.timeNs <= previous) return null
val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f
previous = sample.timeNs
// 1S reports right/down/forward. Rotate 180 degrees about X into the
// shared right/up/back basis before either gravity alignment or fusion.
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z)
val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)
val norm = accel.length()
val gravityValid = abs(norm - 9.81f) < 1.5f
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.runtime.VrPack
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
@@ -186,8 +187,8 @@ internal class HandCamera(
private fun prepare() {
if (stopped) return
runCatching {
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
val model = TrackingPack.model(context, TrackingPack.POSE)
check(VrPack.loadNative(context)) { "the VR pack is not installed" }
val model = VrPack.model(context, VrPack.POSE)
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.runtime.VrPack
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
@@ -150,9 +151,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
private const val MIN_CONFIDENCE = 0.4f
fun open(context: Context): HandModel {
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
check(VrPack.loadNative(context)) { "the VR pack is not installed" }
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
val model = TrackingPack.model(context, TrackingPack.HAND)
val model = VrPack.model(context, VrPack.HAND)
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(mode)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.runtime.VrPack
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Context
@@ -40,11 +41,11 @@ internal object HandTracking {
side = prefs.getString(SIDE, null),
permitted = permitted(context),
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
downloaded = TrackingPack.installed(context),
downloaded = VrPack.installed(context),
)
state.value = next
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
TrackingPack.ensure(context)
if (next.enabled && next.usable && !next.downloaded && VrPack.refresh(context) == AddonPack.State.Missing)
VrPack.ensure(context)
return next
}
@@ -1,56 +0,0 @@
package com.droiddeck.launcher.handtracking
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.AddonPack
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val TAG = "TrackingPack"
private val pack = AddonPack.TRACKING
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
@Volatile private var nativeLoaded = false
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
fun file(context: Context, name: String): File = pack.file(context, name)
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
@@ -146,15 +146,9 @@ public final class PadBridge {
}
}
static boolean isSystemVirtualInputName(String name) {
if (name == null) return false;
String lower = name.trim().toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("uinput-") || lower.startsWith("uinput_");
}
public static boolean isFromController(InputDevice device) {
if (device == null) return false;
if (device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
if (device.isVirtual()) return false;
int sources = device.getSources();
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
@@ -171,7 +165,7 @@ public final class PadBridge {
public static boolean isControllerKeyEvent(KeyEvent event) {
InputDevice device = event.getDevice();
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
if (device == null || device.isVirtual()) return false;
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
@@ -196,7 +190,6 @@ public final class PadBridge {
public static boolean isControllerMotionEvent(MotionEvent event) {
InputDevice device = event.getDevice();
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& !isSystemVirtualInputName(device.getName())
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
@@ -326,27 +319,16 @@ public final class PadBridge {
boolean wired = wiredXboxLayout(event);
pad.rightX = rightX(event, wired);
pad.rightY = rightY(event, wired);
int source = event.getSource();
float lt = 0f;
float rt = 0f;
if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) {
lt = leftTrigger(event, wired);
pad.leftTrigger = lt;
}
if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
rt = rightTrigger(event, wired);
pad.rightTrigger = rt;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) {
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
pad.right = hatX > 0.5f;
pad.left = hatX < -0.5f;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_Y)) {
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.down = hatY > 0.5f;
}
float lt = leftTrigger(event, wired);
float rt = rightTrigger(event, wired);
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.right = hatX > 0.5f;
pad.down = hatY > 0.5f;
pad.left = hatX < -0.5f;
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
notePlayerInput();
@@ -355,12 +337,6 @@ public final class PadBridge {
return true;
}
static boolean hasAxis(InputDevice device, int source, int... axes) {
if (device == null) return false;
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
return false;
}
public interface TouchRoute {
boolean active();
@@ -16,13 +16,10 @@ object SteamTouchBindings {
const val DEFAULT_FOREGROUND = "#232323"
const val DEFAULT_BACKGROUND = "#E4E4E4"
fun cleanLabel(raw: String): String =
raw.replace(Regex("""["\\,\p{Cntrl}]"""), " ").replace(Regex("""\s+"""), " ").trim().let { it.take(it.offsetByCodePoints(0, minOf(32, it.codePointCount(0, it.length)))) }
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
fun compose(): String =
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, ${cleanLabel(label)}, "
else "$action, ${cleanLabel(label)}, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
val hasIcon get() = icon.isNotEmpty()
}
@@ -84,11 +84,11 @@ class SteamTouchIconPicker(
updatePreview(preview)
showPanel(host, title, root, listOf(
context.getString(R.string.touch_no_icon) to {
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = "", foreground = "", background = ""))
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
},
context.getString(R.string.common_cancel) to {},
context.getString(R.string.common_use) to {
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = icon,
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
},
)) { loader.shutdown() }
@@ -22,7 +22,6 @@ object DeckyManager {
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
private const val PATH = "root/homebrew/services/PluginLoader"
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val SETTINGS = "root/homebrew/settings/loader.json"
// droiddeck-session reads the marker from the root of the client it runs.
private val CEF_REMOTE_DEBUG_MARKERS = listOf(
"root/.local/share/Steam/.cef-enable-remote-debugging",
@@ -33,34 +32,7 @@ object DeckyManager {
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>, val all: List<Release> = stable + prerelease)
const val BRANCH_STABLE = 0
const val BRANCH_PRERELEASE = 1
const val BRANCH_TESTING = 2
private fun settingsFile(context: Context) = File(LinuxRuntime.rootDir(context), SETTINGS)
fun branch(context: Context): Int {
val saved = runCatching { JSONObject(settingsFile(context).readText()).optInt("branch", -1) }.getOrDefault(-1)
if (saved >= 0) return saved
val version = File(LinuxRuntime.rootDir(context), VERSION).takeIf { it.isFile }?.readText()?.trim().orEmpty()
return if (version.startsWith("v") && "-pre" in version) BRANCH_PRERELEASE else BRANCH_STABLE
}
fun setBranch(context: Context, branch: Int) {
val file = settingsFile(context)
val settings = runCatching { JSONObject(file.readText()) }.getOrDefault(JSONObject())
settings.put("branch", branch)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, "${file.name}.tmp")
staged.writeText(settings.toString(4))
check(staged.renameTo(file)) { "cannot write ${file.path}" }
}
// Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind.
fun latest(channels: ReleaseChannels, branch: Int): Release? =
if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull()
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
fun installed(context: Context): String? {
val loader = loader(context)
@@ -102,13 +74,17 @@ object DeckyManager {
}
}
fun releases(context: Context, prerelease: Boolean): List<Release> {
val channels = releaseChannels(context)
return if (prerelease) channels.prerelease else channels.stable
}
fun releaseChannels(context: Context): ReleaseChannels {
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
return try {
val array = JSONArray(body)
val stable = mutableListOf<Release>()
val prerelease = mutableListOf<Release>()
val all = mutableListOf<Release>()
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false)) continue
@@ -129,11 +105,9 @@ object DeckyManager {
val size = binary.optLong("size", 0)
if (size <= 0) continue
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
if (!row.tag.startsWith("v")) continue
all.add(row)
(if (isPrerelease || "-pre" in row.tag) prerelease else stable).add(row)
(if (isPrerelease) prerelease else stable).add(row)
}
ReleaseChannels(stable, prerelease, all)
ReleaseChannels(stable, prerelease)
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
}
@@ -0,0 +1,149 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.handtracking.HandTracking
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import java.util.concurrent.Executors
/** The SteamVR driver and layer, hand-tracking models, OpenCV/MediaPipe native libraries and Luma calibration, downloaded on first use. */
internal object VrPack {
data class Removal(val busy: Boolean = false, val error: String? = null)
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
const val LUMA_CALIBRATION = "luma-ultra-640x480.json"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val GUEST = "usr/local/lib/droiddeck-vr"
private const val IMPLICIT_LAYER = "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json"
private const val PACKED_LAYER = "layer/VK_LAYER_DROIDDECK_vr_implicit.json"
private const val MARKER = ".pack"
private const val LEGACY = "addons/tracking"
private const val TAG = "VrPack"
private val TREES = listOf("droiddeck", "layer")
private val IDLE = setOf(SessionPhase.IDLE, SessionPhase.FAILED)
private val pack = AddonPack.VR
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
private val guest = Any()
@Volatile private var nativeLoaded = false
private val worker = Executors.newSingleThreadExecutor { Thread(it, "vr-pack").apply { isDaemon = true } }
private val mutableRemoval = MutableStateFlow(Removal())
val removal: StateFlow<Removal> = mutableRemoval.asStateFlow()
fun init(context: Context) {
val app = context.applicationContext
pack.refresh(app)
val legacy = File(app.filesDir, LEGACY)
val upgrade = pack.outdated(app) || legacy.listFiles().orEmpty().any { File(it, AddonPack.COMPLETE).isFile }
if (legacy.exists()) worker.execute { legacy.deleteRecursively() }
if (upgrade) ensure(app)
}
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { app ->
HandTracking.load(app)
if (SessionState.running || SessionState.phase !in IDLE) stageGuest(app, LinuxRuntime.rootDir(app))
}
fun await(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState)
fun file(context: Context, name: String): File = pack.file(context, name)
fun installedBytes(context: Context): Long = pack.dir(context).walkBottomUp().filter { it.isFile }.sumOf { it.length() }
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
fun stageGuest(context: Context, root: File) = synchronized(guest) {
val tree = File(root, GUEST)
val layer = File(root, IMPLICIT_LAYER)
if (!pack.installed(context)) {
FileUtils.delete(tree)
FileUtils.delete(layer)
return@synchronized
}
val dir = pack.dir(context)
if (FileUtils.readString(File(tree, MARKER)) == dir.name && layer.isFile) return@synchronized
runCatching {
FileUtils.delete(tree)
TREES.forEach { copy(File(dir, it), File(tree, it)) }
copy(File(dir, PACKED_LAYER), layer)
check(FileUtils.writeString(File(tree, MARKER), dir.name))
}.onFailure { Log.w(TAG, "could not stage the VR pack", it) }
}
fun uninstall(context: Context) {
val app = context.applicationContext
worker.execute {
mutableRemoval.value = Removal(busy = true)
val maintenance = LinuxRuntimeInstaller.beginMaintenance(app, app.getString(R.string.vr_module_removing))
val error = when {
maintenance == null -> app.getString(R.string.vr_module_busy)
maintenance.run(null) { remove(app) } -> null
else -> LinuxRuntimeInstaller.removalError() ?: app.getString(R.string.vr_module_remove_failed)
}
if (error == null) HandTracking.setEnabled(app, false)
mutableRemoval.value = Removal(error = error)
}
}
private fun remove(context: Context): String? = synchronized(guest) {
if (!pack.remove(context)) return@synchronized context.getString(R.string.vr_module_remove_failed)
val root = LinuxRuntime.rootDir(context)
FileUtils.delete(File(root, GUEST))
FileUtils.delete(File(root, IMPLICIT_LAYER))
null
}
private fun copy(source: File, target: File) {
if (source.isDirectory) {
source.listFiles().orEmpty().forEach { copy(it, File(target, it.name)) }
return
}
target.parentFile?.mkdirs()
val staged = File(target.path + ".staged")
source.inputStream().use { input -> staged.outputStream().use { FileUtils.copy(input, it) } }
staged.setExecutable(true, false)
check(staged.renameTo(target)) { "could not stage ${target.name}" }
}
}
@@ -1,179 +0,0 @@
package com.droiddeck.launcher.session
import android.os.FileObserver
import android.os.SystemClock
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.FakeInputWriter
import java.io.File
class InputSysfsComponent(
private val nodes: File,
base: File,
private val udevData: File,
private val deckPad: Boolean,
private val steamVirtual: Boolean,
) : SessionPart() {
private val devices = File(base, "devices")
private val classDir = File(base, "class")
private val shown = HashSet<Int>()
private var observer: FileObserver? = null
private var running = false
fun prepare(): Boolean = try {
FileUtils.clear(devices)
FileUtils.clear(classDir)
devices.mkdirs()
classDir.mkdirs()
udevData.mkdirs()
udevData.listFiles()?.forEach { if (isPadRecord(it.name)) it.delete() }
devices.isDirectory && classDir.isDirectory
} catch (e: Exception) {
Log.w(TAG, "pads: could not prepare the input sysfs: $e")
false
}
fun binds() = listOf(devices.path + ":" + GUEST_DEVICES, classDir.path + ":" + GUEST_CLASS)
override fun start() {
synchronized(this) { running = true }
active = this
@Suppress("DEPRECATION")
observer = object : FileObserver(nodes.path, CREATE or DELETE or MOVED_TO or MOVED_FROM or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
if (path != null && NODE_FILE.matches(path)) sync()
}
}.also { it.startWatching() }
sync()
}
override fun stop() {
observer?.stopWatching()
observer = null
if (active === this) active = null
synchronized(this) {
running = false
for (node in shown.toList()) hide(node)
}
}
@Synchronized
fun sync() {
if (!running) return
val wanted = (0 until NODE_LIMIT).filter { node ->
File(nodes, "event$node").exists() && if (node >= FIRST_VIRTUAL) File(nodes, ".uinput-event$node.uevent").isFile
else !ringWithdrawn(deckPad, padType)
}.toSet()
for (node in shown - wanted) hide(node)
for (node in wanted - shown) show(node)
}
private fun show(node: Int) {
val pad = (if (node >= FIRST_VIRTUAL) runCatching { File(nodes, ".uinput-event$node.uevent").readText() }.getOrNull()?.let(::virtualPad)
else ringPad(node, steamVirtual)) ?: return
try {
val input = File(devices, "input$node")
val event = File(input, "event$node")
event.mkdirs()
File(input, "uevent").writeText(pad.uevent())
File(input, "name").writeText(pad.name + "\n")
File(input, "phys").writeText(pad.phys + "\n")
File(input, "uniq").writeText(pad.uniq + "\n")
File(input, "properties").writeText("0\n")
File(input, "id").mkdirs()
File(input, "id/bustype").writeText("%04x\n".format(pad.bus))
File(input, "id/vendor").writeText("%04x\n".format(pad.vendor))
File(input, "id/product").writeText("%04x\n".format(pad.product))
File(input, "id/version").writeText("%04x\n".format(pad.version))
File(input, "capabilities").mkdirs()
for (name in CAPABILITIES) File(input, "capabilities/$name").writeText("0\n")
link(File(input, "subsystem"), GUEST_CLASS)
File(event, "uevent").writeText("MAJOR=$MAJOR\nMINOR=${MINOR_BASE + node}\nDEVNAME=input/event$node\n")
File(event, "dev").writeText("$MAJOR:${MINOR_BASE + node}\n")
link(File(event, "subsystem"), GUEST_CLASS)
link(File(event, "device"), "$GUEST_DEVICES/input$node")
val record = udevRecord(SystemClock.elapsedRealtimeNanos() / 1000)
File(udevData, "+input:input$node").writeText(record)
File(udevData, "c$MAJOR:${MINOR_BASE + node}").writeText(record)
link(File(classDir, "input$node"), "$GUEST_DEVICES/input$node")
link(File(classDir, "event$node"), "$GUEST_DEVICES/input$node/event$node")
shown.add(node)
Log.i(TAG, "pads: /dev/input/event$node listed for udev as \"${pad.name}\" (%04x:%04x)".format(pad.vendor, pad.product))
} catch (e: Exception) {
Log.w(TAG, "pads: could not list /dev/input/event$node for udev: $e")
hide(node)
}
}
private fun hide(node: Int) {
File(classDir, "event$node").delete()
File(classDir, "input$node").delete()
File(udevData, "c$MAJOR:${MINOR_BASE + node}").delete()
File(udevData, "+input:input$node").delete()
File(devices, "input$node").let { FileUtils.clear(it); it.delete() }
if (shown.remove(node)) Log.i(TAG, "pads: /dev/input/event$node no longer listed for udev")
}
private fun link(file: File, target: String) {
file.delete()
Os.symlink(target, file.path)
}
data class Pad(
val bus: Int,
val vendor: Int,
val product: Int,
val version: Int,
val name: String,
val phys: String,
val uniq: String,
) {
fun uevent(): String =
"PRODUCT=%x/%x/%x/%x\nNAME=\"$name\"\nPHYS=\"$phys\"\nUNIQ=\"$uniq\"\nPROP=0\n".format(bus, vendor, product, version)
}
companion object {
private const val TAG = "SessionService"
const val GUEST_DEVICES = "/sys/devices/virtual/input"
const val GUEST_CLASS = "/sys/class/input"
private const val MAJOR = 13
private const val MINOR_BASE = 64
private const val FIRST_VIRTUAL = 16
private const val NODE_LIMIT = 64
private val NODE_FILE = Regex("(\\.uinput-)?event\\d+(\\.uevent)?")
private val CAPABILITIES = listOf("ev", "key", "rel", "abs", "msc", "sw", "led", "snd", "ff")
@Volatile private var active: InputSysfsComponent? = null
@Volatile private var padType = -1
fun padTypeChanged(type: Int) {
padType = type
active?.let { Thread(it::sync, "input-sysfs").start() }
}
internal fun ringWithdrawn(deckPad: Boolean, type: Int) = deckPad && type != FakeInputWriter.PAD_TYPE_XBOX
internal fun isPadRecord(name: String) =
Regex("c$MAJOR:(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it in MINOR_BASE until MINOR_BASE + NODE_LIMIT } == true ||
Regex("\\+input:input(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it < NODE_LIMIT } == true
internal fun udevRecord(initializedUs: Long) =
"I:$initializedUs\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\nE:ID_SERIAL=noserial\nG:seat\nG:uaccess\nQ:seat\nQ:uaccess\nV:1\n"
internal fun ringPad(node: Int, steamVirtual: Boolean) = if (steamVirtual) {
Pad(3, 0x28de, 0x11ff, 0x110, "Microsoft X-Box 360 pad $node", "usb-fakeinput/input$node", "0000000000%02d".format(node))
} else {
Pad(3, 0x045e, 0x028e, 0x110, "Xbox 360 Controller ($node)", "usb-fakeinput/input$node", "0000000000%02d".format(node))
}
internal fun virtualPad(uevent: String): Pad? {
val values = uevent.lines().mapNotNull { line ->
line.indexOf('=').takeIf { it > 0 }?.let { line.substring(0, it) to line.substring(it + 1).trim('"') }
}.toMap()
val id = values["PRODUCT"]?.split('/')?.mapNotNull { it.toIntOrNull(16) }?.takeIf { it.size == 4 } ?: return null
return Pad(id[0], id[1], id[2], id[3], values["NAME"] ?: return null, values["PHYS"].orEmpty(), values["UNIQ"].orEmpty())
}
}
}
@@ -1,9 +1,6 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Environment
import android.os.Handler
import android.os.Looper
@@ -18,35 +15,21 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
class PerfHud(context: Context) {
var text by mutableStateOf("")
private set
var graph by mutableStateOf(FloatArray(0))
private set
private val context = context.applicationContext
private val frames = AtomicInteger()
private var lastTick = 0L
private var lastFps = 0f
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val intervals = FloatArray(GRAPH)
private val graphWrite = AtomicInteger()
private val lastFrameNs = AtomicLong()
@Volatile private var showFps = true
@Volatile private var showBattery = true
@Volatile private var showGraph = true
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context)
&& !File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
private val anyPart: Boolean
get() = showFps || showBattery || showGraph
var onPresentingWindowChanged: (() -> Unit)? = null
private var lastWindow: String? = null
@@ -57,17 +40,7 @@ class PerfHud(context: Context) {
handler.post { onPresentingWindowChanged?.invoke() }
}
}
override fun onGameFrame() {
frames.incrementAndGet()
if (!showGraph) return
val now = System.nanoTime()
val previous = lastFrameNs.getAndSet(now)
if (previous == 0L) return
val ms = ((now - previous) / 1_000_000f).coerceIn(0f, 50f)
val slot = graphWrite.get()
intervals[slot and (GRAPH - 1)] = ms
graphWrite.compareAndSet(slot, slot + 1)
}
override fun onGameFrame() { frames.incrementAndGet() }
}
private val tick = object : Runnable {
@@ -76,82 +49,28 @@ class PerfHud(context: Context) {
val now = SystemClock.elapsedRealtime()
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
lastTick = now
lastFps = frames.getAndSet(0) / dt
text = readout(lastFps)
if (showGraph) publishGraph() else clearGraph()
handler.postDelayed(this, TICK_MS)
text = line(frames.getAndSet(0) / dt)
handler.postDelayed(this, 1000)
}
}
fun refresh() {
readOptions()
if (!enabled || !anyPart) {
stop()
return
}
if (!running) start() else {
if (!showGraph) clearGraph() else publishGraph()
text = readout(lastFps)
}
if (enabled) start() else stop()
}
fun start() {
readOptions()
if (!enabled || !anyPart || running) return
if (!enabled || running) return
running = true
lastTick = SystemClock.elapsedRealtime()
lastFrameNs.set(0L)
frames.set(0)
graphWrite.set(0)
WaylandCompositor.setGameListener(listener)
handler.postDelayed(tick, TICK_MS)
handler.post(tick)
}
fun stop() {
running = false
handler.removeCallbacks(tick)
WaylandCompositor.clearGameListener(listener)
text = ""
graph = FloatArray(0)
}
private fun readOptions() {
showFps = SessionPrefs.hudShowFps(context)
showBattery = SessionPrefs.hudShowBattery(context)
showGraph = SessionPrefs.hudShowGraph(context)
}
private fun readout(fpsValue: Float): String {
val parts = ArrayList<String>(2)
if (showFps) parts.add(line(fpsValue))
if (showBattery) batteryCelsius(readBatteryDeci())?.let { parts.add(it) }
return parts.joinToString("\n")
}
private fun readBatteryDeci(): Int {
val sticky = runCatching {
context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}.getOrNull() ?: return 0
return sticky.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0)
}
private fun clearGraph() {
lastFrameNs.set(0L)
graphWrite.set(0)
if (graph.isNotEmpty()) graph = FloatArray(0)
}
private fun publishGraph() {
val count = graphWrite.get()
val shown = minOf(count, GRAPH)
if (shown < 2) {
graph = FloatArray(0)
return
}
val out = FloatArray(shown)
val start = count - shown
for (i in 0 until shown) out[i] = intervals[(start + i) and (GRAPH - 1)]
graph = out
}
private fun line(base: Float): String {
@@ -172,12 +91,4 @@ class PerfHud(context: Context) {
}
private fun fps(value: Float) = String.format(Locale.US, "%.0f fps", value)
companion object {
private const val TICK_MS = 1_000L
private const val GRAPH = 128
internal fun batteryCelsius(deci: Int): String? =
if (deci <= 0) null else String.format(Locale.US, "%.1f°C", deci / 10f)
}
}
@@ -118,9 +118,7 @@ object SessionArtifacts {
private fun scrubFolder(dir: File, record: Record) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
if (LogRedactor.isBinary(f)) return@forEach
// Too large to rewrite in memory: the folder must not be recorded as scrubbed.
if (!LogRedactor.isText(f)) { record.failed = true; return@forEach }
if (!LogRedactor.isText(f)) return@forEach
val done = scrubFile(dir, f)
if (done == null) record.failed = true else record.files[f] = done
}
@@ -11,6 +11,7 @@ import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LegacyNames
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.ProotFastPath
import com.droiddeck.launcher.runtime.VrPack
import java.io.File
object SessionFiles {
@@ -82,7 +83,6 @@ object SessionFiles {
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
"usr/local/bin/droiddeck-pipewire" to "usr/local/bin/droiddeck-pipewire",
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
@@ -116,31 +116,14 @@ object SessionFiles {
val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "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", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
val handModels = listOf("left", "right").flatMap { side ->
listOf("droiddeck_hand_$side.json", "hand.mtl", "hand.png", "hand_open.obj", "hand_fist.obj", "thumb_index_open.obj",
"thumb_index_pinch.obj")
.map { "droiddeck/resources/rendermodels/droiddeck_hand_$side/$it" }
}
val vr = (listOf(
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
"droiddeck/bin/linuxarm64/xr-display-mode",
"droiddeck/driver.vrdrivermanifest",
"droiddeck/resources/settings/default.vrsettings",
"layer/libVkLayer_droiddeck_vr.so",
"layer/libdroiddeck_vr_scheduling.so",
"layer/VK_LAYER_DROIDDECK_vr.json",
) + handModels).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } +
listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to
"usr/share/vulkan/implicit_layer.d/droiddeck_vr.json")
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
) + 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)
}
@@ -164,6 +147,7 @@ object SessionFiles {
}
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
}
VrPack.stageGuest(context, root)
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
LegacyNames.migrateRootfs(root)
ComponentsManager.migrateLaunchDir(context)
@@ -66,12 +66,15 @@ object SessionLogShare {
return zip
}
/** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
val w = z.bufferedWriter()
LogRedactor.scrubForShare(f, w)
w.flush()
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubForShare(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
@@ -21,7 +21,6 @@ object SessionPrefs {
const val SUSPEND_NEVER = "never"
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
private const val DECKY_ZIP_WARNING_SEEN = "deckyZipWarningSeen"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
@@ -157,43 +156,6 @@ object SessionPrefs {
prefs(context).edit().putBoolean(hudKey(context), on).apply()
}
fun hudShowFps(context: Context): Boolean = prefs(context).getBoolean("hudFps", true)
fun setHudShowFps(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hudFps", on).apply()
}
fun hudShowBattery(context: Context): Boolean = prefs(context).getBoolean("hudBattery", true)
fun setHudShowBattery(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hudBattery", on).apply()
}
fun hudShowGraph(context: Context): Boolean = prefs(context).getBoolean("hudGraph", true)
fun setHudShowGraph(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hudGraph", on).apply()
}
fun hudColor(context: Context): String =
prefs(context).getString("hudColor", HUD_COLOR_MINT)?.takeIf { it in HUD_COLORS } ?: HUD_COLOR_MINT
fun setHudColor(context: Context, color: String) {
if (color in HUD_COLORS) prefs(context).edit().putString("hudColor", color).apply()
}
fun hudOpacity(context: Context): Int =
prefs(context).getInt("hudOpacity", HUD_OPACITY_DEFAULT).takeIf { it in HUD_OPACITY_RANGE } ?: HUD_OPACITY_DEFAULT
fun setHudOpacity(context: Context, percent: Int) {
prefs(context).edit().putInt("hudOpacity", percent.coerceIn(HUD_OPACITY_RANGE)).apply()
}
const val HUD_COLOR_MINT = "mint"
val HUD_COLORS = listOf(HUD_COLOR_MINT, "white", "sky", "amber", "violet")
val HUD_OPACITY_RANGE = 30..100
const val HUD_OPACITY_DEFAULT = 70
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
private fun mangoappSession(context: Context) =
@@ -991,12 +953,6 @@ object SessionPrefs {
}.apply()
}
fun deckyZipWarningSeen(context: Context): Boolean = prefs(context).getBoolean(DECKY_ZIP_WARNING_SEEN, false)
fun setDeckyZipWarningSeen(context: Context) {
prefs(context).edit().putBoolean(DECKY_ZIP_WARNING_SEEN, true).apply()
}
fun steamDownloadsInBackground(context: Context): Boolean {
val prefs = prefs(context)
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
@@ -22,8 +22,6 @@ import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.SessionActivity
@@ -59,7 +57,6 @@ class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
private var deckBinds: List<String> = emptyList()
private var inputSysfs: InputSysfsComponent? = null
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
@@ -71,7 +68,6 @@ class SessionService : Service() {
private val steamDownloadMonitor = SteamDownloadMonitor()
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
@Volatile private var steamLibraryMounts = emptyMap<String, File>()
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
@@ -80,7 +76,6 @@ class SessionService : Service() {
private var signInHold = false
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible)
private var screenOn = true
private var sessionDisplayId = Display.DEFAULT_DISPLAY
private var manualPauseRequested = false
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
@@ -100,8 +95,8 @@ class SessionService : Service() {
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> screenOn = readScreenOn(false)
Intent.ACTION_SCREEN_ON -> screenOn = readScreenOn(true)
Intent.ACTION_SCREEN_OFF -> screenOn = false
Intent.ACTION_SCREEN_ON -> screenOn = true
else -> return
}
if (screenOn) resumeSteamSleepOnReturn()
@@ -109,49 +104,17 @@ class SessionService : Service() {
updateSuspendPolicy()
}
}
private var displayListenerRegistered = false
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (displayId != sessionDisplayId) return
sessionDisplayId = Display.DEFAULT_DISPLAY
refreshScreenOn()
}
override fun onDisplayChanged(displayId: Int) {
if (displayId == sessionDisplayId && displayId != Display.DEFAULT_DISPLAY) refreshScreenOn()
}
}
private fun readScreenOn(interactive: Boolean? = null): Boolean {
val id = sessionDisplayId
val state = if (id == Display.DEFAULT_DISPLAY) null
else (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.getDisplay(id)?.state
return sessionScreenOn(id == Display.DEFAULT_DISPLAY,
interactive ?: (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true, state)
}
private fun refreshScreenOn() {
val on = readScreenOn()
if (on == screenOn) return
screenOn = on
if (on) resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
}
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
screenOn = readScreenOn()
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
screenOn = power?.isInteractive ?: true
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
})
screenReceiverRegistered = true
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.let {
it.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
displayListenerRegistered = true
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -200,19 +163,12 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
screenOn = readScreenOn()
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_ACTIVITY_DISPLAY -> {
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
refreshScreenOn()
return START_NOT_STICKY
}
ACTION_ACTIVITY_HIDDEN -> {
activityVisible = false
suspendAttemptFailed = false
@@ -276,8 +232,7 @@ class SessionService : Service() {
activityVisible = true
xrActivityVisible = false
signInHold = false
sessionDisplayId = intent?.getIntExtra(EXTRA_DISPLAY_ID, Display.DEFAULT_DISPLAY) ?: Display.DEFAULT_DISPLAY
screenOn = readScreenOn()
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
manualPauseRequested = false
suspendDownloadNow = false
steamDownloadMonitor.reset()
@@ -395,12 +350,6 @@ class SessionService : Service() {
val fastPathAt = guest.size
val pulse = startAudio(guest, sessionDir)
if (steamHere) {
val encoder = VideoEncodeBridge(File(filesDir, "venc/venc.sock"))
encoder.attach(this)
components.add(encoder)
guest.add("BL_VENC_SOCKET=" + encoder.socket.absolutePath)
}
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
@@ -419,7 +368,6 @@ class SessionService : Service() {
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
inputSysfs = null
if (controllersOn) {
addControllerEnvironment(guest, fakeInputDir, sessionDir)
SessionState.padBridge?.syncConnectedControllers()
@@ -738,7 +686,6 @@ class SessionService : Service() {
val padType = SessionPrefs.steamController(this)
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
InputSysfsComponent.padTypeChanged(SessionPrefs.padType(padType))
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
}
@@ -752,9 +699,6 @@ class SessionService : Service() {
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
guest.add("FAKE_EVDEV_LOG=1")
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"),
File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM)
.takeIf { it.prepare() }
SessionState.fakeInputDir = fakeInputDir
}
@@ -769,11 +713,6 @@ class SessionService : Service() {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
if (controllersOn) inputSysfs?.let {
it.attach(this)
components.add(it)
binds.addAll(it.binds())
}
val battery = BatteryComponent(
File(filesDir, "session/sys/power_supply"),
File(LinuxRuntime.rootDir(this), "run/vpower"),
@@ -809,7 +748,6 @@ class SessionService : Service() {
File(home, "Storage").mkdirs()
binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage")
val library = GameStorage.effective(this)
steamLibraryMounts = emptyMap()
var storageDiagnosticLibrary: File? = null
if (library != null) {
val problem = GameStorage.prepare(this, library.path)
@@ -821,7 +759,6 @@ class SessionService : Service() {
val content = SecondaryLibrary.contentPath(configured)
binds.addAll(SecondaryLibrary.binds(filesDir, configured, content = content))
storageDiagnosticLibrary = content
steamLibraryMounts = SecondaryLibrary.CARD_GUESTS.associateWith { content }
val route = if (content === configured) "configured" else "verified-native"
Log.i(TAG, "game storage: ${content.path} ($route) -> /mnt/droiddeck-sd (\"${library.label}\"); prefixes and native tools private")
} catch (e: Exception) {
@@ -1085,7 +1022,7 @@ class SessionService : Service() {
private fun resumeSession(panelVisible: Boolean = true) {
if (panelVisible) activityVisible = true
screenOn = readScreenOn()
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
@@ -1280,13 +1217,13 @@ class SessionService : Service() {
private fun updateSuspendPolicy() {
if (!SessionState.running) return
val hidden = !hasVisibleSurface
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) {
manualPauseRequested = true
}
val controller = suspendController ?: return
val hidden = !hasVisibleSurface
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
@@ -1311,7 +1248,7 @@ class SessionService : Service() {
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "display" to sessionDisplayId, "steamSleep" to (steamSleepToken != null),
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"xrActivityVisible" to xrActivityVisible,
"nativeSleep" to (nativeSleepToken != null),
))
@@ -1575,10 +1512,6 @@ class SessionService : Service() {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
}
if (displayListenerRegistered) {
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.unregisterDisplayListener(displayListener)
displayListenerRegistered = false
}
super.onDestroy()
}
@@ -1721,7 +1654,6 @@ class SessionService : Service() {
LinuxRuntime.rootDir(this@SessionService),
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
client = SessionState.steamClientArch,
mounts = steamLibraryMounts,
)
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
@@ -1767,8 +1699,6 @@ class SessionService : Service() {
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_ACTIVITY_DISPLAY = "com.droiddeck.launcher.ACTIVITY_DISPLAY"
private const val EXTRA_DISPLAY_ID = "displayId"
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD"
@@ -1805,10 +1735,8 @@ class SessionService : Service() {
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
displayId: Int = Display.DEFAULT_DISPLAY,
) {
val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode)
.putExtra(EXTRA_DISPLAY_ID, displayId)
if (program != null) intent.putExtra(EXTRA_PROGRAM, program)
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
@@ -1829,15 +1757,10 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN))
}
fun setActivityVisible(context: Context, visible: Boolean, displayId: Int = Display.DEFAULT_DISPLAY) {
fun setActivityVisible(context: Context, visible: Boolean) {
if (!SessionState.running) return
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setActivityDisplay(context: Context, displayId: Int) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY).putExtra(EXTRA_DISPLAY_ID, displayId))
context.startService(Intent(context, SessionService::class.java).setAction(action))
}
fun setXrActivityVisible(context: Context, visible: Boolean) {
@@ -2,22 +2,14 @@ package com.droiddeck.launcher.session
import android.os.Process
import java.io.File
import java.io.IOException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
internal class SessionSuspendController(
private val sessionRoot: () -> Int,
private val helperRoots: () -> List<Int>,
private val suspendAudio: () -> Unit,
private val resumeAudio: () -> Unit,
private val procRoot: File = File("/proc"),
private val signal: (Int, Int) -> Unit = Process::sendSignal,
private val threadSignal: (Int, Int, Int) -> Unit = { pid, tid, signal -> ThreadSignals.tgkill(pid, tid, signal) },
private val sleep: (Long) -> Unit = Thread::sleep,
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
) {
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
@@ -32,95 +24,96 @@ internal class SessionSuspendController(
val parent: Int,
val started: Long,
val state: Char,
val tracer: Int,
)
private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long)
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
@Volatile private var closing = false
private var frozen = emptyList<ProcessRecord>()
fun freeze(completion: (Boolean) -> Unit) {
submit({
val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started }
var complete = false
var interrupted = false
try {
val root = records.values.firstOrNull { it.role == Role.SESSION_ROOT }
?: readProcess(sessionRoot())?.takeIf { it.pid > 1 }?.let {
ProcessRecord(it.pid, it.started, Role.SESSION_ROOT)
} ?: throw IOException("Session root is unavailable")
records[root.pid to root.started] = root
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root.pid }.distinct()
for (helper in helpers) {
if (records.values.any { it.pid == helper && it.role == Role.HELPER_ROOT }) continue
readProcess(helper)?.let {
records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT)
}
}
val deadline = nowMillis() + FREEZE_TIMEOUT_MS
var previous: Map<TaskIdentity, Long>? = null
for (pass in 0 until FREEZE_MAX_PASSES) {
if (closing || nowMillis() >= deadline) break
try {
if (readProcess(root.pid)?.started != root.started) break
addSnapshot(records, root.pid, helpers)
if (readProcess(root.pid)?.started != root.started) break
stop(records.values.toList())
sleep(if (pass == 0) 70L else 35L)
if (closing || nowMillis() >= deadline) break
val current = stoppedSnapshot(records.values.toList(), root)
if (current != null && current == previous) {
complete = true
break
}
previous = current
} catch (_: IOException) {
previous = null
sleep(35L)
}
}
} catch (_: InterruptedException) {
interrupted = true
Thread.interrupted()
} catch (_: Exception) {
} finally {
frozen = records.values.toList()
if (!complete) {
if (restoreSafely(frozen)) frozen = emptyList()
runAudio(resumeAudio)
}
if (interrupted) Thread.currentThread().interrupt()
submit {
if (closing) {
completion(false)
return@submit
}
completion(complete)
}, { completion(false) })
if (frozen.isNotEmpty()) {
val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone)
if (!fullyFrozen && restore(frozen)) frozen = emptyList()
completion(fullyFrozen)
return@submit
}
val records = LinkedHashMap<Pair<Int, Long>, ProcessRecord>()
val root = sessionRoot()
if (root <= 1) {
completion(false)
return@submit
}
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root }.distinct()
var stablePasses = 0
var complete = false
repeat(10) { pass ->
if (closing) {
restore(records.values.toList())
runAudio(resumeAudio)
completion(false)
return@submit
}
val includeRoots = pass >= 2
addSnapshot(records, root, helpers, includeRoots)
stop(records.values.toList(), includeRoots)
Thread.sleep(if (pass == 0) 70L else 35L)
val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT }
if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) {
stablePasses++
if (stablePasses >= 2) complete = true
} else {
stablePasses = 0
}
}
if (complete) {
frozen = records.values.toList()
completion(true)
} else {
val restored = restore(records.values.toList())
runAudio(resumeAudio)
if (!restored) frozen = records.values.toList()
completion(false)
}
}
}
fun resume(completion: (Boolean) -> Unit) {
submit({
submit {
val records = frozen
runAudio(resumeAudio)
val resumed = restoreSafely(records)
val resumed = restore(records)
if (resumed) frozen = emptyList()
completion(resumed)
}, { completion(false) })
}
}
fun closeAndResume(completion: () -> Unit) {
closing = true
submit({
if (restoreSafely(frozen)) frozen = emptyList()
submit {
restore(frozen)
frozen = emptyList()
completion()
executor.shutdown()
}, completion)
}
}
private fun submit(task: () -> Unit, rejected: () -> Unit) {
private fun submit(task: () -> Unit) {
try {
executor.execute(task)
} catch (_: RejectedExecutionException) {
rejected()
executor.execute {
try {
task()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
} catch (_: RuntimeException) {
task()
}
}
@@ -135,25 +128,23 @@ internal class SessionSuspendController(
records: MutableMap<Pair<Int, Long>, ProcessRecord>,
root: Int,
helpers: List<Int>,
includeRoots: Boolean,
) {
val processes = readProcesses()
val byPid = processes.associateBy { it.pid }
val rootRecord = records.values.first { it.role == Role.SESSION_ROOT }
if (byPid[root]?.started != rootRecord.started) throw IOException("Session root exited or changed")
val liveHelpers = helpers.filter { pid ->
records.values.any { it.pid == pid && it.role == Role.HELPER_ROOT && it.started == byPid[pid]?.started }
for (process in descendants(root, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD)
}
for (process in descendants(root, processes, byPid) + processes.filter { it.tracer == root }) {
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.SESSION_CHILD))
}
for (helper in liveHelpers) {
for (helper in helpers) {
for (process in descendants(helper, processes, byPid)) {
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.HELPER_CHILD))
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD)
}
}
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in liveHelpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
if (includeRoots) {
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in helpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
}
}
}
@@ -177,84 +168,36 @@ internal class SessionSuspendController(
}
private fun readProcesses(): List<ProcessInfo> =
(procRoot.listFiles() ?: throw IOException("Cannot enumerate processes")).mapNotNull { entry ->
File("/proc").listFiles().orEmpty().mapNotNull { entry ->
val pid = entry.name.toIntOrNull() ?: return@mapNotNull null
try { readProcess(pid) } catch (_: IOException) { null }
readProcess(pid)
}
private fun readProcess(pid: Int): ProcessInfo? {
val base = File(procRoot, pid.toString())
try {
val fields = readStat(File(base, "stat"))
val tracer = File(base, "status").useLines { lines ->
lines.firstOrNull { it.startsWith("TracerPid:") }?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0
}
return ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first(), tracer)
} catch (error: IOException) {
if (procRoot.list()?.contains(pid.toString()) == false) return null
throw error
val stat = try {
File("/proc/$pid/stat").readText()
} catch (_: Exception) {
return null
}
}
private fun readStat(file: File): List<String> {
val stat = file.readText()
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) throw IOException("Invalid process stat: $file")
if (close < 0 || close + 2 >= stat.length) return null
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
if (fields.size < 20) throw IOException("Incomplete process stat: $file")
return fields
}
private fun stoppedSnapshot(records: List<ProcessRecord>, root: ProcessRecord): Map<TaskIdentity, Long>? {
// PRoot can leave the leader stopped while native worker threads keep running.
val tasks = LinkedHashMap<TaskIdentity, Long>()
var stopped = true
for (record in records) {
val current = readProcess(record.pid)
if (current == null || current.started != record.started) {
if (record == root) throw IOException("Session root exited or changed")
continue
}
val entries = File(procRoot, "${record.pid}/task").listFiles()
?: throw IOException("Cannot enumerate threads: ${record.pid}")
if (entries.none { it.name.toIntOrNull() != null }) throw IOException("Empty thread inventory: ${record.pid}")
var liveRoot = false
for (entry in entries) {
val tid = entry.name.toIntOrNull() ?: continue
val fields = readStat(File(entry, "stat"))
val state = fields[0].first()
if (state != 'Z' && state != 'X') liveRoot = true
if (state != 'T' && state != 't' && state != 'Z' && state != 'X') stopped = false
tasks[TaskIdentity(record, tid, fields[19].toLong())] = fields[11].toLong() + fields[12].toLong()
}
if (record == root && !liveRoot) throw IOException("Session root has no live threads")
if (readProcess(record.pid)?.started != record.started) throw IOException("Process changed during inspection")
}
return tasks.takeIf { stopped && it.isNotEmpty() }
}
// PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
// thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
// signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
private fun stop(records: List<ProcessRecord>) {
for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }) {
stopThreads(record)
if (fields.size < 20) return null
return try {
ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first())
} catch (_: NumberFormatException) {
null
}
}
private fun stopThreads(record: ProcessRecord) {
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
if (current == null || current.started != record.started) return
for (entry in File(procRoot, "${record.pid}/task").listFiles().orEmpty()) {
val tid = entry.name.toIntOrNull() ?: continue
val state = try { readStat(File(entry, "stat"))[0].first() } catch (_: IOException) { continue }
if (state == 'T' || state == 't' || state == 'Z' || state == 'X') continue
try {
threadSignal(record.pid, tid, SIGSTOP)
} catch (_: Throwable) {
}
private fun stop(records: List<ProcessRecord>, includeRoots: Boolean) {
val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }
val session = records.filter { it.role == Role.SESSION_ROOT }
val helpers = records.filter { it.role == Role.HELPER_ROOT }
for (record in descendants) send(record, SIGSTOP)
if (includeRoots) {
for (record in session) send(record, SIGSTOP)
for (record in helpers) send(record, SIGSTOP)
}
}
@@ -270,7 +213,7 @@ internal class SessionSuspendController(
for (record in ordered) send(record, SIGCONT)
val waiting = ordered.toMutableList()
repeat(RESTORE_CHECKS) { check ->
sleep(25L)
Thread.sleep(25L)
waiting.removeAll { isRunningOrGone(it) }
if (waiting.isEmpty()) return true
if (check % 8 == 7) for (record in waiting) send(record, SIGCONT)
@@ -278,40 +221,30 @@ internal class SessionSuspendController(
return false
}
private fun restoreSafely(records: List<ProcessRecord>): Boolean = try {
restore(records)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
false
} catch (_: Exception) {
false
}
private fun send(record: ProcessRecord, signal: Int) {
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
if (current == null || current.started != record.started) return
val current = readProcess(record.pid) ?: return
if (current.started != record.started) return
try {
this.signal(record.pid, signal)
Process.sendSignal(record.pid, signal)
} catch (_: Throwable) {
}
}
private fun isStoppedOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X'
}
private fun isRunningOrGone(record: ProcessRecord): Boolean {
val current = try { readProcess(record.pid) } catch (_: Exception) { return false }
if (current == null || current.started != record.started) return true
if (current.state == 'T' || current.state == 't') return false
// A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop.
return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry ->
entry.name.toIntOrNull() != null &&
try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false }
}
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state != 'T' && current.state != 't'
}
private companion object {
const val SIGSTOP = 19
const val SIGCONT = 18
const val RESTORE_CHECKS = 80
const val FREEZE_TIMEOUT_MS = 8_000L
const val FREEZE_MAX_PASSES = 256
}
}
@@ -1,10 +1,4 @@
package com.droiddeck.launcher.session
import android.view.Display
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
xrVisible || (panelVisible && screenOn)
internal fun sessionScreenOn(onDefaultDisplay: Boolean, interactive: Boolean, displayState: Int?): Boolean =
if (onDefaultDisplay || displayState == null || displayState == Display.STATE_UNKNOWN) interactive
else displayState == Display.STATE_ON || displayState == Display.STATE_VR || displayState == Display.STATE_ON_SUSPEND
@@ -13,7 +13,7 @@ object SettingsReset {
internal val SESSION_KEYS = setOf(
"orientation", "launcherFullscreen", "animations", "theme", "runSteamAtStartup", "addedGamesArt",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "backActionsInverted", "touch", "osc", "shape", "steamController",
"hud", "hudDeck", "backActionsInverted", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "stretch16x9", "picturePosition",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
@@ -1,28 +0,0 @@
package com.droiddeck.launcher.session
class SteamClientSetupFailure(val curlError: String?) {
val download get() = curlError != null
companion object {
const val MARKER = "== steam: the Steam client could not be set up"
private const val STEP = "checking the Steam client"
private const val X64_STEP = "checking the x86-64 Steam client"
private val CURL_ERROR = Regex("""curl: \(\d+\).*""")
fun find(lines: Sequence<String>): SteamClientSetupFailure? {
var curlError: String? = null
var found: SteamClientSetupFailure? = null
lines.forEach { line ->
when {
line.contains(STEP) || line.contains(X64_STEP) -> {
curlError = null
found = null
}
line.contains(MARKER) -> found = SteamClientSetupFailure(curlError)
else -> CURL_ERROR.find(line)?.let { curlError = it.value.trim() }
}
}
return found
}
}
}
@@ -18,9 +18,8 @@ internal class SteamDownloadMonitor {
private var lastReceivedBytes = -1L
private var lastReceivedAt = 0L
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null,
mounts: Map<String, File> = emptyMap()): Boolean {
val current = manifests(runtimeRoot, mounts).associate { it.path to read(it) }
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null): Boolean {
val current = manifests(runtimeRoot).associate { it.path to read(it) }
val progressed = current.any { (path, state) ->
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
}
@@ -99,7 +98,7 @@ internal class SteamDownloadMonitor {
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
}
private fun manifests(runtimeRoot: File, mounts: Map<String, File>): List<File> {
private fun manifests(runtimeRoot: File): List<File> {
val steamRoots = linkedSetOf<File>()
for (home in STEAM_HOMES) {
val primary = File(runtimeRoot, home)
@@ -107,7 +106,7 @@ internal class SteamDownloadMonitor {
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
runCatching {
PATH.findAll(libraryFolders.readText()).forEach { match ->
steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts)
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
}
}
}
@@ -118,15 +117,6 @@ internal class SteamDownloadMonitor {
}
}
private fun hostPath(runtimeRoot: File, guest: String, mounts: Map<String, File>): File {
val path = guest.trimEnd('/')
for ((mount, host) in mounts) {
if (path == mount) return host
if (path.startsWith("$mount/")) return File(host, path.removePrefix("$mount/"))
}
return File(runtimeRoot, path.removePrefix("/"))
}
private fun read(file: File): ManifestState {
val text = runCatching { file.readText() }.getOrDefault("")
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
@@ -1,9 +0,0 @@
package com.droiddeck.launcher.session
internal object ThreadSignals {
init {
System.loadLibrary("sdnative")
}
@JvmStatic external fun tgkill(tgid: Int, tid: Int, signal: Int): Int
}
@@ -1,28 +0,0 @@
package com.droiddeck.launcher.session
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import java.io.File
/** Steam's Remote Play V4L2 encoder in the guest talks to this MediaCodec encoder (venc.c, venc_server.cpp). */
class VideoEncodeBridge(val socket: File) : SessionPart() {
override fun start() {
socket.parentFile?.mkdirs()
val ok = runCatching { System.loadLibrary("ddvenc"); nativeStart(socket.absolutePath) }
.onFailure { Log.w(TAG, "hardware video encoding unavailable", it) }
.getOrDefault(false)
Log.i(TAG, if (ok) "Remote Play hardware encoding on at $socket" else "Remote Play hardware encoding off")
}
override fun stop() {
runCatching { nativeStop() }
socket.delete()
}
private external fun nativeStart(path: String): Boolean
private external fun nativeStop()
private companion object {
const val TAG = "VideoEncodeBridge"
}
}
@@ -23,14 +23,6 @@ internal object LoginFlows {
override val userAgent: String get() = GogAuth.GALAXY_UA
override fun isRedirect(url: String): Boolean = url.startsWith(GogAuth.REDIRECT_PREFIX)
override fun forgetSignIn(done: () -> Unit) {
runCatching { WebCookies.expire(GogAuth.SIGN_IN_URLS, GogAuth.COOKIE_DOMAIN, listOf("/")) { done() } }
.onFailure {
Log.w(TAG, "gog: could not clear the previous web sign-in: ${it.javaClass.simpleName}")
done()
}
}
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? {
val fragment = Uri.parse(url).fragment ?: return activity.getString(com.droiddeck.launcher.R.string.stores_login_error_verification)
val frag = Uri.parse("x://x?$fragment")
@@ -5,7 +5,6 @@ import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.Message
import android.util.Log
@@ -19,7 +18,6 @@ import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
@@ -40,12 +38,8 @@ class StoreLoginActivity : ComponentActivity() {
const val EXTRA_STORE = "store"
const val EXTRA_CLIENT_URL = "client_url"
private const val KEY_STATE = "oauth_state"
private const val KEY_SIGN_IN_CLEARED = "sign_in_cleared"
@Volatile internal var floodColor: Int? = null
internal fun chromeUserAgent(webView: String?): String =
webView?.replace(Regex(" Build/[^;)]*"), "")?.replace("; wv)", ")")?.replace(" Version/4.0", "")
?.takeIf { "Chrome/" in it }
?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
internal const val CHROME_UA = "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
fun generateState(): String {
val bytes = ByteArray(24)
@@ -60,7 +54,6 @@ class StoreLoginActivity : ComponentActivity() {
fun isRedirect(url: String): Boolean
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
val readsPage: Boolean get() = false
fun forgetSignIn(done: () -> Unit) = done()
}
private var store: Store? = null
@@ -81,8 +74,6 @@ class StoreLoginActivity : ComponentActivity() {
private var oauthState: String? = null
private val captured = AtomicBoolean(false)
private var flooded = false
private val chromeUa by lazy { chromeUserAgent(runCatching { WebSettings.getDefaultUserAgent(this) }.getOrNull()) }
private var signInCleared = false
private fun dp(value: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
@@ -118,18 +109,12 @@ class StoreLoginActivity : ComponentActivity() {
}
}
})
val start = flow!!.startUrl
signInCleared = savedInstanceState?.getBoolean(KEY_SIGN_IN_CLEARED) == true
if (signInCleared) webView.loadUrl(start)
else flow!!.forgetSignIn {
signInCleared = true
if (!isDestroyed) webView.loadUrl(start)
}
webView.loadUrl(flow!!.startUrl)
}
private fun newFlow(state: String): Flow? {
val url = clientUrl
if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url, chromeUa)
if (url != null) return com.droiddeck.launcher.stores.epic.EpicClient.loginFlow(url)
return store?.let { LoginFlows.forStore(it, state) }
}
@@ -240,7 +225,7 @@ class StoreLoginActivity : ComponentActivity() {
override fun onCreateWindow(view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean {
if (resultMsg == null) return false
dismissPopup()
val child = newWebView(chromeUa, isPopup = true)
val child = newWebView(CHROME_UA, isPopup = true)
popupWebView = child
popupHost.addView(child, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
popupHost.visibility = View.VISIBLE
@@ -270,8 +255,6 @@ class StoreLoginActivity : ComponentActivity() {
}
private inner class Client(private val isPopup: Boolean) : WebViewClient() {
private var mainFrameUrl: String? = null
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
Log.d(TAG, "page[popup=$isPopup]: ${StoreLog.redactUrl(url)}")
if (url != null && view != null && flow?.isRedirect(url) == true && flow?.readsPage != true) complete(view, url)
@@ -294,14 +277,10 @@ class StoreLoginActivity : ComponentActivity() {
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.cancel()
val url = error?.url
if (url == null || url == view?.url || url == mainFrameUrl) showError(getString(R.string.stores_login_error_ssl))
showError(getString(R.string.stores_login_error_ssl))
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (request.isForMainFrame) mainFrameUrl = request.url.toString()
return route(view, request.url)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = route(view, request.url)
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = route(view, Uri.parse(url))
@@ -312,8 +291,8 @@ class StoreLoginActivity : ComponentActivity() {
if (f.isRedirect(url) && !f.readsPage) { complete(view, url); return true }
if (!isPopup && store == Store.GOG) {
if (GogAuth.isGogHost(uri.host) && view.settings.userAgentString != f.userAgent) { view.settings.userAgentString = f.userAgent; return false }
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != chromeUa) {
view.settings.userAgentString = chromeUa
if (GogAuth.isIdentityProviderHost(uri.host) && view.settings.userAgentString != CHROME_UA) {
view.settings.userAgentString = CHROME_UA
CookieManager.getInstance().setAcceptThirdPartyCookies(view, true)
view.loadUrl(url)
return true
@@ -351,7 +330,6 @@ class StoreLoginActivity : ComponentActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
oauthState?.let { outState.putString(KEY_STATE, it) }
outState.putBoolean(KEY_SIGN_IN_CLEARED, signInCleared)
}
override fun onStart() {
@@ -1,34 +0,0 @@
package com.droiddeck.launcher.stores
import android.net.Uri
import android.webkit.CookieManager
internal object WebCookies {
fun expired(header: String?, domain: String, paths: List<String>, host: String? = null): List<String> {
val domains = listOfNotNull(domain, host?.takeIf { it.endsWith(domain) })
return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct()
.flatMap { name ->
paths.flatMap { path ->
listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" }
}
}
}
fun expire(urls: List<String>, domain: String, paths: List<String>, done: (Int) -> Unit) {
val cookies = CookieManager.getInstance()
val lines = urls.flatMap { url -> expired(cookies.getCookie(url), domain, paths, Uri.parse(url).host).map { url to it } }
if (lines.isEmpty()) {
done(0)
return
}
var left = lines.size
for ((url, line) in lines) {
cookies.setCookie(url, line) {
if (--left == 0) {
cookies.flush()
done(lines.size)
}
}
}
}
}
@@ -21,7 +21,6 @@ import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.StoreLog
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.WebCookies
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.SteamLiveShortcuts
@@ -109,15 +108,28 @@ object EpicClient {
fun forgetWebSignIn() {
android.os.Handler(android.os.Looper.getMainLooper()).post {
runCatching {
WebCookies.expire(COOKIE_URLS, COOKIE_DOMAIN, COOKIE_PATHS) { cleared ->
Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)")
val cookies = android.webkit.CookieManager.getInstance()
var cleared = 0
for (url in COOKIE_URLS) {
val lines = expiredCookies(cookies.getCookie(url), Uri.parse(url).host)
for (line in lines) cookies.setCookie(url, line)
cleared += lines.size
}
cookies.flush()
Log.i(TAG, "cleared the Epic web sign-in ($cleared cookie lines)")
}.onFailure { Log.w(TAG, "could not clear the Epic web sign-in: ${it.javaClass.simpleName}") }
}
}
internal fun expiredCookies(header: String?, host: String? = null): List<String> =
WebCookies.expired(header, COOKIE_DOMAIN, COOKIE_PATHS, host)
internal fun expiredCookies(header: String?, host: String? = null): List<String> {
val domains = listOfNotNull(COOKIE_DOMAIN, host?.takeIf { it.endsWith(COOKIE_DOMAIN) })
return header.orEmpty().split(';').map { it.substringBefore('=').trim() }.filter { it.isNotEmpty() }.distinct()
.flatMap { name ->
COOKIE_PATHS.flatMap { path ->
listOf("$name=; Max-Age=0; Path=$path; Secure") + domains.map { "$name=; Max-Age=0; Path=$path; Secure; Domain=$it" }
}
}
}
fun read(context: Context): Status {
val app = context.applicationContext
@@ -392,10 +404,10 @@ object EpicClient {
}
}
internal fun loginFlow(url: String, browserAgent: String): StoreLoginActivity.Flow? {
internal fun loginFlow(url: String): StoreLoginActivity.Flow? {
val redirect = redirectOf(url) ?: return null
val agent = runCatching { EpicNative.call("auth.config")?.optString("webViewUserAgent") }.getOrNull()?.ifEmpty { null }
return ClientLogin(url, redirect, agent ?: browserAgent)
return ClientLogin(url, redirect, agent ?: StoreLoginActivity.CHROME_UA)
}
private class ClientLogin(
@@ -20,8 +20,6 @@ object GogAuth {
"&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=token&layout=client2"
const val REDIRECT_PREFIX = "https://embed.gog.com/on_login_success"
const val GALAXY_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) GOG Galaxy/2.0"
const val COOKIE_DOMAIN = ".gog.com"
val SIGN_IN_URLS = listOf("https://login.gog.com/", "https://auth.gog.com/")
fun authUrl(state: String): String = "$AUTH_URL&state=${Uri.encode(state)}"
@@ -40,12 +40,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import android.content.Context
@@ -508,7 +502,7 @@ private fun ArtChooserDialog(
sgdb = found?.sgdb ?: SteamGridDb.Status.SERVER
}
val first = remember { FocusRequester() }
LaunchedEffect(options != null) { focusWithinFrames({ false }) { first } }
LaunchedEffect(Unit) { focusWithinFrames({ false }) { first } }
val apply: (AddedGameArt.Option) -> Unit = { option ->
scope.launch {
val saved = withContext(Dispatchers.IO) { target.set(app, slot, option) }
@@ -630,23 +624,14 @@ private fun SgdbSearchDialog(
}
}
LaunchedEffect(Unit) { search() }
val firstItem = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val leaveField = { focusManager.clearFocus(); keyboardController?.hide() }
val keyboard = LocalInputModeManager.current.inputMode == InputMode.Keyboard
LaunchedEffect(picked?.id, matches?.size, images.size, busy) {
val ready = if (picked != null) images.isNotEmpty() else !matches.isNullOrEmpty()
if (keyboard && !busy && ready) focusSoon { firstItem }
}
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp, modifier = Modifier.pointerInput(Unit) { detectTapGestures { leaveField() } }) {
AppDialog(shown, back, "sgdbSearch", wide = false, maxWidth = 460.dp) {
DialogTitleRow(stringResource(R.string.art_sgdb_title, slotName), onClose = { onCloseAll() }, leading = {
SmallIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back), colors.onBackground, onClick = back)
})
androidx.compose.material3.OutlinedTextField(
query, { query = it }, singleLine = true, modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.art_sgdb_search_label)) },
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { leaveField(); search() }),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { search() }),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
)
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.6f).dp
@@ -658,24 +643,24 @@ private fun SgdbSearchDialog(
val chosenGame = picked
val list = matches.orEmpty()
if (chosenGame == null) {
list.forEachIndexed { i, match -> key(match.id) { PickerRow(null, match.name, null, focus = if (i == 0) firstItem else null) { leaveField(); showImages(match) } } }
for (match in list) key(match.id) { PickerRow(null, match.name, null) { showImages(match) } }
if (matches != null && list.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
} else {
Text(chosenGame.name, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
images.forEachIndexed { i, option -> key(option.full) {
for (option in images) key(option.full) {
ArtOption(
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = if (i == 0) firstItem else null,
slot, option, selected = current?.chosen == true && option.full == chosenRef, focus = null,
description = "${artSourceLabel(AddedGameArt.SGDB)} $slotName",
) { onPick(option) }
} }
}
}
if (images.isEmpty() && !busy && status == SteamGridDb.Status.OK) {
Text(stringResource(R.string.art_sgdb_none), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 4.dp))
}
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { leaveField(); picked = null }
if (list.size > 1) PickerRow(null, stringResource(R.string.art_sgdb_other_games), null) { picked = null }
}
if (busy) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp))
}
@@ -69,7 +69,6 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf
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.LocalConfiguration
import androidx.compose.ui.platform.LocalInputModeManager
@@ -559,37 +558,13 @@ private fun repoInfo(post: SteamDeckRepo.Post): String =
listOf(post.author, "♥ ${post.likes}", "⇩ ${post.downloads}", if (post.seconds > 0) stringResource(R.string.boot_movie_seconds, post.seconds) else "")
.filter { it.isNotEmpty() }.joinToString(" · ")
private const val AFTER_OPEN = 3
private const val AFTER_INSTALL = 1
private const val AFTER_USE = 2
/** Install and Use are different buttons in turn, and the button is disabled while installing; it takes focus back for the controller. */
@Composable
private fun RepoAction(
installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit,
takeFocus: Boolean = false,
) {
val requester = remember { FocusRequester() }
val focused = remember { booleanArrayOf(false) }
var pending by remember { mutableIntStateOf(if (takeFocus) AFTER_OPEN else 0) }
val inputMode = LocalInputModeManager.current
LaunchedEffect(installed, inUse, busy, percent != null, pending) {
if (pending == 0) return@LaunchedEffect
if (inputMode.inputMode != InputMode.Keyboard) { pending = 0; return@LaunchedEffect }
focusWithinFrames({ focused[0] }) { requester }
when (pending) {
AFTER_OPEN -> pending = 0
AFTER_INSTALL -> if (installed && focused[0]) pending = 0
AFTER_USE -> if (inUse && focused[0]) pending = 0
else -> Unit
}
}
val m = modifier.focusRequester(requester).onFocusChanged { focused[0] = it.hasFocus || it.isFocused }
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = m, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = m, onClick = { pending = AFTER_USE; onUse() })
private fun RepoAction(installed: Boolean, inUse: Boolean, percent: Int?, busy: Boolean, modifier: Modifier, onInstall: () -> Unit, onUse: () -> Unit) {
if (inUse) SecondaryButton(stringResource(R.string.boot_movie_in_use), compact = true, modifier = modifier, onClick = {})
else if (installed) SecondaryButton(stringResource(R.string.boot_movie_repo_use), compact = true, modifier = modifier, onClick = onUse)
else SecondaryButton(
if (percent != null) stringResource(R.string.boot_movie_converting, percent) else stringResource(R.string.boot_movie_repo_install),
enabled = !busy, compact = true, modifier = m, onClick = { pending = AFTER_INSTALL; onInstall() },
enabled = !busy, compact = true, modifier = modifier, onClick = onInstall,
)
}
@@ -601,6 +576,9 @@ private fun PreviewDialog(
) {
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val action = remember { FocusRequester() }
val inputMode = LocalInputModeManager.current
LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { action } }
val maxVideoHeight = (LocalConfiguration.current.screenHeightDp - 200).coerceAtLeast(120).dp
AppDialog(shown, close, "boot-movie-preview", wide = false, maxWidth = 620.dp) {
DialogTitleRow(post.title, close)
@@ -608,7 +586,7 @@ private fun PreviewDialog(
MoviePreview(post.video, Modifier.heightIn(max = maxVideoHeight).aspectRatio(16f / 9f))
}
Text(repoInfo(post), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth(), onInstall, onUse, takeFocus = true)
RepoAction(installed, inUse, percent, busy, Modifier.fillMaxWidth().focusRequester(action), onInstall, onUse)
}
}
@@ -151,7 +151,6 @@ fun ComponentsPage(
onImport: () -> Unit,
onBack: () -> Unit,
games: List<Library.SteamGame> = emptyList(),
initialGameKey: String? = null,
generalFexPreset: String = "",
onGeneralFexPreset: (String) -> Unit = {},
requestInitialFocus: Boolean = true,
@@ -170,7 +169,7 @@ fun ComponentsPage(
val layer = if (comp in LAYERS) comp else GPU_TAB
BackHandler(onBack = onBack)
var scopeMenu by remember { mutableStateOf(false) }
var selectedGameKey by remember(initialGameKey) { mutableStateOf(initialGameKey) }
var selectedGameKey by remember { mutableStateOf<String?>(null) }
var fexMenu by remember { mutableStateOf(false) }
var gameProtonMenu by remember { mutableStateOf(false) }
var gameProtonId by remember { mutableStateOf<String?>(null) }
@@ -13,12 +13,11 @@ import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun CursorOverlay(
positionOf: () -> Offset, visible: Boolean, scale: Float,
position: Offset, visible: Boolean, scale: Float,
image: ImageBitmap? = null, hotX: Int = 0, hotY: Int = 0, imageScale: Float = 1f,
) {
if (!visible) return
Canvas(modifier = Modifier.fillMaxSize()) {
val position = positionOf()
if (image != null) {
val w = (image.width * imageScale).toInt().coerceAtLeast(1)
val h = (image.height * imageScale).toInt().coerceAtLeast(1)
@@ -359,21 +359,20 @@ private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) {
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } }
SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {}
SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) {
UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
UninstallButton(stringResource(R.string.desktop_uninstall_title), stringResource(R.string.desktop_remove_text),
enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
}
}
}
@Composable
private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) {
internal fun UninstallButton(title: String, note: String, enabled: Boolean, modifier: Modifier = Modifier, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
val label = stringResource(R.string.common_uninstall)
val title = stringResource(R.string.desktop_uninstall_title)
val note = stringResource(R.string.desktop_remove_text)
val keep = stringResource(R.string.flathub_keep)
var at by remember { mutableStateOf<Rect?>(null) }
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
SecondaryButton(label, enabled = enabled, compact = true) {
SecondaryButton(label, enabled = enabled, compact = true, modifier = modifier) {
val from = at ?: return@SecondaryButton
Steps.ask(StepAsk(
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Row
@@ -31,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.shape.CircleShape
@@ -112,7 +110,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) }
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp), corner = { GameRemoveButton(current, s, a) }) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
GameActions(current, s, a, onEdit, launch)
}
@@ -134,7 +132,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState())
.padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp),
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp), corner = { GameRemoveButton(current, s, a) }) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a, onEdit, launch)
}
@@ -441,47 +439,6 @@ private fun GamesDialogs(a: FrontEndActions, games: List<Library.SteamGame>) {
}
}
@Composable
private fun GameRemoveButton(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
val kind = com.droiddeck.launcher.frontend.GameRemoval.kind(g)
if (s.shortcutPicker || kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER) return
if (g.gameFiles?.isDirectory != true) return
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
var asking by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
val shortcut = kind == com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT
val label = stringResource(if (shortcut) R.string.edit_remove else R.string.common_uninstall)
val title = if (shortcut) stringResource(R.string.games_remove_title, g.name) else stringResource(R.string.stores_uninstall_title, g.name)
val body = when {
shortcut -> stringResource(R.string.games_remove_body)
g.library == Library.ADDED -> stringResource(R.string.stores_uninstall_body)
else -> stringResource(R.string.games_uninstall_body)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("game:remove").downToFirstTile().size(44.dp).clip(Shape12)
.background(if (hot) colors.error.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.34f))
.glideBorder(hot, Shape12, colors.error, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = androidx.compose.ui.semantics.Role.Button) { asking = true }
.controllerConfirm { asking = true }
.semantics { contentDescription = label },
) {
androidx.compose.material3.Icon(
Icons.Outlined.Delete, contentDescription = null,
tint = if (hot) colors.error else Color.White, modifier = Modifier.size(18.dp),
)
}
if (asking) ConfirmDialog(
title = title,
text = body,
confirm = label,
onConfirm = { a.onGameRemoved(g) },
onDismiss = { asking = false },
)
}
@Composable
private fun EditGameButton(onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -613,7 +570,7 @@ private fun libraryLabel(g: Library.SteamGame): String = when {
}
@Composable
private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composable BoxScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
val image = g.hero ?: g.art.takeIf { g.library != Library.EPIC_LAUNCHER }
@@ -636,6 +593,5 @@ private fun GameHero(g: Library.SteamGame, modifier: Modifier, corner: @Composab
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
content = content,
)
Box(Modifier.align(Alignment.TopEnd).padding(12.dp), content = corner)
}
}
@@ -165,7 +165,6 @@ class FrontEndActions(
val onAddedGamesChanged: (folders: List<String>) -> Unit = {},
val onSteamGameArtChanged: (appId: Int) -> Unit = {},
val onAddedGameRemoved: (folder: String, appId: Long?, name: String) -> Unit = { _, _, _ -> },
val onGameRemoved: (Library.SteamGame) -> Unit = {},
val onAddedGamesArt: (Boolean) -> Unit = {},
val onSgdbKey: (String) -> Unit = {},
val onStopGameFileSync: () -> Unit = {},
@@ -189,7 +188,6 @@ class FrontEndActions(
val onImportLossless: () -> Unit,
val onProtons: () -> Unit,
val onComponents: (focusContent: Boolean) -> Unit,
val onGameComponents: (Library.SteamGame) -> Unit = { onComponents(true) },
val onComponentsTab: (String) -> Unit = {},
val onSaveImport: (Library.SteamGame) -> Unit = {},
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
@@ -335,6 +335,9 @@ internal fun SetupPanel(
}
}
SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) }
SettingsGroup(stringResource(R.string.vr_module_title)) {
VrModuleRows(uninstall = !s.sessionRunning && !s.runtimeActionsBlocked)
}
}
1 -> {
val controller = s.controller
@@ -523,9 +526,8 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
val back = stringResource(R.string.hop_from_games)
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
val at = Hops.sources["card:components"]
val open = { if (game == null) a.onComponents(true) else a.onGameComponents(game) }
if (at == null) open()
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components"), open)
if (at == null) a.onComponents(true)
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
}
}
add { m ->
@@ -185,9 +185,10 @@ internal fun PrimaryButton(
.graphicsLayer { alpha = if (LaunchOrigin.flooding === placed) 0f else 1f; scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.clip(Shape12)
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
.shine(hot, 0.45f)
.then(if (enabled) Modifier else Modifier.border(1.dp, pal.line2, Shape12))
.then(if (hot) Modifier.border(2.dp, pal.signal, Shape12) else Modifier)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled) {
.glideBorder(hot, Shape12, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) {
placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) }
onClick()
}
@@ -143,15 +143,15 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on
}
}
otherId?.let { id ->
val valid = id.isNotEmpty() && GameEnvironment.validScope(id)
val open = { if (valid) { scope = id.toLong().toString(); otherId = null } }
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AdbTextField(
id, { otherId = it.filter(Char::isDigit) }, stringResource(R.string.game_env_app_id), KeyboardType.Number, ImeAction.Done,
modifier = Modifier.weight(1f), compact = true,
onDone = open,
onDone = { if (GameEnvironment.validScope(id)) { scope = id.toLong().toString(); otherId = null } },
)
SecondaryButton(stringResource(R.string.game_env_open), enabled = valid, onClick = open)
SecondaryButton(stringResource(R.string.game_env_open), enabled = id.isNotEmpty() && GameEnvironment.validScope(id)) {
scope = id.toLong().toString(); otherId = null
}
}
}
if (error) Small(stringResource(R.string.game_env_error), error = true)
@@ -1,11 +1,9 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.text.format.Formatter
import android.speech.tts.TextToSpeech
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -72,7 +70,6 @@ import com.droiddeck.launcher.handtracking.Advice
import com.droiddeck.launcher.handtracking.BodyPose
import com.droiddeck.launcher.handtracking.HandCamera
import com.droiddeck.launcher.handtracking.HandTracking
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.handtracking.Landmark
import com.droiddeck.launcher.handtracking.TrackingCameras
import kotlinx.coroutines.delay
@@ -189,27 +186,12 @@ internal fun HandCameraRows(host: MenuHost, track: (String) -> Modifier, inSessi
})
!s.permitted -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_hint),
stringResource(R.string.hand_camera_allow_button), { ask.launch(Manifest.permission.CAMERA) })
!s.downloaded -> TrackingPackRow()
!s.downloaded -> VrModuleRows(uninstall = null, track)
!inSession -> ActionRow(stringResource(R.string.hand_camera_placement), stringResource(R.string.hand_camera_placement_hint),
stringResource(R.string.hand_camera_check), { HandTracking.requestCheck() })
}
}
@Composable
private fun TrackingPackRow() {
val context = LocalContext.current
val pack by TrackingPack.state.collectAsState()
val size = Formatter.formatShortFileSize(context, TrackingPack.size)
when (val now = pack) {
is AddonPack.State.Downloading -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), {}, progress = now.progress)
AddonPack.State.Failed -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_failed), stringResource(R.string.tracking_pack_retry), { TrackingPack.ensure(context) })
else -> ActionRow(stringResource(R.string.tracking_pack_title),
stringResource(R.string.tracking_pack_hint, size), stringResource(R.string.tracking_pack_download), { TrackingPack.ensure(context) })
}
}
@Composable
private fun PlacementCheck(settings: HandTracking.Settings, play: Boolean, onPlay: () -> Unit, onClose: () -> Unit) {
val context = LocalContext.current
@@ -3,9 +3,6 @@ package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -108,7 +105,7 @@ private fun LogoPreview(hero: File?, logo: File?, value: LogoPosition.Value) {
}
@Composable
private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, onClick: () -> Unit) {
private fun PinChip(label: String, selected: Boolean, onClick: () -> Unit) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
@@ -116,7 +113,7 @@ private fun PinChip(label: String, selected: Boolean, focus: FocusRequester?, on
val shape = RoundedCornerShape(8.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.then(if (focus != null) Modifier.focusRequester(focus) else Modifier).clip(shape)
modifier = Modifier.clip(shape)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f))
.border(if (selected) 2.dp else 1.dp, if (selected) pal.signal else pal.line, shape)
.glideBorder(hot, shape, pal.signal)
@@ -143,9 +140,6 @@ internal fun LogoPositionDialog(
val shown = rememberShown(onBack)
val back = { shown.targetState = false }
var value by remember { mutableStateOf(start ?: LogoPosition.DEFAULT) }
val startPin = remember { value.pin }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { focusSoon { first } }
var failed by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
val save: (LogoPosition.Value?) -> Unit = { next ->
@@ -178,7 +172,7 @@ internal fun LogoPositionDialog(
)
Text(stringResource(R.string.logo_pos_position), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin, if (pin == startPin) first else null) { value = value.copy(pin = pin) }
for (pin in LogoPosition.Pin.values()) PinChip(pin.label(), pin == value.pin) { value = value.copy(pin = pin) }
}
ValueSlider(
value.widthPct, LogoPosition.MIN_PCT..LogoPosition.MAX_PCT, step = 1, enabled = true, label = stringResource(R.string.logo_pos_size),
@@ -14,7 +14,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -99,7 +98,6 @@ class ModeSettings(
val wifiDiscoveryBlocked: Boolean = false,
val deckyInstalled: String? = null,
val deckyLatestRelease: DeckyManager.Release? = null,
val deckyBranch: Int = DeckyManager.BRANCH_STABLE,
val deckyChecking: Boolean = false,
val deckyStage: String? = null,
val deckyPercent: Int = -1,
@@ -146,7 +144,6 @@ class ModeSettingsActions(
val onWifiDiscoverySettings: () -> Unit = {},
val onDeckyInstall: (DeckyManager.Release) -> Unit = {},
val onDeckyCheck: () -> Unit = {},
val onDeckyBranch: (Int) -> Unit = {},
val onDeckyEnabled: (Boolean) -> Unit = {},
val onDeckyUninstall: () -> Unit = {},
val onPickDeckyPluginZip: () -> Unit = {},
@@ -426,19 +423,6 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
}
}
ChoiceRow(
host, "decky-channel", stringResource(R.string.decky_channel),
stringResource(R.string.decky_channel_hint),
listOf(
DeckyManager.BRANCH_STABLE to stringResource(R.string.decky_channel_stable),
DeckyManager.BRANCH_PRERELEASE to stringResource(R.string.decky_channel_prerelease),
) + if (s.deckyBranch == DeckyManager.BRANCH_TESTING) listOf(
DeckyManager.BRANCH_TESTING to stringResource(R.string.decky_channel_testing),
) else emptyList(),
s.deckyBranch,
enabled = s.deckyStage == null && !s.deckySessionRunning,
onPick = a.onDeckyBranch,
)
if (s.deckyInstalled != null) ToggleRow(
host, "decky-enabled", stringResource(R.string.mode_decky),
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
@@ -545,35 +529,6 @@ internal fun ProtonOptions(
GameEnvironmentRow()
}
@Composable
internal fun DeckyZipWarningDialog(onConfirm: () -> Unit, onCancel: () -> Unit) {
val context = androidx.compose.ui.platform.LocalContext.current
var left by remember { mutableIntStateOf(if (SessionPrefs.deckyZipWarningSeen(context)) 0 else DECKY_ZIP_WARNING_SECONDS) }
androidx.compose.runtime.LaunchedEffect(Unit) {
while (left > 0) {
kotlinx.coroutines.delay(1000L)
left--
}
SessionPrefs.setDeckyZipWarningSeen(context)
}
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.decky_zip_warning_title)) },
text = { Text(stringResource(R.string.decky_zip_warning_text)) },
confirmButton = {
androidx.compose.material3.TextButton(onClick = onConfirm, enabled = left == 0) {
Text(
if (left > 0) androidx.compose.ui.res.pluralStringResource(R.plurals.decky_zip_warning_wait, left, left)
else stringResource(R.string.decky_zip_warning_continue),
)
}
},
dismissButton = { androidx.compose.material3.TextButton(onClick = onCancel) { Text(stringResource(R.string.common_cancel)) } },
)
}
private const val DECKY_ZIP_WARNING_SECONDS = 5
@Composable
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.filled.Pause
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -69,8 +68,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -81,93 +78,21 @@ import androidx.compose.ui.text.style.TextOverflow
@Composable
fun HudText(text: String, graph: FloatArray = FloatArray(0), color: String = "mint", opacity: Int = 70) {
if (text.isEmpty() && graph.size < 2) return
val accent = hudInk(color)
val wash = (opacity.coerceIn(30, 100) / 100f)
val ink = accent.copy(alpha = (0.5f + wash * 0.5f).coerceIn(0.62f, 1f))
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Column(
horizontalAlignment = Alignment.End,
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.padding(12.dp)
.background(
Brush.verticalGradient(
listOf(accent.copy(alpha = wash * 0.28f), Color.Black.copy(alpha = wash * 0.72f)),
),
RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 5.dp),
) {
if (text.isNotEmpty()) Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = ink,
textAlign = TextAlign.End,
)
if (graph.size >= 2) {
if (text.isNotEmpty()) Spacer(Modifier.height(4.dp))
HudGraph(graph, accent, wash)
}
}
}
}
@Composable
private fun HudGraph(samples: FloatArray, accent: Color, wash: Float) {
Canvas(Modifier.width(148.dp).height(44.dp)) {
val maxMs = (samples.maxOrNull() ?: 16.7f).coerceAtLeast(16.7f)
val step = size.width / (samples.size - 1)
fun yOf(ms: Float) = size.height * (1f - (ms / maxMs).coerceIn(0f, 1f))
val fillAlpha = wash * 0.5f
val fill = Path().apply {
moveTo(0f, size.height)
samples.forEachIndexed { i, ms -> lineTo(i * step, yOf(ms)) }
lineTo(size.width, size.height)
close()
}
drawPath(
fill,
Brush.verticalGradient(listOf(HUD_SLOW.copy(alpha = fillAlpha), accent.copy(alpha = fillAlpha * 0.45f))),
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
val guide = yOf(16.7f)
drawLine(Color.White.copy(alpha = 0.32f), Offset(0f, guide), Offset(size.width, guide), strokeWidth = 1f)
for (i in 0 until samples.lastIndex) {
val tone = ((samples[i] - 8f) / 25f).coerceIn(0f, 1f)
drawLine(
mix(accent, HUD_SLOW, tone).copy(alpha = 0.55f + wash * 0.4f),
Offset(i * step, yOf(samples[i])),
Offset((i + 1) * step, yOf(samples[i + 1])),
strokeWidth = 2.5f,
cap = StrokeCap.Round,
)
}
}
}
private fun hudInk(color: String): Color = Color(
when (color) {
"white" -> 0xFFF2F5F8
"sky" -> 0xFF66C0F4
"amber" -> 0xFFFFB74D
"violet" -> 0xFFD0A2FF
else -> 0xFF3DDC84
}.toInt(),
)
private fun mix(from: Color, to: Color, t: Float): Color {
val u = t.coerceIn(0f, 1f)
return Color(
red = from.red + (to.red - from.red) * u,
green = from.green + (to.green - from.green) * u,
blue = from.blue + (to.blue - from.blue) * u,
alpha = 1f,
)
}
private val HUD_SLOW = Color(0xFFFF5C6C.toInt())
private class LoadStage(@androidx.annotation.StringRes val label: Int, @androidx.annotation.StringRes val detail: Int)
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
@@ -79,6 +79,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -142,6 +143,8 @@ import com.droiddeck.launcher.gpu.SteamVr
import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.VrPack
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
@@ -195,11 +198,6 @@ class DrawerActions(
val isHomeApp: Boolean,
val androidApps: List<HomeApp.LaunchableApp>,
val hudOn: Boolean,
val hudFps: Boolean = true,
val hudBattery: Boolean = true,
val hudGraph: Boolean = true,
val hudColor: String = SessionPrefs.HUD_COLOR_MINT,
val hudOpacity: Int = SessionPrefs.HUD_OPACITY_DEFAULT,
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
@@ -227,11 +225,6 @@ class DrawerActions(
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
val onHud: (Boolean) -> Unit,
val onHudFps: (Boolean) -> Unit = {},
val onHudBattery: (Boolean) -> Unit = {},
val onHudGraph: (Boolean) -> Unit = {},
val onHudColor: (String) -> Unit = {},
val onHudOpacity: (Int) -> Unit = {},
val onFrameGenPick: (FrameGen.Mode) -> Unit,
val onImportLossless: () -> Unit,
val onKeyboard: () -> Unit,
@@ -318,6 +311,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
val pal = LocalPalette.current
val host = rememberMenuHost()
val (glasses, glassesSettings) = rememberGlasses()
val vrPack by VrPack.state.collectAsState()
val glassesPages = glassesTabs(glasses)
var selectedGlassesTab by rememberSaveable { mutableStateOf(GlassesTab.DISPLAY) }
val glassesTab = selectedGlassesTab.takeIf { key -> glassesPages.any { it.first == key } } ?: GlassesTab.DISPLAY
@@ -472,30 +466,6 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
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.hudOn) {
ToggleRow(host, "hud-fps", stringResource(R.string.drawer_hud_fps), null, a.hudFps,
chipModifier = focus.track(page, "hud-fps"), onChange = a.onHudFps)
ToggleRow(host, "hud-battery", stringResource(R.string.drawer_hud_battery), null, a.hudBattery,
chipModifier = focus.track(page, "hud-battery"), onChange = a.onHudBattery)
ToggleRow(host, "hud-graph", stringResource(R.string.drawer_hud_graph), null, a.hudGraph,
chipModifier = focus.track(page, "hud-graph"), onChange = a.onHudGraph)
ChoiceRow(
host, "hud-color", stringResource(R.string.drawer_hud_color), null,
listOf(
SessionPrefs.HUD_COLOR_MINT to stringResource(R.string.drawer_hud_color_mint),
"white" to stringResource(R.string.drawer_hud_color_white),
"sky" to stringResource(R.string.drawer_hud_color_sky),
"amber" to stringResource(R.string.drawer_hud_color_amber),
"violet" to stringResource(R.string.drawer_hud_color_violet),
),
a.hudColor, chipModifier = focus.track(page, "hud-color"), onPick = a.onHudColor,
)
SliderRow(
stringResource(R.string.drawer_hud_opacity), null, a.hudOpacity,
SessionPrefs.HUD_OPACITY_RANGE, step = 10, format = { "$it%" },
modifier = focus.track(page, "hud-opacity"), onChange = a.onHudOpacity,
)
}
val fgOpen = host.open == "fg"
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
@@ -520,7 +490,10 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
}
SessionDrawerPage.VR -> {
SteamVrGroup(a) { key -> focus.track(page, key) }
if (vrPack == AddonPack.State.Ready) SteamVrGroup(a) { key -> focus.track(page, key) }
else SettingsGroup(stringResource(R.string.vr_module_title)) {
VrModuleRows(uninstall = null) { key -> focus.track(page, key) }
}
if (!a.glassesOutput) CardboardGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.CONTROLLER -> {
@@ -1021,6 +994,8 @@ private fun SteamVrGroup(a: DrawerActions, track: (String) -> Modifier) {
@Composable
private fun SteamVrRow(a: DrawerActions) {
val vrLabel = stringResource(R.string.cardboard_steamvr)
val vrPack by VrPack.state.collectAsState()
if (vrPack != AddonPack.State.Ready) return VrModuleRows(uninstall = null)
when (a.steamVr) {
SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, null, stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") })
SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {}
@@ -29,7 +29,7 @@ import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.glasses.CalibrationDistance
import com.droiddeck.launcher.handtracking.TrackingPack
import com.droiddeck.launcher.runtime.VrPack
import com.droiddeck.launcher.glasses.Glasses
import com.droiddeck.launcher.glasses.SpatialCalibrationProgress
import com.droiddeck.launcher.glasses.SpatialTracking
@@ -95,7 +95,7 @@ class SpatialTrackingActivity : ComponentActivity() {
}
override fun onPause() { SpatialTracking.diagnostics(false);super.onPause() }
private fun printBoard() {
if(!TrackingPack.loadNative(this)) return
if(!VrPack.loadNative(this)) return
val c=StereoCalibrator(this);val mat=Mat()
c.board.generateImage(Size(1500.0,2100.0),mat,0,1)
val bitmap=Bitmap.createBitmap(1500,2100,Bitmap.Config.ARGB_8888)
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -50,6 +51,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.glasses.GlassesMenuMirror
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.runtime.VrPack
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
@@ -107,6 +110,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
},
)
val (glasses, glassesSettings) = rememberGlasses()
val vrPack by VrPack.state.collectAsState()
val headset = glasses.present && glasses.canControl
val drop = rememberDropSheetState()
LaunchedEffect(headset) { if (!headset && (drop.open || drop.shown)) drop.reset() }
@@ -117,7 +121,8 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
if (headset) DropSheet(
drop, glassesTabs(glasses), covered = lifted,
button = { enabled ->
PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true,
PrimaryButton(stringResource(if (vrPack == AddonPack.State.Ready) R.string.games_play_steamvr else R.string.games_install_play_steamvr),
enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true,
modifier = Modifier.fillMaxWidth().testTag("play-steamvr")) { if (enabled) { drop.open = false; a.onPlaySteamVr() } }
},
) { tab ->
@@ -65,7 +65,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
)
Text(
stringResource(R.string.common_cancel), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant,
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).controllerConfirm(onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
modifier = Modifier.clip(Shape12).clickable(role = Role.Button, onClick = close).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@@ -201,12 +201,12 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
val padH = if (narrow) 14.dp else 22.dp
var chip by rememberSaveable { mutableStateOf(Store.GOG.id) }
val tabs = remember {
androidx.compose.runtime.mutableStateListOf<StoreTab>().apply {
addAll(arrangeStoreTabs(SessionPrefs.storesTabOrder(ctx), SessionPrefs.storesTabHidden(ctx)))
}
}
var chip by rememberSaveable { mutableStateOf(tabs.firstOrNull { it.shown }?.store?.id ?: Store.GOG.id) }
var arranging by remember { mutableStateOf(false) }
val shownStores = tabs.filter { it.shown }.map { it.store }
fun commitTabs() {
@@ -638,9 +638,8 @@ private fun StoreDot(store: Store, signedIn: Boolean) {
Box(
Modifier.size(12.dp)
.onGloballyPositioned { StoresMotion.dots[store] = it.boundsInRoot() }
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot)
.background(c.dot.copy(alpha = if (signedIn) 1f else 0.35f), CircleShape),
)
.pulseRing(StoresMotion.pulses[store] ?: 0, c.dot),
) { Box(Modifier.matchParentSize().clip(CircleShape).background(c.dot).alpha(if (signedIn) 1f else 0.35f)) }
}
@Composable
@@ -56,7 +56,7 @@ object Themes {
ELECTRIC, R.string.theme_electric, R.string.theme_electric_detail,
background = Color(0xFF070A10), surface = Color(0xFF0F1522), surfaceVariant = Color(0xFF16203A), line = Color(0xFF1E2A47), line2 = Color(0xFF2A3A5E),
onBackground = Color(0xFFEEF3FF), onSurfaceVariant = Color(0xFF8B9AB8),
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
primary = Color(0xFF2E86FF), primary2 = Color(0xFF136CE0), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFF2E86FF),
good = Color(0xFF4CD37F),
),
)
@@ -69,7 +69,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
val actions = mouse + ACTION_SHOW_KEYBOARD + GestureKeys.codes.keys.map { "key_$it" } + ACTION_KEY_TILDE
val p = LocalPalette.current
GestureDialogFrame(onDismiss,
GestureDialogFrame(stringResource(R.string.gesture_settings), stringResource(R.string.gesture_editor_subtitle), onDismiss,
footer = {
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.gesture_cancel)) }
@@ -83,6 +83,15 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}, modifier = Modifier.testTag("gesture-save")) { Text(stringResource(R.string.gesture_save_changes)) }
}
}) {
Column(Modifier.padding(horizontal = 20.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default"))
if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game"))
if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) }
}
Text(stringResource(if (!forGame) R.string.gesture_scope_default else if (gameDraft == null) R.string.gesture_inheriting else R.string.gesture_scope_game),
color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 12.dp))
}
ScrollableTabRow(tab, edgePadding = 12.dp, containerColor = p.surface, divider = { HorizontalDivider(color = p.line) }) {
tabs.forEachIndexed { index, title ->
Tab(tab == index, onClick = { tab = index }, text = { Text(stringResource(title)) }, selectedContentColor = p.onBackground,
@@ -91,16 +100,9 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
key(tab) {
Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GestureFilterChip(!forGame, onClick = { forGame = false }, label = { Text(stringResource(R.string.gesture_defaults)) }, modifier = Modifier.testTag("gesture-scope-default"))
if (targetGame != null) GestureFilterChip(forGame, onClick = { forGame = true }, label = { Text(stringResource(R.string.gesture_game)) }, modifier = Modifier.testTag("gesture-scope-game"))
}
if (forGame && gameDraft != null) TextButton(onClick = { gameDraft = null }, modifier = Modifier.testTag("gesture-inherit")) { Text(stringResource(R.string.gesture_use_defaults)) }
}
when (tab) {
0 -> {
GestureSection(R.string.gesture_tab_one)
GestureSection(R.string.gesture_tab_one, R.string.gesture_one_hint)
GestureCard(R.string.gesture_tap, config.tapEnabled, { change(config.copy(tapEnabled = it)) }) {
GestureActionField("tap", R.string.gesture_tap, config.tapAction, actions) { change(config.copy(tapAction = it)) }
}
@@ -116,7 +118,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
}
1 -> {
GestureSection(R.string.gesture_tab_two)
GestureSection(R.string.gesture_tab_two, R.string.gesture_two_hint)
GestureCard(R.string.gesture_two_tap, config.twoFingerTapEnabled, { change(config.copy(twoFingerTapEnabled = it)) }) {
GestureActionField("two-tap", R.string.gesture_two_tap, config.twoFingerTapAction, actions) { change(config.copy(twoFingerTapAction = it)) }
}
@@ -132,7 +134,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
}
2 -> {
GestureSection(R.string.gesture_tab_three)
GestureSection(R.string.gesture_tab_three, R.string.gesture_three_hint)
GestureCard(R.string.gesture_three_tap, config.threeFingerTapEnabled, { change(config.copy(threeFingerTapEnabled = it)) }) {
GestureActionField("three-tap", R.string.gesture_three_tap, config.threeFingerTapAction, actions) { change(config.copy(threeFingerTapAction = it)) }
}
@@ -145,13 +147,16 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
}
3 -> {
GestureSection(R.string.gesture_tab_options)
GestureSection(R.string.gesture_tab_options, R.string.gesture_options_hint)
GestureSettingsCard(R.string.gesture_movement) {
Text(stringResource(R.string.gesture_movement_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
GestureChoiceChips(config.mouseDragMovementMode, listOf(MOUSE_DRAG_MOVEMENT_DIRECT to stringResource(R.string.gesture_direct), MOUSE_DRAG_MOVEMENT_RELATIVE to stringResource(R.string.gesture_relative))) { change(config.copy(mouseDragMovementMode = it)) }
Text(stringResource(R.string.gesture_middle_relative_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
}
GestureSettingsCard(R.string.gesture_recognition) {
Text(stringResource(R.string.gesture_threshold, config.gestureThreshold), style = MaterialTheme.typography.bodyMedium)
Slider(config.gestureThreshold.toFloat(), { change(config.copy(gestureThreshold = it.toInt())) }, valueRange = 10f..100f, steps = 17)
Text(stringResource(R.string.gesture_threshold_hint), color = p.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
}
GestureSettingsCard(R.string.gesture_feedback) {
GestureToggle(R.string.gesture_debug, config.showGestureDebugOverlay) { change(config.copy(showGestureDebugOverlay = it)) }
@@ -165,7 +170,7 @@ fun TouchGestureDialog(game: String?, onDismiss: () -> Unit, onSaved: () -> Unit
}
@Composable
private fun GestureDialogFrame(onDismiss: () -> Unit,
private fun GestureDialogFrame(title: String, subtitle: String, onDismiss: () -> Unit,
footer: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) {
val p = LocalPalette.current
// The app window does not opt into RTL, so these dialogs follow their resource locale explicitly.
@@ -176,8 +181,15 @@ private fun GestureDialogFrame(onDismiss: () -> Unit,
Surface(Modifier.widthIn(max = 1040.dp).fillMaxWidth().fillMaxHeight(), shape = RoundedCornerShape(22.dp), color = p.background,
contentColor = p.onBackground, border = BorderStroke(1.dp, p.line2)) {
Column {
Row(Modifier.fillMaxWidth().padding(end = 4.dp), horizontalArrangement = Arrangement.End) {
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(12.dp), color = p.primary.copy(alpha = 0.14f)) {
Icon(Icons.Default.TouchApp, null, tint = p.primary, modifier = Modifier.padding(10.dp).size(24.dp))
}
Column(Modifier.weight(1f).padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = p.onSurfaceVariant)
}
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, stringResource(R.string.gesture_close)) }
}
content()
HorizontalDivider(color = p.line)
@@ -190,8 +202,11 @@ private fun GestureDialogFrame(onDismiss: () -> Unit,
}
@Composable
private fun GestureSection(@StringRes title: Int) {
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
private fun GestureSection(@StringRes title: Int, @StringRes hint: Int) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(hint), style = MaterialTheme.typography.bodyMedium, color = LocalPalette.current.onSurfaceVariant)
}
}
@Composable
@@ -227,18 +242,18 @@ private fun GestureActionField(id: String, @StringRes gesture: Int, selected: St
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, modifier = Modifier.size(19.dp), tint = p.onSurfaceVariant)
}
}
if (open) GestureActionPicker(choices, selected, onDismiss = { open = false }) { open = false; onChoose(it) }
if (open) GestureActionPicker(choices, selected, stringResource(gesture), onDismiss = { open = false }) { open = false; onChoose(it) }
}
@Composable
internal fun GestureActionPicker(choices: List<String>, selected: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
internal fun GestureActionPicker(choices: List<String>, selected: String, gesture: String, onDismiss: () -> Unit, onChoose: (String) -> Unit) {
var query by rememberSaveable { mutableStateOf("") }
var category by remember { mutableStateOf<GestureActionGroup?>(null) }
val catalog = choices.distinct().map { GestureAction(it, gestureActionTitle(it), gestureActionGroup(it)) }
val groups = GestureActionGroup.entries.filter { group -> catalog.any { it.group == group } }
val shown = filterGestureActions(catalog, query, category)
val p = LocalPalette.current
GestureDialogFrame(onDismiss) {
GestureDialogFrame(stringResource(R.string.gesture_action), stringResource(R.string.gesture_action_for, gesture), onDismiss) {
OutlinedTextField(query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).testTag("gesture-action-search"),
singleLine = true, shape = RoundedCornerShape(12.dp), placeholder = { Text(stringResource(R.string.gesture_search_actions)) },
leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = {
@@ -0,0 +1,62 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.VrPack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
internal fun VrModuleRows(uninstall: Boolean?, track: (String) -> Modifier = { Modifier }) {
val context = LocalContext.current
val pack by VrPack.state.collectAsState()
val removal by VrPack.removal.collectAsState()
val title = stringResource(R.string.vr_module_title)
val now = pack
when {
removal.busy -> {
SettingsRow(title, stringResource(R.string.vr_module_removing)) {}
Box(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 10.dp)) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
now is AddonPack.State.Downloading -> {
val percent = (now.progress * 100).toInt()
SettingsRow(title, stringResource(R.string.setup_runtime_progress, stringResource(R.string.vr_module_downloading), percent)) {}
Box(Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 10.dp)) {
LinearProgressIndicator(progress = { now.progress }, modifier = Modifier.fillMaxWidth().height(4.dp))
}
}
now == AddonPack.State.Ready -> {
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { VrPack.installedBytes(context) } }
SettingsRow(title, stringResource(R.string.vr_module_installed, size?.let { FileUtils.sizeToString(it) } ?: "…")) {
if (uninstall != null) UninstallButton(stringResource(R.string.vr_module_uninstall_title), stringResource(R.string.vr_module_uninstall_text),
enabled = uninstall, modifier = Modifier.testTag("vr-module-uninstall")) { VrPack.uninstall(context) }
}
}
else -> {
val hint = if (now == AddonPack.State.Failed) stringResource(R.string.vr_module_failed)
else stringResource(R.string.vr_module_hint, FileUtils.sizeToString(VrPack.size))
SettingsRow(title, hint) {
PrimaryButton(stringResource(R.string.common_download), compact = true,
modifier = track("vr-module").testTag("vr-module-download")) { VrPack.ensure(context) }
}
}
}
removal.error?.let { Box(Modifier.padding(horizontal = 14.dp, vertical = 6.dp)) { Note(it) } }
}
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">النقر مرة واحدة</string>
<string name="gesture_hold_delay">مهلة الضغط المستمر</string>
<string name="gesture_double_delay">الفاصل بين النقرتين</string>
<string name="gesture_scope_game">ينطبق على اللعبة الحالية</string>
<string name="gesture_scope_default">الإعدادات الافتراضية لجميع الألعاب</string>
<string name="gesture_reset">استعادة إعدادات الإيماءات الأصلية</string>
<string name="gesture_cancel">إلغاء</string>
<string name="gesture_action">اختيار إجراء</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">عجلة الفأرة</string>
<string name="gesture_threshold">عتبة الإيماءات: %1$d بكسل</string>
<string name="gesture_debug">إظهار الإيماءة المكتشفة</string>
<string name="gesture_editor_subtitle">عناصر تحكم الفأرة ولوحة المفاتيح مرتبة حسب الإيماءة</string>
<string name="gesture_tab_one">إصبع واحد</string>
<string name="gesture_tab_two">إصبعان</string>
<string name="gesture_tab_three">ثلاثة أصابع</string>
<string name="gesture_tab_options">خيارات</string>
<string name="gesture_one_hint">اختر إجراء النقر أو السحب أو الضغط المستمر.</string>
<string name="gesture_two_hint">اضبط تحريك الكاميرا والتكبير والنقرات الثانوية.</string>
<string name="gesture_three_hint">اجعل الاختصارات الإضافية في متناولك.</string>
<string name="gesture_options_hint">اضبط الحركة والتعرّف والاستجابة المرئية.</string>
<string name="gesture_defaults">الافتراضية</string>
<string name="gesture_game">هذه اللعبة</string>
<string name="gesture_use_defaults">استخدام الإعدادات الافتراضية</string>
<string name="gesture_inheriting">تُستخدم الإعدادات الافتراضية. يؤدي التعديل إلى إنشاء إعدادات خاصة باللعبة.</string>
<string name="gesture_save_changes">حفظ التغييرات</string>
<string name="gesture_close">إغلاق</string>
<string name="gesture_action_label">الإجراء</string>
<string name="gesture_action_for">لـ %1$s</string>
<string name="gesture_search_actions">البحث عن إجراءات الفأرة أو مفاتيح لوحة المفاتيح</string>
<string name="gesture_clear_search">مسح البحث</string>
<string name="gesture_all_actions">الكل</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">لوحة الأرقام</string>
<string name="gesture_group_symbols">الرموز</string>
<string name="gesture_movement">حركة الفأرة</string>
<string name="gesture_movement_hint">يتبع السحب المباشر موضع اللمس. ويتبع السحب النسبي حركة الإصبع، وهو مفيد عندما تعيد اللعبة الفأرة إلى المنتصف.</string>
<string name="gesture_middle_relative_hint">يتبع تحريك العرض بالزر الأوسط حركة الإصبع دائمًا لتجنّب قفزات المؤشر.</string>
<string name="gesture_direct">مباشر</string>
<string name="gesture_relative">نسبي</string>
<string name="gesture_recognition">التعرّف على الإيماءات</string>
<string name="gesture_threshold_hint">تحرّك هذه المسافة قبل أن تتحول إيماءة بإصبعين أو ثلاثة إلى تحريك العرض أو التكبير.</string>
<string name="gesture_feedback">الاستجابة المرئية</string>
<string name="gesture_mouse_behavior">سلوك زر الفأرة</string>
<string name="gesture_ms">%1$d مللي ثانية</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s عبر FEX</string>
<string name="app_fex_not_ready">هذا البرنامج مبني لأجهزة كمبيوتر %1$s ويعمل عبر FEX، الذي لم يتم إعداده بعد. افتح Steam مرة واحدة أثناء تسجيل الدخول: سيجعله DroidDeck يُنزّل FEX وSteam Linux Runtime اللازمة، ثم يُفتح هذا البرنامج.</string>
<string name="session_end_program">هذا البرنامج</string>
<string name="session_end_steam_download">تعذّر تنزيل عميل Steam. قد تكون شبكتك أو VPN تحظر خوادم Steam؛ تحقّق من الاتصال وحاول مرة أخرى.</string>
<string name="session_end_steam_setup">تعذّر إعداد عميل Steam. تحقّق من الاتصال والمساحة الفارغة، ثم حاول مرة أخرى.</string>
<string name="session_end_fex_missing">%1$s مبني لأجهزة كمبيوتر x86 ويعمل عبر FEX، الذي لم يتم تثبيته بعد. شغّل Steam مرة واحدة أثناء تسجيل الدخول ودعه يُنزّل FEX وSteam Linux Runtime، ثم شغّل هذا البرنامج مجددًا.</string>
<string name="add_app_flatpak_hint">معرّف تطبيق على Flathub أو صفحته على Flathub. يُعَدّ Flatpak أولًا إن لم يكن مُعدًّا بعد.</string>
<string name="add_app_file">الملف</string>
@@ -590,16 +588,6 @@
<string name="drawer_prev_page">الصفحة السابقة</string>
<string name="drawer_next_page">الصفحة التالية</string>
<string name="drawer_hud">واجهة الأداء</string>
<string name="drawer_hud_fps">معدل الإطارات</string>
<string name="drawer_hud_battery">درجة حرارة البطارية</string>
<string name="drawer_hud_graph">مخطط الأداء</string>
<string name="drawer_hud_color">اللون</string>
<string name="drawer_hud_color_mint">نعناعي</string>
<string name="drawer_hud_color_white">أبيض</string>
<string name="drawer_hud_color_sky">سماوي</string>
<string name="drawer_hud_color_amber">كهرماني</string>
<string name="drawer_hud_color_violet">بنفسجي</string>
<string name="drawer_hud_opacity">العتامة</string>
<string name="drawer_scaling">وضع التحجيم</string>
<string name="drawer_scaling_sharpness">حدة التحجيم</string>
<string name="drawer_effects">مؤثرات الشاشة</string>
@@ -1048,6 +1036,7 @@
<string name="games_empty">الألعاب التي تثبّتها في Steam تظهر هنا بعد الجلسة.</string>
<string name="games_play_steam">تشغيل Steam</string>
<string name="games_play_steamvr">تشغيل SteamVR</string>
<string name="games_install_play_steamvr">تنزيل وتشغيل SteamVR</string>
<string name="games_launch_settings">إعدادات التشغيل</string>
<string name="games_launch">تشغيل</string>
<string name="games_files">ملفات اللعبة</string>
@@ -1074,9 +1063,6 @@
<string name="games_added">لعبة مُضافة</string>
<string name="games_custom">لعبة مخصّصة</string>
<string name="games_store_storage">تخزين الألعاب</string>
<string name="games_uninstall_body">يحذف ملفاتها ويزيلها من Steam.</string>
<string name="games_remove_title">إزالة %1$s؟</string>
<string name="games_remove_body">يحذف الاختصار وتبقى ملفات اللعبة.</string>
<string name="gpu_auto_hint">أفضل زوج لهذا GPU، يُحافَظ على تحديثه</string>
<string name="gpu_manual_hint">اختر زوجًا بنفسك</string>
<string name="gpu_checking">جارٍ البحث عن برامج التشغيل…</string>
@@ -1409,9 +1395,12 @@
<string name="session_proton_downloading_mb">جارٍ تنزيل Proton Experimental (ARM64) · %1$d من %2$d MB</string>
<string name="session_proton_checking">جارٍ فحص Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">جارٍ فك ضغط Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">جارٍ تنزيل وحدة الواقع الافتراضي</string>
<string name="session_vr_downloading_mb">جارٍ تنزيل وحدة الواقع الافتراضي · %1$d من %2$d MB</string>
<string name="session_runtime_catalog_unreachable">تعذّر الوصول إلى كتالوج بيئة التشغيل. تحقَّق من الاتصال واضغط على «تشغيل» مرة أخرى.</string>
<string name="session_runtime_install_failed">لم يتم تثبيت بيئة تشغيل Linux. تحقَّق من بيئة التشغيل في «الإعداد» واضغط على «تشغيل» مرة أخرى.</string>
<string name="session_desktop_install_failed">لم يتم تثبيت سطح المكتب (%1$s). تحقَّق من الاتصال واضغط على «سطح المكتب» مرة أخرى.</string>
<string name="session_vr_install_failed">لم يتم تثبيت وحدة الواقع الافتراضي. تحقَّق من الاتصال واضغط على «تشغيل SteamVR» مرة أخرى.</string>
<string name="session_ended_named">توقّف %1$s بشكل غير متوقَّع. شارِك السجلات مع بلاغ عن الخطأ، أو حاوِل مرة أخرى.</string>
<string name="session_ended_program">توقّف البرنامج بشكل غير متوقَّع. شارِك السجلات مع بلاغ عن الخطأ، أو حاوِل مرة أخرى.</string>
<string name="session_ended_desktop">توقّف سطح المكتب بشكل غير متوقَّع. شارِك السجلات مع بلاغ عن الخطأ، أو حاوِل مرة أخرى.</string>
@@ -2566,6 +2555,16 @@
<string name="hand_camera_placement">وضع الكاميرا</string>
<string name="hand_camera_placement_hint">تحقّق من أن الكاميرا تراك قبل اللعب.</string>
<string name="hand_camera_check">تحقّق</string>
<string name="vr_module_title">وحدة الواقع الافتراضي</string>
<string name="vr_module_hint">SteamVR على النظارات وQuest وCardboard، مع تتبع اليدين بالكاميرا · تنزيل %1$s</string>
<string name="vr_module_installed">مثبّتة · %1$s</string>
<string name="vr_module_downloading">جارٍ تنزيل وحدة الواقع الافتراضي</string>
<string name="vr_module_failed">تعذّر تنزيل وحدة الواقع الافتراضي. تحقَّق من الاتصال وحاول مرة أخرى.</string>
<string name="vr_module_removing">جارٍ إزالة وحدة الواقع الافتراضي</string>
<string name="vr_module_busy">أوقف الجلسة قبل إزالة وحدة الواقع الافتراضي.</string>
<string name="vr_module_remove_failed">تعذّرت إزالة وحدة الواقع الافتراضي</string>
<string name="vr_module_uninstall_title">إلغاء تثبيت وحدة الواقع الافتراضي؟</string>
<string name="vr_module_uninstall_text">سيتوقف SteamVR وتتبع اليدين بالكاميرا عن العمل حتى تنزّلها مجددًا. يبقى SteamVR نفسه والألعاب والإعدادات كما هي.</string>
<string name="hand_camera_check_title">وضع الكاميرا</string>
<string name="hand_camera_how_rear">ضع الجهاز واقفًا على بُعد 1.5 إلى 2 متر تقريبًا بارتفاع الصدر، مع توجيه الكاميرا الخلفية نحوك. ثم ادخل إلى مجال الرؤية ويداك ممدودتان.</string>
<string name="hand_camera_how_front">ضع الجهاز واقفًا على بُعد 1.5 إلى 2 متر تقريبًا بارتفاع الصدر، مع توجيه الشاشة نحوك. ثم ادخل إلى مجال الرؤية ويداك ممدودتان.</string>
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Hacer clic una vez</string>
<string name="gesture_hold_delay">Tiempo de pulsación sostenida</string>
<string name="gesture_double_delay">Intervalo de doble toque</string>
<string name="gesture_scope_game">Se aplica al juego actual</string>
<string name="gesture_scope_default">Ajustes predeterminados para todos los juegos</string>
<string name="gesture_reset">Restablecer los ajustes originales de gestos</string>
<string name="gesture_cancel">Cancelar</string>
<string name="gesture_action">Elegir acción</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Rueda del ratón</string>
<string name="gesture_threshold">Umbral de gestos: %1$d px</string>
<string name="gesture_debug">Mostrar gesto reconocido</string>
<string name="gesture_editor_subtitle">Controles de ratón y teclado organizados por gesto</string>
<string name="gesture_tab_one">Un dedo</string>
<string name="gesture_tab_two">Dos dedos</string>
<string name="gesture_tab_three">Tres dedos</string>
<string name="gesture_tab_options">Opciones</string>
<string name="gesture_one_hint">Elige qué debe hacer un toque, arrastre o pulsación sostenida.</string>
<string name="gesture_two_hint">Configura el movimiento de cámara, el zoom y los clics secundarios.</string>
<string name="gesture_three_hint">Ten más accesos directos al alcance de la mano.</string>
<string name="gesture_options_hint">Ajusta el movimiento, el reconocimiento y la respuesta visual.</string>
<string name="gesture_defaults">Predeterminados</string>
<string name="gesture_game">Este juego</string>
<string name="gesture_use_defaults">Usar valores predeterminados</string>
<string name="gesture_inheriting">Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego.</string>
<string name="gesture_save_changes">Guardar cambios</string>
<string name="gesture_close">Cerrar</string>
<string name="gesture_action_label">ACCIÓN</string>
<string name="gesture_action_for">Para %1$s</string>
<string name="gesture_search_actions">Buscar acciones de ratón o teclas del teclado</string>
<string name="gesture_clear_search">Borrar búsqueda</string>
<string name="gesture_all_actions">Todas</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Teclado numérico</string>
<string name="gesture_group_symbols">Símbolos</string>
<string name="gesture_movement">Movimiento del ratón</string>
<string name="gesture_movement_hint">El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón.</string>
<string name="gesture_middle_relative_hint">El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor.</string>
<string name="gesture_direct">Directo</string>
<string name="gesture_relative">Relativo</string>
<string name="gesture_recognition">Reconocimiento de gestos</string>
<string name="gesture_threshold_hint">Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom.</string>
<string name="gesture_feedback">Respuesta visual</string>
<string name="gesture_mouse_behavior">Comportamiento del botón del ratón</string>
<string name="gesture_ms">%1$d ms</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s con FEX</string>
<string name="app_fex_not_ready">Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá.</string>
<string name="session_end_program">Este programa</string>
<string name="session_end_steam_download">No se pudo descargar el cliente de Steam. Es posible que tu red o VPN esté bloqueando los servidores de Steam; revisa la conexión e inténtalo de nuevo.</string>
<string name="session_end_steam_setup">No se pudo configurar el cliente de Steam. Revisa la conexión y el espacio libre, y vuelve a intentarlo.</string>
<string name="session_end_fex_missing">%1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo.</string>
<string name="add_app_flatpak_hint">Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está.</string>
<string name="add_app_file">Archivo</string>
@@ -577,16 +575,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_hud_fps">Tasa de fotogramas</string>
<string name="drawer_hud_battery">Temperatura de la batería</string>
<string name="drawer_hud_graph">Gráfico de rendimiento</string>
<string name="drawer_hud_color">Color</string>
<string name="drawer_hud_color_mint">Menta</string>
<string name="drawer_hud_color_white">Blanco</string>
<string name="drawer_hud_color_sky">Celeste</string>
<string name="drawer_hud_color_amber">Ámbar</string>
<string name="drawer_hud_color_violet">Violeta</string>
<string name="drawer_hud_opacity">Opacidad</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>
@@ -1009,6 +997,16 @@
<string name="hand_camera_placement">Posición de la cámara</string>
<string name="hand_camera_placement_hint">Comprueba que la cámara pueda verte antes de jugar.</string>
<string name="hand_camera_check">Comprobar</string>
<string name="vr_module_title">Módulo de RV</string>
<string name="vr_module_hint">SteamVR en lentes, Quest y Cardboard, además de seguimiento de manos con la cámara · descarga de %1$s</string>
<string name="vr_module_installed">Instalado · %1$s</string>
<string name="vr_module_downloading">Descargando el módulo de RV</string>
<string name="vr_module_failed">No se pudo descargar el módulo de RV. Revisa la conexión y vuelve a intentarlo.</string>
<string name="vr_module_removing">Quitando el módulo de RV</string>
<string name="vr_module_busy">Detén la sesión antes de quitar el módulo de RV.</string>
<string name="vr_module_remove_failed">No se pudo quitar el módulo de RV</string>
<string name="vr_module_uninstall_title">¿Desinstalar el módulo de RV?</string>
<string name="vr_module_uninstall_text">SteamVR y el seguimiento de manos con la cámara dejarán de funcionar hasta que lo vuelvas a descargar. SteamVR, los juegos y la configuración se conservan.</string>
<string name="hand_camera_check_title">Posición de la cámara</string>
<string name="hand_camera_how_rear">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la cámara trasera hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
<string name="hand_camera_how_front">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la pantalla hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
@@ -1086,6 +1084,7 @@
<string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string>
<string name="games_play_steam">Jugar en Steam</string>
<string name="games_play_steamvr">Jugar en SteamVR</string>
<string name="games_install_play_steamvr">Descargar y jugar en SteamVR</string>
<string name="games_launch_settings">Ajustes de inicio</string>
<string name="games_launch">Iniciar</string>
<string name="games_files">Archivos del juego</string>
@@ -1112,9 +1111,6 @@
<string name="games_added">Juego agregado</string>
<string name="games_custom">Juego personalizado</string>
<string name="games_store_storage">Almacenamiento de juegos</string>
<string name="games_uninstall_body">Elimina sus archivos y la quita de Steam.</string>
<string name="games_remove_title">¿Quitar %1$s?</string>
<string name="games_remove_body">Elimina su acceso directo. Los archivos del juego se conservan.</string>
<string name="gpu_auto_hint">El mejor par para esta GPU, siempre actualizado</string>
<string name="gpu_manual_hint">Elige un par manualmente</string>
<string name="gpu_checking">Buscando controladores…</string>
@@ -1421,9 +1417,12 @@
<string name="session_proton_downloading_mb">descargando Proton Experimental (ARM64) · %1$d de %2$d MB</string>
<string name="session_proton_checking">comprobando Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">descomprimiendo Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">descargando el módulo de RV</string>
<string name="session_vr_downloading_mb">descargando el módulo de RV · %1$d de %2$d MB</string>
<string name="session_runtime_catalog_unreachable">No se pudo acceder al catálogo del runtime. Revisa la conexión y vuelve a presionar Jugar.</string>
<string name="session_runtime_install_failed">El runtime de Linux no se instaló. Revisa el runtime en Configuración y vuelve a presionar Jugar.</string>
<string name="session_desktop_install_failed">El escritorio no se instaló (%1$s). Revisa la conexión y presiona Escritorio de nuevo.</string>
<string name="session_vr_install_failed">El módulo de RV no se instaló. Revisa la conexión y presiona Jugar en SteamVR de nuevo.</string>
<string name="session_ended_named">%1$s se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
<string name="session_ended_program">El programa se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
<string name="session_ended_desktop">El escritorio se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Еднократно щракване</string>
<string name="gesture_hold_delay">Закъснение за задържане</string>
<string name="gesture_double_delay">Интервал за двойно докосване</string>
<string name="gesture_scope_game">Прилага се към текущата игра</string>
<string name="gesture_scope_default">Настройки по подразбиране за всички игри</string>
<string name="gesture_reset">Възстановяване на първоначалните настройки на жестовете</string>
<string name="gesture_cancel">Отказ</string>
<string name="gesture_action">Избор на действие</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Колелце на мишката</string>
<string name="gesture_threshold">Праг на жестовете: %1$d пиксела</string>
<string name="gesture_debug">Показване на разпознатия жест</string>
<string name="gesture_editor_subtitle">Управление с мишка и клавиатура, подредено по жестове</string>
<string name="gesture_tab_one">Един пръст</string>
<string name="gesture_tab_two">Два пръста</string>
<string name="gesture_tab_three">Три пръста</string>
<string name="gesture_tab_options">Опции</string>
<string name="gesture_one_hint">Изберете действието при докосване, плъзгане или задържане.</string>
<string name="gesture_two_hint">Настройте преместването на камерата, мащабирането и вторичните щраквания.</string>
<string name="gesture_three_hint">Дръжте допълнителните команди подръка.</string>
<string name="gesture_options_hint">Настройте движението, разпознаването и визуалната обратна връзка.</string>
<string name="gesture_defaults">Стандартни настройки</string>
<string name="gesture_game">Тази игра</string>
<string name="gesture_use_defaults">Използване на подразбиращите се</string>
<string name="gesture_inheriting">Използват се настройките по подразбиране. Редактирането създава настройки за тази игра.</string>
<string name="gesture_save_changes">Запазване на промените</string>
<string name="gesture_close">Затвори</string>
<string name="gesture_action_label">ДЕЙСТВИЕ</string>
<string name="gesture_action_for">За %1$s</string>
<string name="gesture_search_actions">Търсене на действия с мишката или клавиши</string>
<string name="gesture_clear_search">Изчистване на търсенето</string>
<string name="gesture_all_actions">Всички</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Цифрова клавиатура</string>
<string name="gesture_group_symbols">Символи</string>
<string name="gesture_movement">Движение на мишката</string>
<string name="gesture_movement_hint">Директното плъзгане следва мястото на докосване. Относителното следва движението на пръста и е полезно, когато играта центрира мишката.</string>
<string name="gesture_middle_relative_hint">Преместването със средния бутон винаги следва движението на пръста, за да няма скокове на курсора.</string>
<string name="gesture_direct">Директно</string>
<string name="gesture_relative">Относително</string>
<string name="gesture_recognition">Разпознаване на жестове</string>
<string name="gesture_threshold_hint">Изминете това разстояние, преди жест с два или три пръста да стане преместване или мащабиране.</string>
<string name="gesture_feedback">Визуална обратна връзка</string>
<string name="gesture_mouse_behavior">Поведение на бутона на мишката</string>
<string name="gesture_ms">%1$d мс</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s през FEX</string>
<string name="app_fex_not_ready">Тази програма е компилирана за %1$s компютри и се стартира през FEX, който още не е настроен. Отворете Steam веднъж, докато сте влезли: DroidDeck ще изтегли FEX и необходимата Steam Linux Runtime, след което тя ще се отвори.</string>
<string name="session_end_program">Тази програма</string>
<string name="session_end_steam_download">Клиентът на Steam не можа да бъде изтеглен. Мрежата или VPN връзката ви може да блокира сървърите на Steam; проверете връзката и опитайте отново.</string>
<string name="session_end_steam_setup">Клиентът на Steam не можа да бъде настроен. Проверете връзката и свободното място, след което опитайте отново.</string>
<string name="session_end_fex_missing">%1$s е компилирана за x86 компютри и се стартира през FEX, който още не е инсталиран. Стартирайте Steam веднъж, докато сте влезли, и го оставете да изтегли FEX и Steam Linux Runtime, след което стартирайте отново.</string>
<string name="add_app_flatpak_hint">ID на приложение в Flathub или страницата му в Flathub. Flatpak се настройва първо, ако още не е.</string>
<string name="add_app_file">Файл</string>
@@ -582,16 +580,6 @@
<string name="drawer_prev_page">Предишна страница</string>
<string name="drawer_next_page">Следваща страница</string>
<string name="drawer_hud">HUD за производителност</string>
<string name="drawer_hud_fps">Честота на кадрите</string>
<string name="drawer_hud_battery">Температура на батерията</string>
<string name="drawer_hud_graph">Графика на производителността</string>
<string name="drawer_hud_color">Цвят</string>
<string name="drawer_hud_color_mint">Мента</string>
<string name="drawer_hud_color_white">Бяло</string>
<string name="drawer_hud_color_sky">Небесно</string>
<string name="drawer_hud_color_amber">Кехлибарено</string>
<string name="drawer_hud_color_violet">Виолетово</string>
<string name="drawer_hud_opacity">Прозрачност</string>
<string name="drawer_scaling">Режим на мащабиране</string>
<string name="drawer_scaling_sharpness">Острота на мащабиране</string>
<string name="drawer_effects">Ефекти на екрана</string>
@@ -1024,6 +1012,7 @@
<string name="games_empty">Игрите, които инсталирате в Steam, се показват тук след сесията.</string>
<string name="games_play_steam">Пускане на Steam</string>
<string name="games_play_steamvr">Пускане на SteamVR</string>
<string name="games_install_play_steamvr">Изтегли и пусни SteamVR</string>
<string name="games_launch_settings">Настройки за стартиране</string>
<string name="games_launch">Стартиране</string>
<string name="games_files">Файлове на играта</string>
@@ -1050,9 +1039,6 @@
<string name="games_added">Добавена игра</string>
<string name="games_custom">Персонализирана игра</string>
<string name="games_store_storage">Хранилище за игри</string>
<string name="games_uninstall_body">Изтрива файловете ѝ и я премахва от Steam.</string>
<string name="games_remove_title">Премахване на %1$s?</string>
<string name="games_remove_body">Изтрива пряката пътека. Файловете на играта остават.</string>
<string name="gpu_auto_hint">Най-добрата двойка за този GPU, поддържана актуална</string>
<string name="gpu_manual_hint">Изберете двойка сами</string>
<string name="gpu_checking">Проверка за драйвери…</string>
@@ -1357,9 +1343,12 @@
<string name="session_proton_downloading_mb">изтегляне на Proton Experimental (ARM64) · %1$d от %2$d MB</string>
<string name="session_proton_checking">проверка на Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">разопаковане на Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">изтегляне на VR модула</string>
<string name="session_vr_downloading_mb">изтегляне на VR модула · %1$d от %2$d MB</string>
<string name="session_runtime_catalog_unreachable">Каталогът на средата не можа да бъде достигнат. Провери връзката и натисни „Пускане“ отново.</string>
<string name="session_runtime_install_failed">Средата на Linux не се инсталира. Провери средата в „Настройка“ и натисни „Пускане“ отново.</string>
<string name="session_desktop_install_failed">Работният плот не се инсталира (%1$s). Провери връзката и натисни „Desktop“ отново.</string>
<string name="session_vr_install_failed">VR модулът не се инсталира. Провери връзката и натисни „Пускане на SteamVR“ отново.</string>
<string name="session_ended_named">%1$s спря неочаквано. Сподели логовете в отчет за грешка или опитай отново.</string>
<string name="session_ended_program">Програмата спря неочаквано. Сподели логовете в отчет за грешка или опитай отново.</string>
<string name="session_ended_desktop">Работният плот спря неочаквано. Сподели логовете в отчет за грешка или опитай отново.</string>
@@ -2466,6 +2455,16 @@
<string name="hand_camera_placement">Разположение на камерата</string>
<string name="hand_camera_placement_hint">Проверете дали камерата ви вижда, преди да играете.</string>
<string name="hand_camera_check">Проверка</string>
<string name="vr_module_title">VR модул</string>
<string name="vr_module_hint">SteamVR на очила, Quest и Cardboard, плюс проследяване на ръцете с камерата · изтегляне %1$s</string>
<string name="vr_module_installed">Инсталиран · %1$s</string>
<string name="vr_module_downloading">Изтегляне на VR модула</string>
<string name="vr_module_failed">VR модулът не можа да бъде изтеглен. Провери връзката и опитай отново.</string>
<string name="vr_module_removing">Премахване на VR модула</string>
<string name="vr_module_busy">Спри сесията, преди да премахнеш VR модула.</string>
<string name="vr_module_remove_failed">VR модулът не можа да бъде премахнат</string>
<string name="vr_module_uninstall_title">Деинсталиране на VR модула?</string>
<string name="vr_module_uninstall_text">SteamVR и проследяването на ръцете с камерата спират да работят, докато не го изтеглиш отново. Самият SteamVR, игрите и настройките се запазват.</string>
<string name="hand_camera_check_title">Разположение на камерата</string>
<string name="hand_camera_how_rear">Поставете устройството изправено на 1,5–2 метра разстояние, приблизително на нивото на гърдите, със задната камера, насочена към вас. След това застанете в кадър с протегнати ръце.</string>
<string name="hand_camera_how_front">Поставете устройството изправено на 1,5–2 метра разстояние, приблизително на нивото на гърдите, с екрана, насочен към вас. След това застанете в кадър с протегнати ръце.</string>
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Jednou kliknout</string>
<string name="gesture_hold_delay">Prodleva podržení</string>
<string name="gesture_double_delay">Interval dvojitého klepnutí</string>
<string name="gesture_scope_game">Platí pro aktuální hru</string>
<string name="gesture_scope_default">Výchozí nastavení pro všechny hry</string>
<string name="gesture_reset">Obnovit původní nastavení gest</string>
<string name="gesture_cancel">Zrušit</string>
<string name="gesture_action">Vybrat akci</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Kolečko myši</string>
<string name="gesture_threshold">Práh gest: %1$d px</string>
<string name="gesture_debug">Zobrazovat rozpoznané gesto</string>
<string name="gesture_editor_subtitle">Ovládání myši a klávesnice uspořádané podle gest</string>
<string name="gesture_tab_one">Jeden prst</string>
<string name="gesture_tab_two">Dva prsty</string>
<string name="gesture_tab_three">Tři prsty</string>
<string name="gesture_tab_options">Možnosti</string>
<string name="gesture_one_hint">Vyberte akci pro klepnutí, tažení nebo podržení.</string>
<string name="gesture_two_hint">Nastavte posouvání kamery, přiblížení a vedlejší kliknutí.</string>
<string name="gesture_three_hint">Mějte další zkratky na dosah.</string>
<string name="gesture_options_hint">Upravte pohyb, rozpoznávání a vizuální odezvu.</string>
<string name="gesture_defaults">Výchozí</string>
<string name="gesture_game">Tato hra</string>
<string name="gesture_use_defaults">Použít výchozí</string>
<string name="gesture_inheriting">Používá se výchozí nastavení. Úpravou vznikne nastavení pro tuto hru.</string>
<string name="gesture_save_changes">Uložit změny</string>
<string name="gesture_close">Zavřít</string>
<string name="gesture_action_label">AKCE</string>
<string name="gesture_action_for">Pro %1$s</string>
<string name="gesture_search_actions">Hledat akce myši nebo klávesy</string>
<string name="gesture_clear_search">Vymazat hledání</string>
<string name="gesture_all_actions">Vše</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Numerická klávesnice</string>
<string name="gesture_group_symbols">Symboly</string>
<string name="gesture_movement">Pohyb myši</string>
<string name="gesture_movement_hint">Přímé tažení sleduje polohu dotyku. Relativní tažení sleduje pohyb prstu a hodí se, když hra vrací myš do středu.</string>
<string name="gesture_middle_relative_hint">Posouvání prostředním tlačítkem vždy sleduje pohyb prstu, aby kurzor nepřeskakoval.</string>
<string name="gesture_direct">Přímé</string>
<string name="gesture_relative">Relativní</string>
<string name="gesture_recognition">Rozpoznávání gest</string>
<string name="gesture_threshold_hint">Po překonání této vzdálenosti se gesto dvěma či třemi prsty změní na posouvání nebo přiblížení.</string>
<string name="gesture_feedback">Vizuální odezva</string>
<string name="gesture_mouse_behavior">Chování tlačítka myši</string>
<string name="gesture_ms">%1$d ms</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s přes FEX</string>
<string name="app_fex_not_ready">Tento program je sestaven pro PC %1$s a běží přes FEX, který ještě není nastaven. Jednou spusťte přihlášený Steam: DroidDeck tím stáhne FEX a Steam Linux Runtime, který potřebuje, a pak se tento program otevře.</string>
<string name="session_end_program">Tento program</string>
<string name="session_end_steam_download">Klienta Steam se nepodařilo stáhnout. Vaše síť nebo VPN možná blokuje servery Steamu; zkontrolujte připojení a zkuste to znovu.</string>
<string name="session_end_steam_setup">Klienta Steam se nepodařilo nastavit. Zkontrolujte připojení a volné místo a zkuste to znovu.</string>
<string name="session_end_fex_missing">%1$s je sestaven pro PC x86 a běží přes FEX, který ještě není nainstalován. Jednou spusťte přihlášený Steam a nechte ho stáhnout FEX a Steam Linux Runtime, pak to spusťte znovu.</string>
<string name="add_app_flatpak_hint">ID aplikace Flathub nebo její stránka na Flathub. Flatpak se nejprve nastaví, pokud ještě není.</string>
<string name="add_app_file">Soubor</string>
@@ -586,16 +584,6 @@
<string name="drawer_prev_page">Předchozí stránka</string>
<string name="drawer_next_page">Další stránka</string>
<string name="drawer_hud">HUD výkonu</string>
<string name="drawer_hud_fps">Snímková frekvence</string>
<string name="drawer_hud_battery">Teplota baterie</string>
<string name="drawer_hud_graph">Graf výkonu</string>
<string name="drawer_hud_color">Barva</string>
<string name="drawer_hud_color_mint">Máta</string>
<string name="drawer_hud_color_white">Bílá</string>
<string name="drawer_hud_color_sky">Nebeská</string>
<string name="drawer_hud_color_amber">Ambrová</string>
<string name="drawer_hud_color_violet">Fialová</string>
<string name="drawer_hud_opacity">Průhlednost</string>
<string name="drawer_scaling">Režim škálování</string>
<string name="drawer_scaling_sharpness">Ostrost škálování</string>
<string name="drawer_effects">Efekty obrazovky</string>
@@ -1036,6 +1024,7 @@
<string name="games_empty">Hry, které nainstalujete ve Steamu, se zde zobrazí po relaci.</string>
<string name="games_play_steam">Hrát ve Steamu</string>
<string name="games_play_steamvr">Hrát ve SteamVR</string>
<string name="games_install_play_steamvr">Stáhnout a hrát ve SteamVR</string>
<string name="games_launch_settings">Nastavení spuštění</string>
<string name="games_launch">Spustit</string>
<string name="games_files">Soubory hry</string>
@@ -1062,9 +1051,6 @@
<string name="games_added">Přidaná hra</string>
<string name="games_custom">Vlastní hra</string>
<string name="games_store_storage">Úložiště her</string>
<string name="games_uninstall_body">Smaže její soubory a odebere ji ze Steamu.</string>
<string name="games_remove_title">Odebrat %1$s?</string>
<string name="games_remove_body">Smaže zástupce. Soubory hry zůstanou.</string>
<string name="gpu_auto_hint">Nejlepší dvojice pro tento GPU, udržovaná aktuální</string>
<string name="gpu_manual_hint">Vyberte dvojici sami</string>
<string name="gpu_checking">Hledání ovladačů…</string>
@@ -1383,9 +1369,12 @@
<string name="session_proton_downloading_mb">stahování Proton Experimental (ARM64) · %1$d z %2$d MB</string>
<string name="session_proton_checking">kontrola Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">rozbalování Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">stahování modulu VR</string>
<string name="session_vr_downloading_mb">stahování modulu VR · %1$d z %2$d MB</string>
<string name="session_runtime_catalog_unreachable">Nepodařilo se spojit s katalogem runtime. Zkontrolujte připojení a znovu stiskněte Hrát.</string>
<string name="session_runtime_install_failed">Runtime Linuxu se nenainstaloval. Zkontrolujte runtime v Přípravě a znovu stiskněte Hrát.</string>
<string name="session_desktop_install_failed">Plocha se nenainstalovala (%1$s). Zkontrolujte připojení a znovu stiskněte Plocha.</string>
<string name="session_vr_install_failed">Modul VR se nenainstaloval. Zkontrolujte připojení a znovu stiskněte Hrát ve SteamVR.</string>
<string name="session_ended_named">Došlo k neočekávanému zastavení %1$s. Přiložte logy k hlášení chyby, nebo zkuste znovu.</string>
<string name="session_ended_program">Program neočekávaně skončil. Přiložte logy k hlášení chyby, nebo zkuste znovu.</string>
<string name="session_ended_desktop">Plocha neočekávaně skončila. Přiložte logy k hlášení chyby, nebo zkuste znovu.</string>
@@ -2516,6 +2505,16 @@
<string name="hand_camera_placement">Umístění kamery</string>
<string name="hand_camera_placement_hint">Než začnete hrát, zkontrolujte, že vás kamera vidí.</string>
<string name="hand_camera_check">Zkontrolovat</string>
<string name="vr_module_title">Modul VR</string>
<string name="vr_module_hint">SteamVR na brýlích, Quest a Cardboard a sledování rukou kamerou · stažení %1$s</string>
<string name="vr_module_installed">Nainstalováno · %1$s</string>
<string name="vr_module_downloading">Stahování modulu VR</string>
<string name="vr_module_failed">Modul VR se nepodařilo stáhnout. Zkontrolujte připojení a zkuste to znovu.</string>
<string name="vr_module_removing">Odebírání modulu VR</string>
<string name="vr_module_busy">Před odebráním modulu VR ukončete relaci.</string>
<string name="vr_module_remove_failed">Modul VR se nepodařilo odebrat</string>
<string name="vr_module_uninstall_title">Odinstalovat modul VR?</string>
<string name="vr_module_uninstall_text">SteamVR a sledování rukou kamerou přestanou fungovat, dokud modul znovu nestáhnete. SteamVR samotný, hry a nastavení zůstanou zachovány.</string>
<string name="hand_camera_check_title">Umístění kamery</string>
<string name="hand_camera_how_rear">Postavte zařízení svisle do vzdálenosti 1,5 až 2 metry přibližně ve výšce hrudníku tak, aby zadní kamera směřovala k vám. Pak se postavte do záběru s nataženýma rukama.</string>
<string name="hand_camera_how_front">Postavte zařízení svisle do vzdálenosti 1,5 až 2 metry přibližně ve výšce hrudníku tak, aby obrazovka směřovala k vám. Pak se postavte do záběru s nataženýma rukama.</string>
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Klik én gang</string>
<string name="gesture_hold_delay">Forsinkelse før hold</string>
<string name="gesture_double_delay">Interval for dobbelttryk</string>
<string name="gesture_scope_game">Gælder det aktuelle spil</string>
<string name="gesture_scope_default">Standardindstillinger for alle spil</string>
<string name="gesture_reset">Gendan de oprindelige bevægelsesindstillinger</string>
<string name="gesture_cancel">Annuller</string>
<string name="gesture_action">Vælg handling</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Musehjul</string>
<string name="gesture_threshold">Bevægelsestærskel: %1$d px</string>
<string name="gesture_debug">Vis genkendt bevægelse</string>
<string name="gesture_editor_subtitle">Muse- og tastaturstyring organiseret efter bevægelse</string>
<string name="gesture_tab_one">Én finger</string>
<string name="gesture_tab_two">To fingre</string>
<string name="gesture_tab_three">Tre fingre</string>
<string name="gesture_tab_options">Indstillinger</string>
<string name="gesture_one_hint">Vælg, hvad et tryk, træk eller hold skal gøre.</string>
<string name="gesture_two_hint">Indstil kamerapanorering, zoom og sekundære klik.</string>
<string name="gesture_three_hint">Hav flere genveje inden for rækkevidde.</string>
<string name="gesture_options_hint">Juster bevægelse, genkendelse og visuel feedback.</string>
<string name="gesture_defaults">Standarder</string>
<string name="gesture_game">Dette spil</string>
<string name="gesture_use_defaults">Brug standardindstillinger</string>
<string name="gesture_inheriting">Bruger standardindstillingerne. Redigering opretter særlige indstillinger for spillet.</string>
<string name="gesture_save_changes">Gem ændringer</string>
<string name="gesture_close">Luk</string>
<string name="gesture_action_label">HANDLING</string>
<string name="gesture_action_for">For %1$s</string>
<string name="gesture_search_actions">Søg efter musehandlinger eller tastaturtaster</string>
<string name="gesture_clear_search">Ryd søgning</string>
<string name="gesture_all_actions">Alle</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Numerisk tastatur</string>
<string name="gesture_group_symbols">Symboler</string>
<string name="gesture_movement">Musebevægelse</string>
<string name="gesture_movement_hint">Direkte træk følger berøringspositionen. Relative træk følger fingerbevægelsen og er nyttige, når et spil centrerer musen.</string>
<string name="gesture_middle_relative_hint">Panorering med midterste museknap følger altid fingerbevægelsen for at undgå markørspring.</string>
<string name="gesture_direct">Direkte</string>
<string name="gesture_relative">Relativ</string>
<string name="gesture_recognition">Genkendelse af bevægelser</string>
<string name="gesture_threshold_hint">Bevæg fingrene så langt, før en bevægelse med to eller tre fingre bliver til panorering eller zoom.</string>
<string name="gesture_feedback">Visuel feedback</string>
<string name="gesture_mouse_behavior">Museknappens funktion</string>
<string name="gesture_ms">%1$d ms</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s via FEX</string>
<string name="app_fex_not_ready">Dette program er bygget til %1$s-pc’er og køres via FEX, som ikke er sat op endnu. Åbn Steam én gang, mens du er logget ind: DroidDeck downloader så FEX og den Steam Linux Runtime, det kræver, hvorefter dette åbner.</string>
<string name="session_end_program">Dette program</string>
<string name="session_end_steam_download">Steam-klienten kunne ikke downloades. Dit netværk eller din VPN blokerer muligvis Steams servere; tjek forbindelsen, og prøv igen.</string>
<string name="session_end_steam_setup">Steam-klienten kunne ikke konfigureres. Tjek forbindelsen og den ledige lagerplads, og prøv igen.</string>
<string name="session_end_fex_missing">%1$s er bygget til x86-pc’er og køres via FEX, som ikke er installeret endnu. Start Steam én gang, mens du er logget ind, og lad det downloade FEX og Steam Linux Runtime, og start så dette igen.</string>
<string name="add_app_flatpak_hint">Et Flathub-app-id eller dens Flathub-side. Flatpak sættes op først, hvis det ikke allerede er.</string>
<string name="add_app_file">Fil</string>
@@ -582,16 +580,6 @@
<string name="drawer_prev_page">Forrige side</string>
<string name="drawer_next_page">Næste side</string>
<string name="drawer_hud">Ydelses-HUD</string>
<string name="drawer_hud_fps">Billedhastighed</string>
<string name="drawer_hud_battery">Batteritemperatur</string>
<string name="drawer_hud_graph">Ydelsesgraf</string>
<string name="drawer_hud_color">Farve</string>
<string name="drawer_hud_color_mint">Mint</string>
<string name="drawer_hud_color_white">Hvid</string>
<string name="drawer_hud_color_sky">Himmelblå</string>
<string name="drawer_hud_color_amber">Ravgul</string>
<string name="drawer_hud_color_violet">Violet</string>
<string name="drawer_hud_opacity">Gennemsigtighed</string>
<string name="drawer_scaling">Skaleringstilstand</string>
<string name="drawer_scaling_sharpness">Skaleringsskarphed</string>
<string name="drawer_effects">Skærmeffekter</string>
@@ -1024,6 +1012,7 @@
<string name="games_empty">Spil, du installerer i Steam, vises her efter sessionen.</string>
<string name="games_play_steam">Spil Steam</string>
<string name="games_play_steamvr">Spil SteamVR</string>
<string name="games_install_play_steamvr">Download og spil SteamVR</string>
<string name="games_launch_settings">Startindstillinger</string>
<string name="games_launch">Start</string>
<string name="games_files">Spilfiler</string>
@@ -1050,9 +1039,6 @@
<string name="games_added">Tilføjet spil</string>
<string name="games_custom">Brugerdefineret spil</string>
<string name="games_store_storage">Spillager</string>
<string name="games_uninstall_body">Sletter dets filer og fjerner det fra Steam.</string>
<string name="games_remove_title">Fjern %1$s?</string>
<string name="games_remove_body">Sletter genvejen. Spillets filer bliver.</string>
<string name="gpu_auto_hint">Det bedste par til denne GPU, holdt opdateret</string>
<string name="gpu_manual_hint">Vælg et par selv</string>
<string name="gpu_checking">Søger efter drivere…</string>
@@ -1357,9 +1343,12 @@
<string name="session_proton_downloading_mb">downloader Proton Experimental (ARM64) · %1$d af %2$d MB</string>
<string name="session_proton_checking">kontrollerer Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">udpakker Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">downloader VR-modulet</string>
<string name="session_vr_downloading_mb">downloader VR-modulet · %1$d af %2$d MB</string>
<string name="session_runtime_catalog_unreachable">Kunne ikke nå runtime-kataloget. Kontroller forbindelsen, og tryk på Spil igen.</string>
<string name="session_runtime_install_failed">Linux-runtime blev ikke installeret. Kontroller runtime i Opsætning, og tryk på Spil igen.</string>
<string name="session_desktop_install_failed">Skrivebordet blev ikke installeret (%1$s). Kontroller forbindelsen, og tryk på Skrivebord igen.</string>
<string name="session_vr_install_failed">VR-modulet blev ikke installeret. Kontroller forbindelsen, og tryk på Spil SteamVR igen.</string>
<string name="session_ended_named">%1$s stoppede uventet. Del logs med en fejlrapport, eller prøv igen.</string>
<string name="session_ended_program">Programmet stoppede uventet. Del logs med en fejlrapport, eller prøv igen.</string>
<string name="session_ended_desktop">Skrivebordet stoppede uventet. Del logs med en fejlrapport, eller prøv igen.</string>
@@ -2466,6 +2455,16 @@
<string name="hand_camera_placement">Kameraplacering</string>
<string name="hand_camera_placement_hint">Tjek, at kameraet kan se dig, før du spiller.</string>
<string name="hand_camera_check">Tjek</string>
<string name="vr_module_title">VR-modul</string>
<string name="vr_module_hint">SteamVR på briller, Quest og Cardboard samt håndsporing med kameraet · %1$s download</string>
<string name="vr_module_installed">Installeret · %1$s</string>
<string name="vr_module_downloading">Downloader VR-modulet</string>
<string name="vr_module_failed">VR-modulet kunne ikke downloades. Kontroller forbindelsen, og prøv igen.</string>
<string name="vr_module_removing">Fjerner VR-modulet</string>
<string name="vr_module_busy">Stop sessionen, før du fjerner VR-modulet.</string>
<string name="vr_module_remove_failed">VR-modulet kunne ikke fjernes</string>
<string name="vr_module_uninstall_title">Afinstaller VR-modulet?</string>
<string name="vr_module_uninstall_text">SteamVR og håndsporing med kameraet holder op med at virke, indtil du downloader det igen. Selve SteamVR, spil og indstillinger bevares.</string>
<string name="hand_camera_check_title">Kameraplacering</string>
<string name="hand_camera_how_rear">Stil enheden op 1,5 til 2 meter væk i cirka brysthøjde med bagkameraet vendt mod dig. Træd så ind i billedet med hænderne ude.</string>
<string name="hand_camera_how_front">Stil enheden op 1,5 til 2 meter væk i cirka brysthøjde med skærmen vendt mod dig. Træd så ind i billedet med hænderne ude.</string>
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Einmal klicken</string>
<string name="gesture_hold_delay">Halteverzögerung</string>
<string name="gesture_double_delay">Doppeltipp-Intervall</string>
<string name="gesture_scope_game">Gilt für das aktuelle Spiel</string>
<string name="gesture_scope_default">Standardeinstellungen für alle Spiele</string>
<string name="gesture_reset">Ursprüngliche Gesteneinstellungen wiederherstellen</string>
<string name="gesture_cancel">Abbrechen</string>
<string name="gesture_action">Aktion auswählen</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Mausrad</string>
<string name="gesture_threshold">Gestenschwelle: %1$d px</string>
<string name="gesture_debug">Erkannte Geste anzeigen</string>
<string name="gesture_editor_subtitle">Maus- und Tastatursteuerung nach Gesten geordnet</string>
<string name="gesture_tab_one">Ein Finger</string>
<string name="gesture_tab_two">Zwei Finger</string>
<string name="gesture_tab_three">Drei Finger</string>
<string name="gesture_tab_options">Optionen</string>
<string name="gesture_one_hint">Wähle die Aktion für Tippen, Ziehen oder Halten.</string>
<string name="gesture_two_hint">Kameraschwenks, Zoom und sekundäre Klicks anpassen.</string>
<string name="gesture_three_hint">Zusätzliche Kurzbefehle griffbereit halten.</string>
<string name="gesture_options_hint">Bewegung, Erkennung und visuelle Rückmeldung anpassen.</string>
<string name="gesture_defaults">Standardwerte</string>
<string name="gesture_game">Dieses Spiel</string>
<string name="gesture_use_defaults">Standardeinstellungen verwenden</string>
<string name="gesture_inheriting">Standardeinstellungen werden verwendet. Änderungen erstellen eine Spielanpassung.</string>
<string name="gesture_save_changes">Änderungen speichern</string>
<string name="gesture_close">Schließen</string>
<string name="gesture_action_label">AKTION</string>
<string name="gesture_action_for">Für %1$s</string>
<string name="gesture_search_actions">Mausaktionen oder Tastaturtasten suchen</string>
<string name="gesture_clear_search">Suche leeren</string>
<string name="gesture_all_actions">Alle</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Ziffernblock</string>
<string name="gesture_group_symbols">Symbole</string>
<string name="gesture_movement">Mausbewegung</string>
<string name="gesture_movement_hint">Direktes Ziehen folgt der Berührungsposition. Relatives Ziehen folgt der Fingerbewegung und eignet sich für Spiele, die die Maus zentrieren.</string>
<string name="gesture_middle_relative_hint">Schwenks mit mittlerer Maustaste folgen immer der Fingerbewegung, um Zeigersprünge zu vermeiden.</string>
<string name="gesture_direct">Direkt</string>
<string name="gesture_relative">Relativ</string>
<string name="gesture_recognition">Gestenerkennung</string>
<string name="gesture_threshold_hint">Bewege die Finger um diese Strecke, bevor eine Zwei- oder Dreifingergeste als Schwenken oder Zoomen gilt.</string>
<string name="gesture_feedback">Visuelle Rückmeldung</string>
<string name="gesture_mouse_behavior">Verhalten der Maustaste</string>
<string name="gesture_ms">%1$d ms</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s über FEX</string>
<string name="app_fex_not_ready">Dieses Programm ist für %1$s-PCs gebaut und läuft über FEX, das noch nicht eingerichtet ist. Öffne Steam einmal, während du angemeldet bist: DroidDeck lässt es FEX und die nötige Steam Linux Runtime herunterladen, danach lässt sich das Programm öffnen.</string>
<string name="session_end_program">Dieses Programm</string>
<string name="session_end_steam_download">Der Steam-Client konnte nicht heruntergeladen werden. Dein Netzwerk oder VPN blockiert möglicherweise die Server von Steam; prüfe die Verbindung und versuche es erneut.</string>
<string name="session_end_steam_setup">Der Steam-Client konnte nicht eingerichtet werden. Prüfe die Verbindung und den freien Speicher und versuche es erneut.</string>
<string name="session_end_fex_missing">%1$s ist für x86-PCs gebaut und läuft über FEX, das noch nicht installiert ist. Starte Steam einmal, während du angemeldet bist, und lass es FEX und die Steam Linux Runtime herunterladen. Starte es danach erneut.</string>
<string name="add_app_flatpak_hint">Eine Flathub-App-ID oder ihre Flathub-Seite. Flatpak wird zuerst eingerichtet, falls noch nicht geschehen.</string>
<string name="add_app_file">Datei</string>
@@ -579,16 +577,6 @@
<string name="drawer_prev_page">Vorherige Seite</string>
<string name="drawer_next_page">Nächste Seite</string>
<string name="drawer_hud">Leistungs-HUD</string>
<string name="drawer_hud_fps">Bildrate</string>
<string name="drawer_hud_battery">Akkutemperatur</string>
<string name="drawer_hud_graph">Leistungsdiagramm</string>
<string name="drawer_hud_color">Farbe</string>
<string name="drawer_hud_color_mint">Minze</string>
<string name="drawer_hud_color_white">Weiß</string>
<string name="drawer_hud_color_sky">Himmelblau</string>
<string name="drawer_hud_color_amber">Bernstein</string>
<string name="drawer_hud_color_violet">Violett</string>
<string name="drawer_hud_opacity">Deckkraft</string>
<string name="drawer_scaling">Skalierungsmodus</string>
<string name="drawer_scaling_sharpness">Skalierungsschärfe</string>
<string name="drawer_effects">Bildschirmeffekte</string>
@@ -1013,6 +1001,7 @@
<string name="games_empty">Spiele, die du in Steam installierst, erscheinen hier nach der Sitzung.</string>
<string name="games_play_steam">Mit Steam spielen</string>
<string name="games_play_steamvr">Mit SteamVR spielen</string>
<string name="games_install_play_steamvr">Herunterladen und mit SteamVR spielen</string>
<string name="games_launch_settings">Starteinstellungen</string>
<string name="games_launch">Starten</string>
<string name="games_files">Spieldateien</string>
@@ -1039,9 +1028,6 @@
<string name="games_added">Hinzugefügtes Spiel</string>
<string name="games_custom">Eigenes Spiel</string>
<string name="games_store_storage">Spielspeicher</string>
<string name="games_uninstall_body">Löscht seine Dateien und entfernt es aus Steam.</string>
<string name="games_remove_title">%1$s entfernen?</string>
<string name="games_remove_body">Löscht die Verknüpfung. Die Spieldateien bleiben.</string>
<string name="gpu_auto_hint">Das beste Paar für diese GPU, stets aktuell</string>
<string name="gpu_manual_hint">Wähle selbst ein Paar</string>
<string name="gpu_checking">Suche nach Treibern…</string>
@@ -1346,9 +1332,12 @@
<string name="session_proton_downloading_mb">Proton Experimental (ARM64) wird heruntergeladen · %1$d von %2$d MB</string>
<string name="session_proton_checking">Proton Experimental (ARM64) wird geprüft</string>
<string name="session_proton_unpacking">Proton Experimental (ARM64) wird entpackt</string>
<string name="session_vr_downloading">VR-Modul wird heruntergeladen</string>
<string name="session_vr_downloading_mb">VR-Modul wird heruntergeladen · %1$d von %2$d MB</string>
<string name="session_runtime_catalog_unreachable">Der Katalog der Laufzeiten war nicht erreichbar. Prüfe die Verbindung und tippe erneut auf Spielen.</string>
<string name="session_runtime_install_failed">Die Linux-Laufzeit wurde nicht installiert. Prüfe sie unter Einrichtung und tippe erneut auf Spielen.</string>
<string name="session_desktop_install_failed">Der Desktop wurde nicht installiert (%1$s). Prüfe die Verbindung und tippe erneut auf Desktop.</string>
<string name="session_vr_install_failed">Das VR-Modul wurde nicht installiert. Prüfe die Verbindung und tippe erneut auf Mit SteamVR spielen.</string>
<string name="session_ended_named">%1$s wurde unerwartet beendet. Teile die Protokolle mit einem Fehlerbericht oder versuche es erneut.</string>
<string name="session_ended_program">Das Programm wurde unerwartet beendet. Teile die Protokolle mit einem Fehlerbericht oder versuche es erneut.</string>
<string name="session_ended_desktop">Der Desktop wurde unerwartet beendet. Teile die Protokolle mit einem Fehlerbericht oder versuche es erneut.</string>
@@ -2452,6 +2441,16 @@
<string name="hand_camera_placement">Kameraplatzierung</string>
<string name="hand_camera_placement_hint">Prüfe, ob die Kamera dich sehen kann, bevor du spielst.</string>
<string name="hand_camera_check">Prüfen</string>
<string name="vr_module_title">VR-Modul</string>
<string name="vr_module_hint">SteamVR auf Brillen, Quest und Cardboard sowie Handtracking per Kamera · %1$s Download</string>
<string name="vr_module_installed">Installiert · %1$s</string>
<string name="vr_module_downloading">VR-Modul wird heruntergeladen</string>
<string name="vr_module_failed">Das VR-Modul konnte nicht heruntergeladen werden. Prüfe die Verbindung und versuche es erneut.</string>
<string name="vr_module_removing">VR-Modul wird entfernt</string>
<string name="vr_module_busy">Beende die Sitzung, bevor du das VR-Modul entfernst.</string>
<string name="vr_module_remove_failed">Das VR-Modul konnte nicht entfernt werden</string>
<string name="vr_module_uninstall_title">VR-Modul deinstallieren?</string>
<string name="vr_module_uninstall_text">SteamVR und Handtracking per Kamera funktionieren erst wieder, wenn du es erneut herunterlädst. SteamVR selbst, Spiele und Einstellungen bleiben erhalten.</string>
<string name="hand_camera_check_title">Kameraplatzierung</string>
<string name="hand_camera_how_rear">Stell das Gerät etwa 1,5 bis 2 Meter entfernt in Brusthöhe auf, mit der Rückkamera zu dir gerichtet. Tritt dann mit ausgestreckten Händen ins Bild.</string>
<string name="hand_camera_how_front">Stell das Gerät etwa 1,5 bis 2 Meter entfernt in Brusthöhe auf, mit dem Bildschirm zu dir gerichtet. Tritt dann mit ausgestreckten Händen ins Bild.</string>
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Ένα κλικ</string>
<string name="gesture_hold_delay">Καθυστέρηση κρατήματος</string>
<string name="gesture_double_delay">Διάστημα διπλού πατήματος</string>
<string name="gesture_scope_game">Ισχύει για το τρέχον παιχνίδι</string>
<string name="gesture_scope_default">Προεπιλεγμένες ρυθμίσεις για όλα τα παιχνίδια</string>
<string name="gesture_reset">Επαναφορά αρχικών ρυθμίσεων χειρονομιών</string>
<string name="gesture_cancel">Ακύρωση</string>
<string name="gesture_action">Επιλογή ενέργειας</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Ροδέλα ποντικιού</string>
<string name="gesture_threshold">Όριο χειρονομιών: %1$d px</string>
<string name="gesture_debug">Εμφάνιση αναγνωρισμένης χειρονομίας</string>
<string name="gesture_editor_subtitle">Χειρισμοί ποντικιού και πληκτρολογίου ανά χειρονομία</string>
<string name="gesture_tab_one">Ένα δάχτυλο</string>
<string name="gesture_tab_two">Δύο δάχτυλα</string>
<string name="gesture_tab_three">Τρία δάχτυλα</string>
<string name="gesture_tab_options">Επιλογές</string>
<string name="gesture_one_hint">Επιλέξτε την ενέργεια για πάτημα, σύρσιμο ή κράτημα.</string>
<string name="gesture_two_hint">Ρυθμίστε τη μετατόπιση της κάμερας, τη μεγέθυνση και τα δευτερεύοντα κλικ.</string>
<string name="gesture_three_hint">Κρατήστε επιπλέον συντομεύσεις κοντά σας.</string>
<string name="gesture_options_hint">Ρυθμίστε την κίνηση, την αναγνώριση και την οπτική ανάδραση.</string>
<string name="gesture_defaults">Προεπιλογές</string>
<string name="gesture_game">Αυτό το παιχνίδι</string>
<string name="gesture_use_defaults">Χρήση προεπιλογών</string>
<string name="gesture_inheriting">Χρησιμοποιούνται οι προεπιλογές. Η επεξεργασία δημιουργεί ρυθμίσεις για αυτό το παιχνίδι.</string>
<string name="gesture_save_changes">Αποθήκευση αλλαγών</string>
<string name="gesture_close">Κλείσιμο</string>
<string name="gesture_action_label">ΕΝΕΡΓΕΙΑ</string>
<string name="gesture_action_for">Για %1$s</string>
<string name="gesture_search_actions">Αναζήτηση ενεργειών ποντικιού ή πλήκτρων</string>
<string name="gesture_clear_search">Εκκαθάριση αναζήτησης</string>
<string name="gesture_all_actions">Όλα</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Αριθμητικό πληκτρολόγιο</string>
<string name="gesture_group_symbols">Σύμβολα</string>
<string name="gesture_movement">Κίνηση ποντικιού</string>
<string name="gesture_movement_hint">Το άμεσο σύρσιμο ακολουθεί τη θέση αφής. Το σχετικό σύρσιμο ακολουθεί την κίνηση του δαχτύλου, χρήσιμο όταν το παιχνίδι επαναφέρει το ποντίκι στο κέντρο.</string>
<string name="gesture_middle_relative_hint">Η μετατόπιση με το μεσαίο κουμπί ακολουθεί πάντα την κίνηση του δαχτύλου για να αποφεύγονται άλματα του δείκτη.</string>
<string name="gesture_direct">Απευθείας</string>
<string name="gesture_relative">Σχετική</string>
<string name="gesture_recognition">Αναγνώριση χειρονομιών</string>
<string name="gesture_threshold_hint">Κινηθείτε κατά αυτή την απόσταση προτού μια χειρονομία με δύο ή τρία δάχτυλα γίνει μετατόπιση ή μεγέθυνση.</string>
<string name="gesture_feedback">Οπτική ανάδραση</string>
<string name="gesture_mouse_behavior">Συμπεριφορά κουμπιού ποντικιού</string>
<string name="gesture_ms">%1$d ms</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s μέσω FEX</string>
<string name="app_fex_not_ready">Αυτό το πρόγραμμα είναι χτισμένο για υπολογιστές %1$s και εκτελείται μέσω FEX, το οποίο δεν έχει ρυθμιστεί ακόμα. Ανοίξτε το Steam μία φορά ενώ είστε συνδεδεμένοι: το DroidDeck κάνει λήψη του FEX και του Steam Linux Runtime που χρειάζεται, και μετά αυτό ανοίγει.</string>
<string name="session_end_program">Αυτό το πρόγραμμα</string>
<string name="session_end_steam_download">Δεν ήταν δυνατή η λήψη του πελάτη Steam. Το δίκτυο ή το VPN σας ίσως αποκλείει τους διακομιστές του Steam· ελέγξτε τη σύνδεση και δοκιμάστε ξανά.</string>
<string name="session_end_steam_setup">Δεν ήταν δυνατή η ρύθμιση του πελάτη Steam. Ελέγξτε τη σύνδεση και τον ελεύθερο χώρο αποθήκευσης και δοκιμάστε ξανά.</string>
<string name="session_end_fex_missing">Το %1$s είναι χτισμένο για υπολογιστές x86 και εκτελείται μέσω FEX, το οποίο δεν έχει εγκατασταθεί ακόμα. Εκκινήστε το Steam μία φορά ενώ είστε συνδεδεμένοι και αφήστε το να κάνει λήψη του FEX και του Steam Linux Runtime, μετά εκκινήστε αυτό ξανά.</string>
<string name="add_app_flatpak_hint">Ένα ID εφαρμογής Flathub ή η σελίδα της στο Flathub. Το Flatpak ρυθμίζεται πρώτα, αν δεν έχει ρυθμιστεί ακόμα.</string>
<string name="add_app_file">Αρχείο</string>
@@ -582,16 +580,6 @@
<string name="drawer_prev_page">Προηγούμενη σελίδα</string>
<string name="drawer_next_page">Επόμενη σελίδα</string>
<string name="drawer_hud">HUD απόδοσης</string>
<string name="drawer_hud_fps">Ρυθμός καρέ</string>
<string name="drawer_hud_battery">Θερμοκρασία μπαταρίας</string>
<string name="drawer_hud_graph">Γράφημα απόδοσης</string>
<string name="drawer_hud_color">Χρώμα</string>
<string name="drawer_hud_color_mint">Μέντα</string>
<string name="drawer_hud_color_white">Λευκό</string>
<string name="drawer_hud_color_sky">Γαλάζιο</string>
<string name="drawer_hud_color_amber">Κεχριμπαρί</string>
<string name="drawer_hud_color_violet">Βιολετί</string>
<string name="drawer_hud_opacity">Διαφάνεια</string>
<string name="drawer_scaling">Λειτουργία κλιμάκωσης</string>
<string name="drawer_scaling_sharpness">Οξύτητα κλιμάκωσης</string>
<string name="drawer_effects">Εφέ οθόνης</string>
@@ -1024,6 +1012,7 @@
<string name="games_empty">Τα παιχνίδια που εγκαθιστάτε στο Steam εμφανίζονται εδώ μετά τη συνεδρία.</string>
<string name="games_play_steam">Αναπαραγωγή Steam</string>
<string name="games_play_steamvr">Αναπαραγωγή SteamVR</string>
<string name="games_install_play_steamvr">Λήψη και αναπαραγωγή SteamVR</string>
<string name="games_launch_settings">Ρυθμίσεις εκκίνησης</string>
<string name="games_launch">Εκκίνηση</string>
<string name="games_files">Αρχεία παιχνιδιού</string>
@@ -1050,9 +1039,6 @@
<string name="games_added">Προστέθηκε παιχνίδι</string>
<string name="games_custom">Προσαρμοσμένο παιχνίδι</string>
<string name="games_store_storage">Αποθήκευση παιχνιδιών</string>
<string name="games_uninstall_body">Διαγράφει τα αρχεία του και το αφαιρεί από το Steam.</string>
<string name="games_remove_title">Αφαίρεση του %1$s;</string>
<string name="games_remove_body">Διαγράφει τη συντόμευση. Τα αρχεία του παιχνιδιού παραμένουν.</string>
<string name="gpu_auto_hint">Το καλύτερο ζεύγος για αυτή τη GPU, ενημερωμένο</string>
<string name="gpu_manual_hint">Επιλέξτε ένα ζεύγος εσείς</string>
<string name="gpu_checking">Έλεγχος για προγράμματα οδήγησης…</string>
@@ -1357,9 +1343,12 @@
<string name="session_proton_downloading_mb">λήψη του Proton Experimental (ARM64) · %1$d από %2$d MB</string>
<string name="session_proton_checking">έλεγχος του Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">αποσυμπίεση του Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">λήψη της μονάδας VR</string>
<string name="session_vr_downloading_mb">λήψη της μονάδας VR · %1$d από %2$d MB</string>
<string name="session_runtime_catalog_unreachable">Δεν ήταν δυνατή η πρόσβαση στον κατάλογο runtime. Ελέγξτε τη σύνδεση και πατήστε ξανά Αναπαραγωγή.</string>
<string name="session_runtime_install_failed">Το Linux runtime δεν εγκαταστάθηκε. Ελέγξτε το runtime στη Ρύθμιση και πατήστε ξανά Αναπαραγωγή.</string>
<string name="session_desktop_install_failed">Η επιφάνεια εργασίας δεν εγκαταστάθηκε (%1$s). Ελέγξτε τη σύνδεση και πατήστε ξανά Επιφάνεια εργασίας.</string>
<string name="session_vr_install_failed">Η μονάδα VR δεν εγκαταστάθηκε. Ελέγξτε τη σύνδεση και πατήστε ξανά Αναπαραγωγή SteamVR.</string>
<string name="session_ended_named">%1$s διακόπηκε αναπάντεχα. Κοινοποιήστε τα αρχεία καταγραφής με μια αναφορά σφάλματος, ή δοκιμάστε ξανά.</string>
<string name="session_ended_program">Το πρόγραμμα διακόπηκε αναπάντεχα. Κοινοποιήστε τα αρχεία καταγραφής με μια αναφορά σφάλματος, ή δοκιμάστε ξανά.</string>
<string name="session_ended_desktop">Η επιφάνεια εργασίας διακόπηκε αναπάντεχα. Κοινοποιήστε τα αρχεία καταγραφής με μια αναφορά σφάλματος, ή δοκιμάστε ξανά.</string>
@@ -2466,6 +2455,16 @@
<string name="hand_camera_placement">Τοποθέτηση κάμερας</string>
<string name="hand_camera_placement_hint">Βεβαιωθείτε ότι η κάμερα σάς βλέπει πριν παίξετε.</string>
<string name="hand_camera_check">Έλεγχος</string>
<string name="vr_module_title">Μονάδα VR</string>
<string name="vr_module_hint">SteamVR σε γυαλιά, Quest και Cardboard, καθώς και παρακολούθηση χεριών με την κάμερα · λήψη %1$s</string>
<string name="vr_module_installed">Εγκατεστημένη · %1$s</string>
<string name="vr_module_downloading">Λήψη της μονάδας VR</string>
<string name="vr_module_failed">Δεν ήταν δυνατή η λήψη της μονάδας VR. Ελέγξτε τη σύνδεση και δοκιμάστε ξανά.</string>
<string name="vr_module_removing">Κατάργηση της μονάδας VR</string>
<string name="vr_module_busy">Σταματήστε την περίοδο λειτουργίας πριν καταργήσετε τη μονάδα VR.</string>
<string name="vr_module_remove_failed">Δεν ήταν δυνατή η κατάργηση της μονάδας VR</string>
<string name="vr_module_uninstall_title">Κατάργηση εγκατάστασης της μονάδας VR;</string>
<string name="vr_module_uninstall_text">Το SteamVR και η παρακολούθηση χεριών με την κάμερα σταματούν να λειτουργούν μέχρι να τη κατεβάσετε ξανά. Το ίδιο το SteamVR, τα παιχνίδια και οι ρυθμίσεις διατηρούνται.</string>
<string name="hand_camera_check_title">Τοποθέτηση κάμερας</string>
<string name="hand_camera_how_rear">Στήστε τη συσκευή όρθια σε απόσταση 1,5 έως 2 μέτρων, περίπου στο ύψος του στήθους, με την πίσω κάμερα στραμμένη προς εσάς. Έπειτα μπείτε στο οπτικό πεδίο με τα χέρια τεντωμένα.</string>
<string name="hand_camera_how_front">Στήστε τη συσκευή όρθια σε απόσταση 1,5 έως 2 μέτρων, περίπου στο ύψος του στήθους, με την οθόνη στραμμένη προς εσάς. Έπειτα μπείτε στο οπτικό πεδίο με τα χέρια τεντωμένα.</string>
+12
View File
@@ -18,6 +18,8 @@
<string name="gesture_click_once">Hacer clic una vez</string>
<string name="gesture_hold_delay">Tiempo de pulsación sostenida</string>
<string name="gesture_double_delay">Intervalo de doble toque</string>
<string name="gesture_scope_game">Se aplica al juego actual</string>
<string name="gesture_scope_default">Ajustes predeterminados para todos los juegos</string>
<string name="gesture_reset">Restablecer los ajustes originales de gestos</string>
<string name="gesture_cancel">Cancelar</string>
<string name="gesture_action">Elegir acción</string>
@@ -35,16 +37,23 @@
<string name="gesture_scroll">Rueda del ratón</string>
<string name="gesture_threshold">Umbral de gestos: %1$d px</string>
<string name="gesture_debug">Mostrar gesto reconocido</string>
<string name="gesture_editor_subtitle">Controles de ratón y teclado organizados por gesto</string>
<string name="gesture_tab_one">Un dedo</string>
<string name="gesture_tab_two">Dos dedos</string>
<string name="gesture_tab_three">Tres dedos</string>
<string name="gesture_tab_options">Opciones</string>
<string name="gesture_one_hint">Elige qué debe hacer un toque, arrastre o pulsación sostenida.</string>
<string name="gesture_two_hint">Configura el movimiento de cámara, el zoom y los clics secundarios.</string>
<string name="gesture_three_hint">Ten más accesos directos al alcance de la mano.</string>
<string name="gesture_options_hint">Ajusta el movimiento, el reconocimiento y la respuesta visual.</string>
<string name="gesture_defaults">Predeterminados</string>
<string name="gesture_game">Este juego</string>
<string name="gesture_use_defaults">Usar valores predeterminados</string>
<string name="gesture_inheriting">Se usan los ajustes predeterminados. Al editarlos, se crean ajustes específicos del juego.</string>
<string name="gesture_save_changes">Guardar cambios</string>
<string name="gesture_close">Cerrar</string>
<string name="gesture_action_label">ACCIÓN</string>
<string name="gesture_action_for">Para %1$s</string>
<string name="gesture_search_actions">Buscar acciones de ratón o teclas del teclado</string>
<string name="gesture_clear_search">Borrar búsqueda</string>
<string name="gesture_all_actions">Todas</string>
@@ -59,9 +68,12 @@
<string name="gesture_group_numpad">Teclado numérico</string>
<string name="gesture_group_symbols">Símbolos</string>
<string name="gesture_movement">Movimiento del ratón</string>
<string name="gesture_movement_hint">El arrastre directo sigue la posición del toque. El relativo sigue el movimiento del dedo y resulta útil cuando un juego centra el ratón.</string>
<string name="gesture_middle_relative_hint">El desplazamiento con el botón central siempre sigue el movimiento del dedo para evitar saltos del cursor.</string>
<string name="gesture_direct">Directo</string>
<string name="gesture_relative">Relativo</string>
<string name="gesture_recognition">Reconocimiento de gestos</string>
<string name="gesture_threshold_hint">Recorre esta distancia antes de que un gesto con dos o tres dedos se convierta en desplazamiento o zoom.</string>
<string name="gesture_feedback">Respuesta visual</string>
<string name="gesture_mouse_behavior">Comportamiento del botón del ratón</string>
<string name="gesture_ms">%1$d ms</string>
+14 -15
View File
@@ -176,8 +176,6 @@
<string name="app_fex_x86">%1$s con FEX</string>
<string name="app_fex_not_ready">Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá.</string>
<string name="session_end_program">Este programa</string>
<string name="session_end_steam_download">No se ha podido descargar el cliente de Steam. Puede que tu red o VPN esté bloqueando los servidores de Steam; comprueba la conexión y vuelve a intentarlo.</string>
<string name="session_end_steam_setup">No se ha podido configurar el cliente de Steam. Comprueba la conexión y el espacio libre y vuelve a intentarlo.</string>
<string name="session_end_fex_missing">%1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo.</string>
<string name="add_app_flatpak_hint">Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está.</string>
<string name="add_app_file">Archivo</string>
@@ -581,16 +579,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_hud_fps">Tasa de fotogramas</string>
<string name="drawer_hud_battery">Temperatura de la batería</string>
<string name="drawer_hud_graph">Gráfico de rendimiento</string>
<string name="drawer_hud_color">Color</string>
<string name="drawer_hud_color_mint">Menta</string>
<string name="drawer_hud_color_white">Blanco</string>
<string name="drawer_hud_color_sky">Celeste</string>
<string name="drawer_hud_color_amber">Ámbar</string>
<string name="drawer_hud_color_violet">Violeta</string>
<string name="drawer_hud_opacity">Opacidad</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>
@@ -1027,6 +1015,7 @@
<string name="games_empty">Los juegos que instales en Steam aparecen aquí después de la sesión.</string>
<string name="games_play_steam">Jugar en Steam</string>
<string name="games_play_steamvr">Jugar en SteamVR</string>
<string name="games_install_play_steamvr">Descargar y jugar en SteamVR</string>
<string name="games_launch_settings">Ajustes de inicio</string>
<string name="games_launch">Iniciar</string>
<string name="games_files">Archivos del juego</string>
@@ -1053,9 +1042,6 @@
<string name="games_added">Juego añadido</string>
<string name="games_custom">Juego personalizado</string>
<string name="games_store_storage">Almacenamiento de juegos</string>
<string name="games_uninstall_body">Elimina sus archivos y lo quita de Steam.</string>
<string name="games_remove_title">¿Quitar %1$s?</string>
<string name="games_remove_body">Elimina su acceso directo. Los archivos del juego se conservan.</string>
<string name="gpu_auto_hint">El mejor par para esta GPU, siempre actualizado</string>
<string name="gpu_manual_hint">Elige un par manualmente</string>
<string name="gpu_checking">Buscando controladores…</string>
@@ -1367,9 +1353,12 @@
<string name="session_proton_downloading_mb">descargando Proton Experimental (ARM64) · %1$d de %2$d MB</string>
<string name="session_proton_checking">comprobando Proton Experimental (ARM64)</string>
<string name="session_proton_unpacking">descomprimiendo Proton Experimental (ARM64)</string>
<string name="session_vr_downloading">descargando el módulo de RV</string>
<string name="session_vr_downloading_mb">descargando el módulo de RV · %1$d de %2$d MB</string>
<string name="session_runtime_catalog_unreachable">No se pudo acceder al catálogo del runtime. Revisa la conexión y vuelve a pulsar Jugar.</string>
<string name="session_runtime_install_failed">El runtime de Linux no se instaló. Revisa el runtime en Configuración y vuelve a pulsar Jugar.</string>
<string name="session_desktop_install_failed">El escritorio no se instaló (%1$s). Revisa la conexión y pulsa Escritorio de nuevo.</string>
<string name="session_vr_install_failed">El módulo de RV no se instaló. Revisa la conexión y pulsa Jugar en SteamVR de nuevo.</string>
<string name="session_ended_named">%1$s se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
<string name="session_ended_program">El programa se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
<string name="session_ended_desktop">El escritorio se detuvo inesperadamente. Comparte los registros en un informe de error o inténtalo de nuevo.</string>
@@ -2474,6 +2463,16 @@
<string name="hand_camera_placement">Posición de la cámara</string>
<string name="hand_camera_placement_hint">Comprueba que la cámara pueda verte antes de jugar.</string>
<string name="hand_camera_check">Comprobar</string>
<string name="vr_module_title">Módulo de RV</string>
<string name="vr_module_hint">SteamVR en gafas, Quest y Cardboard, además de seguimiento de manos con la cámara · descarga de %1$s</string>
<string name="vr_module_installed">Instalado · %1$s</string>
<string name="vr_module_downloading">Descargando el módulo de RV</string>
<string name="vr_module_failed">No se ha podido descargar el módulo de RV. Revisa la conexión y vuelve a intentarlo.</string>
<string name="vr_module_removing">Quitando el módulo de RV</string>
<string name="vr_module_busy">Detén la sesión antes de quitar el módulo de RV.</string>
<string name="vr_module_remove_failed">No se ha podido quitar el módulo de RV</string>
<string name="vr_module_uninstall_title">¿Desinstalar el módulo de RV?</string>
<string name="vr_module_uninstall_text">SteamVR y el seguimiento de manos con la cámara dejarán de funcionar hasta que lo vuelvas a descargar. SteamVR, los juegos y los ajustes se conservan.</string>
<string name="hand_camera_check_title">Posición de la cámara</string>
<string name="hand_camera_how_rear">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la cámara trasera hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
<string name="hand_camera_how_front">Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la pantalla hacia ti. Luego entra en el encuadre con las manos extendidas.</string>
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more