mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 11:47:14 -04:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb44931973 | ||
|
|
59cac100e8 | ||
|
|
371e87fa31 | ||
|
|
e9a7ef5f44 | ||
|
|
40461caf4a | ||
|
|
1e877fa602 | ||
|
|
906009a33a | ||
|
|
08559a02f8 | ||
|
|
1d93626453 | ||
|
|
9d0989374b | ||
|
|
3278afaa1a | ||
|
|
bf221ffbbd | ||
|
|
c636eea355 | ||
|
|
ffaa906cd1 | ||
|
|
d61af961c1 | ||
|
|
bc01b77752 | ||
|
|
a1b01a04a8 | ||
|
|
b44235c495 | ||
|
|
b80d5db176 | ||
|
|
d3c0d258b7 | ||
|
|
9dcdbf245a | ||
|
|
369e218afb | ||
|
|
119d24bf6b | ||
|
|
2a905510d5 | ||
|
|
da29919b5a | ||
|
|
05608ac4d4 | ||
|
|
135b84a501 | ||
|
|
e7cbba37d1 | ||
|
|
d9bb5e15b0 | ||
|
|
46cdc32cf1 | ||
|
|
387572e733 | ||
|
|
9f46c7a50b | ||
|
|
0607bb0b3a | ||
|
|
b89d87cbde | ||
|
|
06e06b9673 | ||
|
|
b896e7a3b9 | ||
|
|
c91daa9147 | ||
|
|
31ea194f3d | ||
|
|
472bb28c88 | ||
|
|
eaa4a7a047 | ||
|
|
4fa467c800 | ||
|
|
beb96e6f0c |
No files matched your search
@@ -57,6 +57,22 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Version bumps are isolated
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
|
||||
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
|
||||
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
|
||||
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
|
||||
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
|
||||
echo "::error::versionCode/versionName changes must be isolated in their own PR"
|
||||
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Merge onto main, or say what conflicts
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
@@ -157,7 +173,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -179,6 +195,10 @@ jobs:
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
|
||||
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
|
||||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||||
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
|
||||
mkdir -p "$D/usr/local/bin"
|
||||
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
|
||||
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
|
||||
@@ -230,6 +250,26 @@ jobs:
|
||||
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
|
||||
rm -rf mango-pkgs
|
||||
ls -l "$M"
|
||||
# The Windows components installer's tools (tools/msitools/packages.txt).
|
||||
T="$D/usr/local/lib/droiddeck-msitools"
|
||||
mkdir -p "$T" msi-pkgs
|
||||
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
|
||||
[ -n "$url" ] || continue
|
||||
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
|
||||
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
|
||||
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
|
||||
rm msi-pkgs/pkg.tar.xz
|
||||
done
|
||||
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
|
||||
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
|
||||
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
|
||||
done
|
||||
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
|
||||
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
|
||||
done
|
||||
rm -rf msi-pkgs
|
||||
ls -l "$T"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
|
||||
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
|
||||
@@ -343,11 +383,23 @@ jobs:
|
||||
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
|
||||
fi
|
||||
SESSION=$(syms "$D/libblsession.so")
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap; do
|
||||
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
|
||||
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
|
||||
done
|
||||
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
|
||||
if grep -qx drmIoctl <<<"$SESSION"; then
|
||||
echo "::error::libblsession.so exports drmIoctl"; exit 1
|
||||
fi
|
||||
SSBS=$(syms "$D/libssbs.so")
|
||||
for sym in sigaction signal bsd_signal sysv_signal; do
|
||||
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
|
||||
done
|
||||
test -f "$D/usr/local/bin/droiddeck-session"
|
||||
test -f "$D/usr/local/bin/droiddeck-proton-extra"
|
||||
test -f "$D/usr/local/bin/droiddeck-msi-install"
|
||||
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
|
||||
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Cache the CMake build tree
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts.
|
||||
|
||||
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
|
||||
|
||||
|
||||
+4
-2
@@ -79,8 +79,8 @@ android {
|
||||
// file out of its own data directory, and this app's entire job is to exec a rootfs:
|
||||
// proot, gamescope, the Steam client and everything it launches all live under files/.
|
||||
targetSdkVersion 28
|
||||
versionCode 10
|
||||
versionName "0.3.0"
|
||||
versionCode 11
|
||||
versionName "0.3.1"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
@@ -190,6 +190,8 @@ dependencies {
|
||||
// The runtime tarball is a zstd-compressed tar.
|
||||
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
|
||||
implementation 'org.apache.commons:commons-compress:1.27.1'
|
||||
// Bannerlator's Windows components come as .tar.xz (WinComponents).
|
||||
implementation 'org.tukaani:xz:1.10'
|
||||
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
|
||||
// on-screen pad are Views underneath it, which is what they are in every Compose app.
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -71,6 +71,10 @@
|
||||
<action android:name="android.intent.action.CREATE_SHORTCUT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
|
||||
@@ -123,6 +127,14 @@
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@style/AppTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".agent.AgentStartActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".SessionActivity"
|
||||
android:exported="false"
|
||||
@@ -173,5 +185,11 @@
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
<provider
|
||||
android:name=".agent.AgentBridgeProvider"
|
||||
android:authorities="${applicationId}.agent"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:grantUriPermissions="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -44,6 +44,11 @@ add_subdirectory(waylandcomp)
|
||||
add_library(sdnative SHARED sdnative/sdnative.c)
|
||||
target_link_libraries(sdnative log)
|
||||
|
||||
# The device info window's Vulkan section: the phone's own driver, asked once through the system
|
||||
# loader (opened with dlopen, so a phone without Vulkan still loads the app).
|
||||
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
|
||||
target_link_libraries(deviceinfo dl)
|
||||
|
||||
# Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its
|
||||
# prebuilt libtermux.so with a redundant libstdc++.so dependency.
|
||||
add_library(termux SHARED termux/termux.c)
|
||||
@@ -0,0 +1,112 @@
|
||||
// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one
|
||||
// instance on the system loader, the first physical device's properties, a few limits and the
|
||||
// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open.
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#define VK_NO_PROTOTYPES
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0)
|
||||
|
||||
static const char *const EXTENSIONS[] = {
|
||||
"VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color",
|
||||
"VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing",
|
||||
"VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query",
|
||||
};
|
||||
|
||||
static const char *type_name(VkPhysicalDeviceType type) {
|
||||
switch (type) {
|
||||
case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU";
|
||||
case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU";
|
||||
default: return "other";
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) {
|
||||
static char out[4096];
|
||||
size_t used = 0;
|
||||
out[0] = 0;
|
||||
void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); }
|
||||
PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr");
|
||||
PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL;
|
||||
PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL;
|
||||
if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
|
||||
uint32_t loader = VK_API_VERSION_1_0;
|
||||
if (instanceVersion) instanceVersion(&loader);
|
||||
LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader));
|
||||
|
||||
VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info",
|
||||
.apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 };
|
||||
VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app };
|
||||
VkInstance instance = VK_NULL_HANDLE;
|
||||
if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
|
||||
|
||||
#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name)
|
||||
GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties);
|
||||
GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties);
|
||||
GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures);
|
||||
uint32_t count = 1;
|
||||
VkPhysicalDevice device = VK_NULL_HANDLE;
|
||||
VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED;
|
||||
if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) {
|
||||
LINE("error=no Vulkan device\n");
|
||||
} else {
|
||||
VkPhysicalDeviceProperties p;
|
||||
vkGetPhysicalDeviceProperties(device, &p);
|
||||
LINE("device=%s\n", p.deviceName);
|
||||
LINE("type=%s\n", type_name(p.deviceType));
|
||||
LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion));
|
||||
// Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION.
|
||||
LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff);
|
||||
LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID);
|
||||
LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D);
|
||||
|
||||
// Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties).
|
||||
uint32_t extCount = 0;
|
||||
if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL);
|
||||
static VkExtensionProperties exts[512];
|
||||
if (extCount > 512) extCount = 512;
|
||||
if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts);
|
||||
int driverProps = p.apiVersion >= VK_API_VERSION_1_2;
|
||||
for (uint32_t i = 0; i < extCount; i++)
|
||||
if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1;
|
||||
if (driverProps && vkGetPhysicalDeviceProperties2) {
|
||||
VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
|
||||
VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver };
|
||||
vkGetPhysicalDeviceProperties2(device, &p2);
|
||||
LINE("driver_name=%s\n", driver.driverName);
|
||||
LINE("driver_info=%s\n", driver.driverInfo);
|
||||
LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor,
|
||||
driver.conformanceVersion.subminor, driver.conformanceVersion.patch);
|
||||
}
|
||||
LINE("extension_count=%u\n", extCount);
|
||||
for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) {
|
||||
int has = 0;
|
||||
for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]);
|
||||
LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no");
|
||||
}
|
||||
if (vkGetPhysicalDeviceFeatures) {
|
||||
VkPhysicalDeviceFeatures f;
|
||||
vkGetPhysicalDeviceFeatures(device, &f);
|
||||
LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n",
|
||||
f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no");
|
||||
}
|
||||
if (vkGetPhysicalDeviceMemoryProperties) {
|
||||
VkPhysicalDeviceMemoryProperties m;
|
||||
vkGetPhysicalDeviceMemoryProperties(device, &m);
|
||||
VkDeviceSize largest = 0;
|
||||
for (uint32_t i = 0; i < m.memoryHeapCount; i++)
|
||||
if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size;
|
||||
LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20));
|
||||
}
|
||||
}
|
||||
if (vkDestroyInstance) vkDestroyInstance(instance, NULL);
|
||||
dlclose(lib);
|
||||
return (*env)->NewStringUTF(env, out);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import com.droiddeck.launcher.ui.ComponentsPage
|
||||
import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
@@ -64,6 +65,9 @@ import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
import com.droiddeck.launcher.frontend.CoverArt
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.frontend.GameFiles
|
||||
import com.droiddeck.launcher.frontend.GameFileSync
|
||||
import com.droiddeck.launcher.frontend.GameLaunchIntent
|
||||
import com.droiddeck.launcher.ui.DroidDeckTheme
|
||||
import com.droiddeck.launcher.ui.RomsDialog
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
@@ -110,6 +114,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
@@ -150,7 +155,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var forceFullscreen by mutableStateOf(true)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
@@ -304,13 +308,13 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolutionCap by mutableStateOf(1080)
|
||||
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
@@ -323,6 +327,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var renderer by mutableStateOf("vulkan")
|
||||
private var gameStorage by mutableStateOf("")
|
||||
private var storageDiagnostics by mutableStateOf(false)
|
||||
private var skipPreallocation by mutableStateOf(false)
|
||||
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
|
||||
@@ -398,10 +403,15 @@ class MainActivity : ComponentActivity() {
|
||||
private fun pickGameExport(game: Library.SteamGame?) {
|
||||
onSavePicked = { folder ->
|
||||
saveAction(getString(R.string.game_frontend_files)) {
|
||||
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
|
||||
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
|
||||
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
|
||||
getString(R.string.game_file_exported, folder.path)
|
||||
val saved = if (game != null) GameFiles.export(folder, game)
|
||||
else {
|
||||
GameFileSync.enable(this, folder)
|
||||
folder
|
||||
}
|
||||
ui.post {
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
}
|
||||
getString(R.string.game_file_exported, saved.path)
|
||||
}
|
||||
}
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
|
||||
@@ -415,10 +425,10 @@ class MainActivity : ComponentActivity() {
|
||||
shortcutLibraryScanning = true
|
||||
} else shortcutLibraryScanning = false
|
||||
pendingGameLink = null
|
||||
if (request.action == Intent.ACTION_VIEW) {
|
||||
if (GameLaunchIntent.accepts(request.action)) {
|
||||
val copy = Intent(request)
|
||||
Thread({
|
||||
val id = com.droiddeck.launcher.frontend.GameFiles.readIntent(this, copy)
|
||||
val id = GameLaunchIntent.read(this, copy)
|
||||
ui.post {
|
||||
if (intent === request && !isDestroyed) {
|
||||
pendingGameLink = id
|
||||
@@ -431,7 +441,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchGame(game: Library.SteamGame): Boolean {
|
||||
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
|
||||
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
|
||||
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
@@ -446,10 +456,12 @@ class MainActivity : ComponentActivity() {
|
||||
return false
|
||||
}
|
||||
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
|
||||
return true
|
||||
}
|
||||
return startSession(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL,
|
||||
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
|
||||
}
|
||||
@@ -461,10 +473,14 @@ class MainActivity : ComponentActivity() {
|
||||
if (game == null) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else if (launchGame(game)) {
|
||||
} else if (launchGame(game, returnHome = true)) {
|
||||
pendingGameLink = null
|
||||
intent.data = null
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,10 +492,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
readGameIntent(intent)
|
||||
gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this)
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
theme = SessionPrefs.theme(this)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
@@ -489,7 +506,7 @@ class MainActivity : ComponentActivity() {
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme) {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
@@ -516,6 +533,7 @@ class MainActivity : ComponentActivity() {
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
appScale = appScale,
|
||||
isHomeApp = homeAppSelected,
|
||||
homeScreenEnabled = homeScreenEnabled,
|
||||
defaultHomeLabel = defaultHomeLabel,
|
||||
@@ -553,7 +571,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
onStopGameFileSync = {
|
||||
Thread({
|
||||
com.droiddeck.launcher.frontend.GameFileSync.disable(this)
|
||||
GameFileSync.disable(this)
|
||||
ui.post { gameSyncFolder = null }
|
||||
}, "game-file-stop-sync").start()
|
||||
},
|
||||
@@ -621,13 +639,10 @@ class MainActivity : ComponentActivity() {
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
Thread({
|
||||
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
|
||||
ui.post {
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
}, "share-logs").start()
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
},
|
||||
onClearLogs = {
|
||||
Thread({
|
||||
@@ -645,6 +660,10 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onAppScale = { percent ->
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
},
|
||||
onLauncherFullscreen = { on ->
|
||||
SessionPrefs.setLauncherFullscreen(this, on)
|
||||
launcherFullscreen = on
|
||||
@@ -1117,7 +1136,8 @@ class MainActivity : ComponentActivity() {
|
||||
private fun ModeSettingsHost(mode: String) {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
mode = mode, resolution = resolution,
|
||||
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
gpuDrivers = drivers.summary(),
|
||||
@@ -1129,17 +1149,19 @@ class MainActivity : ComponentActivity() {
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
clientDirectAudio = clientDirectAudio,
|
||||
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null,
|
||||
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
|
||||
mic = if (mode == SessionService.MODE_STEAM) mic else null,
|
||||
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
storageOptions = storageOptions,
|
||||
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
|
||||
skipPreallocation = mode == SessionService.MODE_STEAM && skipPreallocation,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
forceSsbs = forceSsbs,
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
@@ -1172,9 +1194,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
},
|
||||
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
@@ -1190,7 +1210,6 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
|
||||
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
|
||||
onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on },
|
||||
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
|
||||
onMic = { on ->
|
||||
SessionPrefs.setMicEnabled(this, on)
|
||||
@@ -1210,7 +1229,12 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
|
||||
storageDiagnostics = on
|
||||
},
|
||||
onSkipPreallocation = { on ->
|
||||
SessionPrefs.setSkipLibraryPreallocation(this, on)
|
||||
skipPreallocation = on
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
|
||||
onSyncBackend = { id ->
|
||||
SessionPrefs.setSyncBackend(this, id)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
@@ -1218,6 +1242,7 @@ class MainActivity : ComponentActivity() {
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamDeckMode = { on ->
|
||||
SessionPrefs.setSteamDeckMode(this, on)
|
||||
steamDeckMode = on
|
||||
@@ -1329,19 +1354,19 @@ class MainActivity : ComponentActivity() {
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
customResolution = SessionPrefs.customResolution(this, mode)
|
||||
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
@@ -1353,13 +1378,13 @@ class MainActivity : ComponentActivity() {
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
|
||||
forceFullscreen = SessionPrefs.forceFullscreen(this)
|
||||
stretch16x9 = SessionPrefs.stretch16x9(this)
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
renderer = SessionPrefs.desktopRenderer(this)
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
skipPreallocation = SessionPrefs.skipLibraryPreallocation(this)
|
||||
settingsMode = mode
|
||||
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
@@ -82,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.DRAWER_PAGES
|
||||
import com.droiddeck.launcher.ui.SessionDrawerPage
|
||||
import com.droiddeck.launcher.ui.sessionDrawerPages
|
||||
import com.droiddeck.launcher.ui.step
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
@@ -158,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
|
||||
private var cursorSerial = 0
|
||||
private var clientHidesCursor = false
|
||||
/**
|
||||
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
|
||||
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
|
||||
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
|
||||
* at the screen edge, and so did the camera (#153).
|
||||
*/
|
||||
private var pointerLocked = false
|
||||
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
|
||||
uiHandler.post {
|
||||
if (pointerLocked == locked) return@post
|
||||
pointerLocked = locked
|
||||
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
|
||||
updatePointerCapture()
|
||||
}
|
||||
}
|
||||
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
|
||||
private var cursorHotX by mutableStateOf(0)
|
||||
private var cursorHotY by mutableStateOf(0)
|
||||
@@ -205,13 +223,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var closeAtOnce = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerPage by mutableIntStateOf(0)
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
private var effects by mutableStateOf(ScreenEffects.OFF)
|
||||
@@ -225,7 +242,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var onScreenButtonsVisible by mutableStateOf(false)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
@@ -267,10 +284,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
|
||||
}
|
||||
@@ -321,7 +340,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// below) and the session starts when it is in.
|
||||
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
|
||||
|
||||
val root = FrameLayout(this)
|
||||
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
|
||||
// the root sees every one of them whichever view that is.
|
||||
val root = object : FrameLayout(this) {
|
||||
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
|
||||
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
|
||||
super.dispatchPointerCaptureChanged(hasCapture)
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
@@ -384,6 +412,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.setPointerLockListener(pointerLockListener)
|
||||
SessionEvents.markReadyIfPossible()
|
||||
SessionState.endListener = endListener
|
||||
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
|
||||
@@ -414,6 +443,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// this full-screen host and remain as a white veil after the drawer closes.
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
// The drawer, the PC keyboard and picture in picture take the mouse back.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
@@ -469,19 +500,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
isHomeApp = isHomeApp,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onUpscaler = { m ->
|
||||
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
|
||||
WaylandCompositor.nativeSetUpscaler(m)
|
||||
@@ -528,7 +557,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
@@ -713,28 +741,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
Thread({
|
||||
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
|
||||
.onFailure { Log.w(TAG, "could not package current session logs", it) }
|
||||
.getOrNull()
|
||||
uiHandler.post {
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}, "share-session-logs").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
effects = SessionPrefs.screenEffects(this)
|
||||
@@ -756,7 +778,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
|
||||
@@ -1012,38 +1033,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
private fun outputSize(): Pair<Int, Int> {
|
||||
val bounds = panelBounds()
|
||||
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
|
||||
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
|
||||
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
|
||||
// square display draws for the frame it was made for and cuts the sides off itself.
|
||||
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
|
||||
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
|
||||
// compositor letterboxes onto a squarer panel.
|
||||
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
|
||||
// fill it.
|
||||
val aspect = when (SessionPrefs.shapeMode(this)) {
|
||||
SessionPrefs.SHAPE_WIDE -> 16f / 9f
|
||||
SessionPrefs.SHAPE_EXACT -> panelW / panelH
|
||||
else -> maxOf(panelW / panelH, 16f / 9f)
|
||||
}
|
||||
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
|
||||
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
|
||||
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
|
||||
// the cap or lift it to the panel.
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
// A custom resolution is taken as given; the compositor fits it to the panel.
|
||||
SessionPrefs.customResolution(this, mode)?.let { return it }
|
||||
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
|
||||
// unless the user chose a resolution for the mode.
|
||||
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
|
||||
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
|
||||
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
|
||||
else SessionPrefs.resolutionCap(this, mode)
|
||||
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
|
||||
val width = (height * aspect).toInt()
|
||||
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
|
||||
return Pair(width and 1.inv(), height and 1.inv())
|
||||
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
|
||||
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
|
||||
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
|
||||
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
|
||||
else SessionPrefs.resolutionChoice(this, mode, panel)
|
||||
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1138,6 +1136,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// the request landed in this instance's onNewIntent and went with its finish().
|
||||
SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
setIntent(next)
|
||||
recreate()
|
||||
return@runOnUiThread
|
||||
@@ -1261,7 +1260,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (drawerOpen) {
|
||||
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
|
||||
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
|
||||
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
|
||||
releaseDrawerDirection()
|
||||
}
|
||||
return true
|
||||
@@ -1348,6 +1348,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
|
||||
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
|
||||
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
|
||||
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
|
||||
@@ -1698,6 +1702,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
|
||||
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
|
||||
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
|
||||
val d = InputDevice.getDevice(id)
|
||||
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
|
||||
}
|
||||
|
||||
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
|
||||
private fun updatePointerCapture() {
|
||||
if (!::surfaceView.isInitialized) return
|
||||
val view = window.decorView
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
|
||||
if (want == view.hasPointerCapture()) return
|
||||
if (want) view.requestPointerCapture() else view.releasePointerCapture()
|
||||
}
|
||||
|
||||
/**
|
||||
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
|
||||
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
|
||||
*/
|
||||
private fun onCapturedPointer(event: MotionEvent): Boolean {
|
||||
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
var dx = event.x
|
||||
var dy = event.y
|
||||
for (i in 0 until event.historySize) {
|
||||
dx += event.getHistoricalX(i)
|
||||
dy += event.getHistoricalY(i)
|
||||
}
|
||||
WaylandCompositor.sendPointerDelta(dx, dy)
|
||||
}
|
||||
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
|
||||
val button = when (event.actionButton) {
|
||||
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
|
||||
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
|
||||
else -> PointerGestures.BTN_LEFT
|
||||
}
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
|
||||
}
|
||||
MotionEvent.ACTION_SCROLL -> {
|
||||
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
|
||||
}
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
|
||||
/** Which controller the session has, so a pad report says what was plugged in and when. */
|
||||
private fun logInputDevice(what: String, deviceId: Int) {
|
||||
@@ -1714,6 +1767,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
if (pipUi) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
return
|
||||
@@ -1729,6 +1783,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
@@ -1843,6 +1898,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
recreate()
|
||||
return
|
||||
}
|
||||
// Home/notification resumes must retain the external frontend's return destination.
|
||||
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
|
||||
setIntent(intent)
|
||||
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
|
||||
handleHomeGuideIntent(intent)
|
||||
@@ -1867,6 +1924,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePointerCapture()
|
||||
if (!pipUi) sessionClipboard.start()
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
@@ -1886,6 +1944,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePointerCapture()
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
@@ -1906,6 +1965,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
goFullscreen()
|
||||
refreshClipboard()
|
||||
}
|
||||
// Android drops a capture with the focus; a lock still held takes it again on return.
|
||||
updatePointerCapture()
|
||||
}
|
||||
|
||||
/** The keyboard/trackpad presentation can own focus instead of the main display. */
|
||||
@@ -1933,6 +1994,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
|
||||
private val firstFrameListener = Runnable {
|
||||
SessionEvents.firstFrame()
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
|
||||
runOnUiThread {
|
||||
loading.visible = false
|
||||
hud.start()
|
||||
@@ -1955,6 +2017,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
WaylandCompositor.clearPointerLockListener(pointerLockListener)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -1977,6 +2040,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
* off, it just closes.
|
||||
*/
|
||||
override fun finish() {
|
||||
if (isFinishing) return
|
||||
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
|
||||
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
|
||||
if (!leaving) {
|
||||
@@ -1987,6 +2051,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
super.finish()
|
||||
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
|
||||
com.droiddeck.launcher.ui.QuitFlood.take()
|
||||
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
if (quitFlooded) overridePendingTransition(0, 0)
|
||||
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
|
||||
}
|
||||
@@ -2002,6 +2072,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_RETURN_HOME = "returnHome"
|
||||
/** How long the leaving flood may take before the session closes without it. */
|
||||
private const val LEAVE_TIMEOUT_MS = 1_500L
|
||||
private const val TAG = "SessionActivity"
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,19 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AppUiPrefs {
|
||||
const val DEFAULT_SCALE = 100
|
||||
val scales = listOf(75, 85, 100, 115, 125, 150)
|
||||
|
||||
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
|
||||
|
||||
fun scale(context: Context): Int = normalizeScale(
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
|
||||
)
|
||||
|
||||
fun setScale(context: Context, percent: Int) {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
|
||||
.putInt("scale", normalizeScale(percent)).apply()
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,12 @@ object DeviceReport {
|
||||
h("Display")
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
k("Shape setting", SessionPrefs.shapeMode(context))
|
||||
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
|
||||
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
|
||||
val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice)
|
||||
k(label, "$choice (${size.first}x${size.second})")
|
||||
}
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
@@ -163,7 +166,6 @@ object DeviceReport {
|
||||
k("proot fast path", SessionPrefs.prootFastPath(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
|
||||
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
|
||||
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
|
||||
k("Microphone", SessionPrefs.micEnabled(context))
|
||||
@@ -175,6 +177,7 @@ object DeviceReport {
|
||||
SessionPrefs.GAME_STORAGE_OFF -> "internal only"
|
||||
else -> g
|
||||
})
|
||||
k("Skip space reservation", SessionPrefs.skipLibraryPreallocation(context))
|
||||
|
||||
h("Android process limits")
|
||||
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
|
||||
|
||||
@@ -25,7 +25,7 @@ object GameEnvironment {
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
@@ -37,8 +37,12 @@ object GameEnvironment {
|
||||
"DOTNET_EnableWriteXorExecute" to "0",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
// Steam settings' Force SSBS: droiddeck-game-env preloads libssbs.so into the game's Wine.
|
||||
if (forceSsbs) put(FORCE_SSBS, "1")
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
|
||||
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -190,6 +190,14 @@ object LogRedactor {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump whenever a pattern is added or tightened. A session folder records the version it was
|
||||
* scrubbed under (SessionArtifacts.SCRUBBED_TREE_MARKER), and a share copies a file as it is
|
||||
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
|
||||
* out.
|
||||
*/
|
||||
const val RULES_VERSION = 1
|
||||
|
||||
/** [src]'s lines, scrubbed, to [out]. */
|
||||
fun scrubTo(src: java.io.File, out: java.io.Writer) {
|
||||
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
|
||||
|
||||
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
|
||||
}
|
||||
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(initialDir = initialDir)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
?.let { File(it) }?.takeIf { it.isDirectory }
|
||||
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
|
||||
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
|
||||
FileManagerScreen(
|
||||
pickMode = true,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Best-effort detection of the Windows redistributables a game bundles, by scanning its install
|
||||
* folder for the redist installers Steam/Epic/GOG ship (`_CommonRedist/`, `_Redist/`,
|
||||
* `Redistributables/`, loose `vcredist_x64.exe`, …) and mapping them to entries in our component
|
||||
* catalog (WinComponents, Bannerlator's components.json `name` keys). The result drives the
|
||||
* Recommended part of a game's Windows components; the user still switches each one on.
|
||||
* Bannerlator's detector, unchanged but for this note and its package.
|
||||
*
|
||||
* Wine-aware: keeps the redists that actually matter under Wine/DXVK (VC++ runtimes, .NET, PhysX,
|
||||
* OpenAL, XACT, XNA, GFWL) and SKIPS the ones DXVK/VKD3D already provide (DX12 Agility SDK) or that
|
||||
* aren't our components (anti-cheat, Rockstar/Social-Club/launcher setups). Never throws; returns an
|
||||
* empty list on anything unexpected. Pure filesystem — no network, no Android deps (unit-testable).
|
||||
*/
|
||||
object DependencyDetector {
|
||||
|
||||
/** How a recommendation was found — drives the UI's "recommended vs optional" split. */
|
||||
enum class Kind {
|
||||
/** The game bundles the redist installer (in _CommonRedist etc.) — meant to be installed. */
|
||||
BUNDLED,
|
||||
/** A runtime DLL the game ships loose — usually already works from the game folder; install
|
||||
* only if the game misbehaves. */
|
||||
SHIPPED,
|
||||
/** Steam installs it with the game from Steamworks Shared ([SteamRedists]). */
|
||||
STEAM,
|
||||
}
|
||||
|
||||
/** A recommended component: the catalog [componentName] to install, a display [label], the on-disk
|
||||
* redist that triggered it ([reason]), and [kind] (bundled installer vs shipped DLL). */
|
||||
data class Recommendation(val componentName: String, val label: String, val reason: String, val kind: Kind = Kind.BUNDLED)
|
||||
|
||||
private const val MAX_DEPTH = 5
|
||||
private const val MAX_ENTRIES = 8000 // cost guard against giant game trees
|
||||
|
||||
/** A loose VC++ installer with no year in its path — resolved to "latest" only if no specific
|
||||
* vcredistYYYY was found anywhere (else it's just a duplicate of the versioned one). */
|
||||
private const val VCREDIST_LOOSE = "__vcredist_loose"
|
||||
|
||||
/** Directory names that are redist containers worth descending into. */
|
||||
private val REDIST_DIR_RE = Regex(
|
||||
"""(?i)^(_?commonredist|_?redist(ributables)?|__?installer|vcredist|vc_redist|directx|direct_x|physx|openal|dotnet(fx)?|\.net|xna|xact|redist|support)$"""
|
||||
)
|
||||
|
||||
/** Skip whole subtrees that are never our components. */
|
||||
private val SKIP_DIR_RE = Regex("""(?i)^(d3d12.*|.*agility.*|dxvk|vkd3d|battleye|easyanticheat|eac|anticheat)$""")
|
||||
|
||||
private val YEAR_TO_VCREDIST = mapOf(
|
||||
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010",
|
||||
"2012" to "vcredist2012", "2013" to "vcredist2013", "2015" to "vcredist2015",
|
||||
"2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
|
||||
)
|
||||
|
||||
private val LABELS = mapOf(
|
||||
"vcredist2005" to "Visual C++ 2005", "vcredist2008" to "Visual C++ 2008",
|
||||
"vcredist2010" to "Visual C++ 2010", "vcredist2012" to "Visual C++ 2012",
|
||||
"vcredist2013" to "Visual C++ 2013", "vcredist2015" to "Visual C++ 2015-2017",
|
||||
"vcredist2019" to "Visual C++ 2019", "vcredist2022" to "Visual C++ 2015-2022",
|
||||
"physx" to "NVIDIA PhysX", "oalinst" to "OpenAL", "xact" to "DirectX XACT audio",
|
||||
"xact_x64" to "DirectX XACT audio (x64)", "xna40" to "XNA Framework 4.0",
|
||||
"xna31" to "XNA Framework 3.1", "d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
|
||||
"XLiveRedist" to "Games for Windows Live", "dotnet40" to ".NET Framework 4.0",
|
||||
"dotnet45" to ".NET Framework 4.5", "dotnet452" to ".NET Framework 4.5.2",
|
||||
"dotnet46" to ".NET Framework 4.6", "dotnet472" to ".NET Framework 4.7.2",
|
||||
"dotnet48" to ".NET Framework 4.8", "dotnet35" to ".NET Framework 3.5",
|
||||
"dotnetcoredesktop6" to ".NET Desktop 6", "dotnetcoredesktop7" to ".NET Desktop 7",
|
||||
"dotnetcoredesktop8" to ".NET Desktop 8",
|
||||
)
|
||||
|
||||
/** Preferred install order (runtimes others may depend on first). */
|
||||
private val INSTALL_ORDER = listOf(
|
||||
"vcredist2005", "vcredist2008", "vcredist2010", "vcredist2012", "vcredist2013",
|
||||
"vcredist2015", "vcredist2019", "vcredist2022",
|
||||
"dotnet35", "dotnet40", "dotnet45", "dotnet452", "dotnet46", "dotnet472", "dotnet48",
|
||||
"dotnetcoredesktop6", "dotnetcoredesktop7", "dotnetcoredesktop8",
|
||||
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
|
||||
)
|
||||
|
||||
/**
|
||||
* Detect from the .exe the user picked, resolving the game root first: walk up from the exe's
|
||||
* folder (bounded) to the nearest ancestor that holds a redist container (`_CommonRedist`, …) at
|
||||
* its top level, and scan from there — so a nested exe (`…/Game/bin/x.exe`) still finds a root-level
|
||||
* `_CommonRedist`, without over-reaching into a shared `steamapps/common` (which has no redist
|
||||
* container of its own). Falls back to scanning the exe's own folder.
|
||||
*/
|
||||
fun detectForExe(exeFile: File): List<Recommendation> {
|
||||
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
|
||||
var d: File? = start
|
||||
var depth = 0
|
||||
while (d != null && depth < 4) {
|
||||
val hasRedistContainer = try {
|
||||
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
|
||||
} catch (_: Throwable) { false }
|
||||
if (hasRedistContainer) return detect(d)
|
||||
d = d.parentFile
|
||||
depth++
|
||||
}
|
||||
return detect(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan [gameDir] and return the recommended components, de-duplicated (first reason wins), in a
|
||||
* stable install-friendly order. Empty when nothing is detected or [gameDir] isn't a directory.
|
||||
*/
|
||||
fun detect(gameDir: File): List<Recommendation> {
|
||||
if (!gameDir.isDirectory) return emptyList()
|
||||
val hits = LinkedHashMap<String, Recommendation>() // componentName -> first recommendation
|
||||
var visited = 0
|
||||
|
||||
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
|
||||
val name = f.name.lowercase(Locale.US)
|
||||
val path = f.path.replace('\\', '/').lowercase(Locale.US)
|
||||
// Bundled = in a redist container or an installer (exe/msi/cab); shipped = a loose runtime DLL.
|
||||
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
|
||||
Kind.BUNDLED else Kind.SHIPPED
|
||||
for (comp in classify(path, name, isDir)) {
|
||||
val existing = hits[comp]
|
||||
if (existing == null) {
|
||||
hits[comp] = Recommendation(comp, LABELS[comp] ?: comp, relativeReason(gameDir, f), kind)
|
||||
} else if (existing.kind == Kind.SHIPPED && kind == Kind.BUNDLED) {
|
||||
hits[comp] = existing.copy(kind = Kind.BUNDLED) // a bundled installer outranks a shipped DLL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun walk(dir: File, depth: Int, insideRedist: Boolean) {
|
||||
if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return
|
||||
val entries = dir.listFiles() ?: return
|
||||
for (f in entries) {
|
||||
if (visited++ > MAX_ENTRIES) return
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) continue
|
||||
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
|
||||
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
|
||||
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
|
||||
// tree a few levels deep so runtime libraries the game ships (in bin/, etc.) are seen.
|
||||
if (isRedistDir || depth < 3) walk(f, depth + 1, isRedistDir)
|
||||
} else {
|
||||
// Classify installers + runtime libraries (.dll/.exe/.msi) anywhere in the scanned
|
||||
// tree; other files are ignored. Recommendations are advisory, so cast a wide net.
|
||||
val ext = lower.substringAfterLast('.', "")
|
||||
if (insideRedist || ext == "dll" || ext == "exe" || ext == "msi") consider(f, isDir = false, insideRedist = insideRedist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
walk(gameDir, 0, false)
|
||||
resolveLooseVcredist(hits)
|
||||
hits.values.sortedBy { INSTALL_ORDER.indexOf(it.componentName).let { i -> if (i < 0) 999 else i } }
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
|
||||
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
|
||||
val loose = hits.remove(VCREDIST_LOOSE) ?: return
|
||||
val hasSpecific = hits.keys.any { it in YEAR_TO_VCREDIST.values }
|
||||
if (!hasSpecific) hits["vcredist2022"] = loose.copy(componentName = "vcredist2022", label = LABELS["vcredist2022"]!!)
|
||||
}
|
||||
|
||||
private fun relativeReason(gameDir: File, f: File): String {
|
||||
val g = gameDir.path.replace('\\', '/')
|
||||
val p = f.path.replace('\\', '/')
|
||||
return if (p.startsWith("$g/")) p.substring(g.length + 1) else f.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a redist file/dir (its full lowercased [path], leaf [name], and whether it [isDir]) to
|
||||
* catalog component name(s). Empty for things we deliberately skip.
|
||||
*/
|
||||
private fun classify(path: String, name: String, isDir: Boolean): List<String> {
|
||||
// Hard skips first — DX12 is provided by DXVK/VKD3D; these aren't our components.
|
||||
if (path.contains("d3d12") || path.contains("agility") ||
|
||||
path.contains("battleye") || path.contains("anticheat") || path.contains("easyanticheat") ||
|
||||
name.contains("socialclub") || name.contains("social-club") ||
|
||||
name.contains("rockstar") || name == "setup.exe"
|
||||
) return emptyList()
|
||||
|
||||
// Visual C++ runtimes — most important. Year comes from the path context.
|
||||
if (path.contains("vcredist") || path.contains("vc_redist") || name.startsWith("vcredist") || name.startsWith("vc_redist")) {
|
||||
val year = YEAR_TO_VCREDIST.keys.firstOrNull { path.contains(it) }
|
||||
return when {
|
||||
year != null -> listOf(YEAR_TO_VCREDIST.getValue(year))
|
||||
isDir -> emptyList() // bare "vcredist" container — wait for the year subfolder / installer
|
||||
else -> listOf(VCREDIST_LOOSE) // loose installer, no year → resolved post-walk
|
||||
}
|
||||
}
|
||||
// VC++ runtime DLL the game ships (msvcp140.dll → 2015-2022, msvcp120.dll → 2013, …).
|
||||
VCRUNTIME_DLL.entries.firstOrNull { name.startsWith(it.key) }?.let { return listOf(it.value) }
|
||||
if (path.contains("openal") || name.startsWith("oalinst") || name == "openal32.dll" || name == "wrap_oal.dll") return listOf("oalinst")
|
||||
if (path.contains("physx") || name.startsWith("physx")) return listOf("physx")
|
||||
if (path.contains("xact") || name.startsWith("xactengine") || name.startsWith("x3daudio")) return listOf("xact", "xact_x64")
|
||||
// Games for Windows Live — the redist folder, OR a GFWL library the game ships (xlive.dll,
|
||||
// Codemasters' dbxLive32/64, gfwl*): GFWL titles need XLiveRedist to run even when they don't
|
||||
// bundle the redist itself.
|
||||
if (path.contains("xliveredist") || name.contains("xlive") || name.startsWith("gfwl") || name.startsWith("dbxlive")) {
|
||||
return listOf("XLiveRedist")
|
||||
}
|
||||
if (path.contains("xna")) return listOf(if (path.contains("3.1") || path.contains("3_1") || path.contains("v31")) "xna31" else "xna40")
|
||||
// .NET — Framework and Core/Desktop runtimes.
|
||||
if (path.contains("dotnet") || path.contains(".net") || name.startsWith("ndp") || name.startsWith("dotnetfx") || name.contains("windowsdesktop-runtime")) {
|
||||
val comp = dotnetComponent("$path $name")
|
||||
return when {
|
||||
comp != null -> listOf(comp)
|
||||
isDir -> emptyList() // bare ".NET" container — wait for the versioned installer
|
||||
else -> listOf("dotnet48") // unlabeled .NET Framework installer file → sensible default
|
||||
}
|
||||
}
|
||||
// DirectX 11 helper DLLs the game ships (d3dx11_43.dll).
|
||||
if (D3DX11_RE.matches(name)) return listOf("d3dx11")
|
||||
// DirectX 9 helper DLLs / d3dcompiler / June-2010 DX redist (DXVK provides core d3d9 but NOT
|
||||
// these helper DLLs).
|
||||
if (name.startsWith("dxsetup") || name.startsWith("dxwebsetup") ||
|
||||
D3DX9_RE.matches(name) || D3DCOMPILER_RE.matches(name) ||
|
||||
(path.contains("directx") && name.endsWith(".exe"))
|
||||
) return listOf("d3dx9")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/** Runtime DLL prefix → the VC++ redistributable that provides it (checked with startsWith). */
|
||||
private val VCRUNTIME_DLL = linkedMapOf(
|
||||
"vcruntime140" to "vcredist2022", "msvcp140" to "vcredist2022", "msvcr140" to "vcredist2022", "concrt140" to "vcredist2022",
|
||||
"msvcp120" to "vcredist2013", "msvcr120" to "vcredist2013",
|
||||
"msvcp110" to "vcredist2012", "msvcr110" to "vcredist2012",
|
||||
"msvcp100" to "vcredist2010", "msvcr100" to "vcredist2010",
|
||||
"msvcp90" to "vcredist2008", "msvcr90" to "vcredist2008",
|
||||
"msvcp80" to "vcredist2005", "msvcr80" to "vcredist2005",
|
||||
)
|
||||
private val D3DX9_RE = Regex("""d3dx9_\d+\.dll""")
|
||||
private val D3DX11_RE = Regex("""d3dx11_\d+\.dll""")
|
||||
private val D3DCOMPILER_RE = Regex("""d3dcompiler_\d+\.dll""")
|
||||
|
||||
/** Resolve a .NET component from installer/path text, or null when no version is discernible. */
|
||||
private fun dotnetComponent(s: String): String? = when {
|
||||
s.contains("desktop-runtime-8") || Regex("""\b8\.\d""").containsMatchIn(s) -> "dotnetcoredesktop8"
|
||||
s.contains("desktop-runtime-7") || Regex("""\b7\.\d""").containsMatchIn(s) -> "dotnetcoredesktop7"
|
||||
s.contains("desktop-runtime-6") || Regex("""\b6\.\d""").containsMatchIn(s) -> "dotnetcoredesktop6"
|
||||
s.contains("ndp48") || s.contains("fx48") || s.contains("4.8") -> "dotnet48"
|
||||
s.contains("ndp472") || s.contains("4.7.2") -> "dotnet472"
|
||||
s.contains("ndp46") || s.contains("4.6") -> "dotnet46"
|
||||
s.contains("ndp452") || s.contains("4.5.2") -> "dotnet452"
|
||||
s.contains("dotnetfx45") || s.contains("ndp45") || s.contains("4.5") -> "dotnet45"
|
||||
s.contains("dotnetfx40") || s.contains("ndp40") || s.contains("4.0") -> "dotnet40"
|
||||
s.contains("dotnetfx35") || s.contains("3.5") -> "dotnet35"
|
||||
else -> null // no version signal (e.g. a bare "DotNet" container dir)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import java.io.InputStream
|
||||
|
||||
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
|
||||
object GameFiles {
|
||||
const val EXTENSION = "droiddeck"
|
||||
const val EXTENSION = "steam"
|
||||
private const val MAX_BYTES = 128
|
||||
private const val INVENTORY = ".droiddeck-sync.json"
|
||||
private const val FORMAT = "droiddeck-game-files-v1"
|
||||
@@ -46,35 +46,49 @@ object GameFiles {
|
||||
}
|
||||
if (count > MAX_BYTES) return null
|
||||
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
|
||||
return GameLaunchLink.parse(text) ?: text.takeIf(GameLaunchLink::validId)
|
||||
return text.takeIf(GameLaunchLink::validId)
|
||||
}
|
||||
|
||||
fun filename(game: Library.SteamGame): String {
|
||||
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
|
||||
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
|
||||
.trim().trim('.').ifBlank { "Game" }
|
||||
val points = sanitized.codePoints().limit(40).toArray()
|
||||
val title = String(points, 0, points.size)
|
||||
return "$title (${game.gameIdString}).$EXTENSION"
|
||||
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
|
||||
return "$title$suffix.$EXTENSION"
|
||||
}
|
||||
|
||||
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
|
||||
private fun contents(id: String): String {
|
||||
require(GameLaunchLink.validId(id))
|
||||
return id
|
||||
}
|
||||
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
|
||||
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(folder, game, filename(game))
|
||||
|
||||
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { "Folder is unavailable" }
|
||||
val target = child(folder, filename(game))
|
||||
val text = contents(game.gameIdString)
|
||||
val target = exportTarget(folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
|
||||
if (!target.exists()) write(target, text)
|
||||
exportArt(folder, game)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(folder, name)
|
||||
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
|
||||
child(folder, filename(game, disambiguate = true))
|
||||
} else target
|
||||
}
|
||||
|
||||
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
|
||||
private fun exportArt(folder: File, game: Library.SteamGame) {
|
||||
val art = listOfNotNull(game.icon, game.art).firstOrNull {
|
||||
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
|
||||
} ?: return
|
||||
val title = filename(game).substringBeforeLast(" (${game.gameIdString})")
|
||||
val title = filename(game).substringBeforeLast(".")
|
||||
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
|
||||
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
|
||||
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
|
||||
@@ -95,12 +109,20 @@ object GameFiles {
|
||||
record.getJSONObject("games")
|
||||
} else JSONObject()
|
||||
val next = JSONObject()
|
||||
val exports = games.distinctBy { it.gameId }.map { game ->
|
||||
val target = child(folder, filename(game))
|
||||
val unique = games.distinctBy { it.gameId }
|
||||
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
|
||||
val exports = unique.map { game ->
|
||||
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
|
||||
val preferred = child(folder, name)
|
||||
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(folder, game, name)
|
||||
val text = contents(game.gameIdString)
|
||||
val existed = target.exists()
|
||||
// Keep an edited export without letting it block the rest of the library sync.
|
||||
val file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
|
||||
!matches(target, contents(game.gameIdString))) target else export(folder, game)
|
||||
next.put(file.name, game.gameIdString)
|
||||
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(folder, game, target.name)
|
||||
if (!existed || owned) next.put(file.name, game.gameIdString)
|
||||
file
|
||||
}
|
||||
// Write new exports before pruning; a failed write must not remove old launch files.
|
||||
@@ -119,6 +141,16 @@ object GameFiles {
|
||||
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
|
||||
}
|
||||
|
||||
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
|
||||
if (record.optString(file.name) == id) return true
|
||||
if (!file.exists()) return false
|
||||
return record.keys().asSequence().any { name ->
|
||||
record.optString(name) == id && child(folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun child(folder: File, name: String): File {
|
||||
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
|
||||
val file = File(folder, name)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
object GameLaunchIntent {
|
||||
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
|
||||
const val EXTRA_APP_ID = "app_id"
|
||||
const val EXTRA_GAME_SOURCE = "game_source"
|
||||
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
|
||||
|
||||
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
|
||||
|
||||
fun read(context: Context, intent: Intent): String? = when (intent.action) {
|
||||
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
|
||||
ACTION, GAMENATIVE_ACTION -> readId(intent)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
internal fun readId(intent: Intent): String? = runCatching {
|
||||
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
|
||||
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
|
||||
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
|
||||
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
|
||||
is Int -> value.takeIf { it > 0 }?.toString()
|
||||
is String -> value.takeIf(GameLaunchLink::validId)
|
||||
else -> null
|
||||
}
|
||||
id
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which catalog components a game's prefix already has from elsewhere - a real installer, the game,
|
||||
* the user - so the list can say so instead of recommending them again. Bannerlator's detector,
|
||||
* on DroidDeck's compatdata/<id>: only markers absent from a fresh Proton prefix count (OpenAL32,
|
||||
* Mono, PhysX, Games for Windows Live); msvcp140, d3dx9 and the like ship with Proton and cannot
|
||||
* tell. Files DroidDeck put there itself (droiddeck-wincomponents' state) are not counted.
|
||||
* Pure filesystem reads; never throws.
|
||||
*/
|
||||
object PrefixInstalledDetector {
|
||||
private const val STATE = ".droiddeck-wincomponents.json"
|
||||
|
||||
/** Detector names (oalinst, mono, physx, XLiveRedist) present in [compat]/pfx. */
|
||||
fun detect(compat: File?): Set<String> = try {
|
||||
val pfx = compat?.let { File(it, "pfx") }
|
||||
if (pfx == null || !pfx.isDirectory) emptySet() else {
|
||||
val ours = ours(compat)
|
||||
val out = HashSet<String>()
|
||||
val openal = listOf("system32", "syswow64").map { "drive_c/windows/$it/openal32.dll" }
|
||||
if (openal.any { it !in ours && fileIgnoringCase(pfx, it) }) out += "oalinst"
|
||||
if (File(pfx, "drive_c/windows/mono").isDirectory) out += "mono"
|
||||
if (hasPhysX(pfx)) out += "physx"
|
||||
if (registryContains(pfx, "Games for Windows")) out += "XLiveRedist"
|
||||
out
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
private fun ours(compat: File): Set<String> = try {
|
||||
val files = JSONObject(File(compat, STATE).readText()).optJSONObject("files")
|
||||
files?.keys()?.asSequence()?.map { it.lowercase() }?.toSet().orEmpty()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
/** Windows names ignore case; the prefix's folder may spell them either way. */
|
||||
private fun fileIgnoringCase(pfx: File, rel: String): Boolean {
|
||||
val target = File(pfx, rel)
|
||||
return target.parentFile?.listFiles()?.any { it.isFile && it.name.equals(target.name, ignoreCase = true) } == true
|
||||
}
|
||||
|
||||
private fun hasPhysX(pfx: File): Boolean {
|
||||
for (pf in arrayOf("drive_c/Program Files", "drive_c/Program Files (x86)")) {
|
||||
val base = File(pfx, pf)
|
||||
if (File(base, "NVIDIA Corporation/PhysX").isDirectory) return true
|
||||
if (base.listFiles()?.any { it.isDirectory && it.name.contains("PhysX", ignoreCase = true) } == true) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun registryContains(pfx: File, needle: String): Boolean =
|
||||
arrayOf("system.reg", "user.reg").any { name ->
|
||||
val f = File(pfx, name)
|
||||
try {
|
||||
f.isFile && f.length() < 32L * 1024 * 1024 &&
|
||||
f.bufferedReader().use { r -> r.lineSequence().any { it.contains(needle, ignoreCase = true) } }
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What Steam itself installs alongside a game: the Steamworks Shared redistributables (app 228980)
|
||||
* listed under "SharedDepots" in the game's appmanifest. Those live in Steamworks Shared rather than
|
||||
* the game's folder, so the folder scan ([DependencyDetector]) cannot see them; this reads Steam's
|
||||
* list instead. Depots are matched to Bannerlator's catalog names; unknown ones are left out.
|
||||
*/
|
||||
object SteamRedists {
|
||||
/** Steamworks Shared depot -> (catalog component, what Steam calls it). 2015-2019 runtimes are one family. */
|
||||
val DEPOTS = linkedMapOf(
|
||||
"228981" to ("vcredist2005_dll" to "VC++ 2005"),
|
||||
"228982" to ("vcredist2008_dll" to "VC++ 2008"),
|
||||
"228983" to ("vcredist2010_dll" to "VC++ 2010"),
|
||||
"228984" to ("vcredist2012_dll" to "VC++ 2012"),
|
||||
"228985" to ("vcredist2013_dll" to "VC++ 2013"),
|
||||
"228986" to ("vcredist2015_dll" to "VC++ 2015"),
|
||||
"228987" to ("vcredist2019_dll" to "VC++ 2017"),
|
||||
"228988" to ("vcredist2019_dll" to "VC++ 2019"),
|
||||
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
|
||||
"228990" to ("d3dx9" to "DirectX (June 2010)"),
|
||||
)
|
||||
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
|
||||
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
|
||||
|
||||
/** The game's appmanifest beside its install folder (steamapps/common/<dir> -> steamapps/). */
|
||||
fun manifest(gameDir: File?, appId: Int): File? {
|
||||
val steamapps = gameDir?.parentFile?.parentFile ?: return null
|
||||
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
|
||||
val text = try {
|
||||
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
|
||||
} catch (_: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
val block = BLOCK.find(text)?.groupValues?.get(1) ?: return emptyList()
|
||||
return ENTRY.findAll(block).mapNotNull { DEPOTS[it.groupValues[1]] }
|
||||
.distinctBy { it.first }
|
||||
.map { (component, label) -> DependencyDetector.Recommendation(component, label, label, DependencyDetector.Kind.STEAM) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system,
|
||||
* the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once
|
||||
* ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are
|
||||
* left out rather than guessed.
|
||||
*/
|
||||
object DeviceInfo {
|
||||
class Section(val title: String, val rows: List<Pair<String, String>>)
|
||||
|
||||
fun collect(context: Context): List<Section> {
|
||||
val vk = VulkanInfo.query()
|
||||
// After Vulkan has been asked, detection can use its name too.
|
||||
val gpu = GpuInfo.detect()
|
||||
return listOf(
|
||||
chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu),
|
||||
).filter { it.rows.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** The whole window as plain text, for a bug report. */
|
||||
fun text(sections: List<Section>): String = buildString {
|
||||
append("DroidDeck device info\n")
|
||||
for (s in sections) {
|
||||
append('\n').append(s.title).append('\n')
|
||||
for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<Pair<String, String>>.add(key: String, value: String?) {
|
||||
if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim())
|
||||
}
|
||||
|
||||
private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null }
|
||||
|
||||
private fun prop(name: String): String = GpuInfo.systemProperty(name)
|
||||
|
||||
private fun chip() = Section("Chip (SoC)", buildList<Pair<String, String>> {
|
||||
val model = SocNames.model()
|
||||
val platform = prop("ro.board.platform")
|
||||
add("Name", SocNames.name(model, platform))
|
||||
add("Model", model)
|
||||
if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER)
|
||||
add("Platform", platform)
|
||||
add("Hardware", Build.HARDWARE)
|
||||
add("Board", Build.BOARD)
|
||||
val id = read("/sys/devices/soc0/soc_id")
|
||||
val machine = read("/sys/devices/soc0/machine")
|
||||
add("SoC id", listOfNotNull(id, machine).joinToString(" · "))
|
||||
add("Family", read("/sys/devices/soc0/family"))
|
||||
})
|
||||
|
||||
private fun gpu(gpu: GpuInfo) = Section("GPU", buildList<Pair<String, String>> {
|
||||
add("GPU", gpu.name)
|
||||
add("Family", gpu.family.label)
|
||||
add("Model found from", when (gpu.modelSource) {
|
||||
"kernel" -> "the kernel's GPU name"
|
||||
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
|
||||
"platform" -> "the platform (the kernel's name has no model)"
|
||||
else -> "unknown"
|
||||
})
|
||||
add("Kernel name", gpu.kgslName)
|
||||
val kgsl = "/sys/class/kgsl/kgsl-3d0"
|
||||
add("Clock range", gpuRange())
|
||||
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
|
||||
add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" })
|
||||
add("Support", gpu.supportText)
|
||||
})
|
||||
|
||||
private fun cpu() = Section("CPU", buildList<Pair<String, String>> {
|
||||
add("Cores", Runtime.getRuntime().availableProcessors().toString())
|
||||
// Each cpufreq policy is one cluster: the cores it covers and their speed range.
|
||||
val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) }
|
||||
?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty()
|
||||
for (p in policies) {
|
||||
val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
|
||||
val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull()
|
||||
val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue
|
||||
val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}"
|
||||
add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " +
|
||||
(if (min != null) ghz(min) + "–" else "") + ghz(max))
|
||||
}
|
||||
add("ABI", Build.SUPPORTED_ABIS.joinToString(", "))
|
||||
})
|
||||
|
||||
private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6)
|
||||
|
||||
/** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */
|
||||
private fun gpuRange(): String? {
|
||||
fun mhz(hz: Long) = "${hz / 1_000_000} MHz"
|
||||
val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies")
|
||||
?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
|
||||
if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)"
|
||||
read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" }
|
||||
val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f ->
|
||||
val n = f.name.lowercase()
|
||||
(n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) }
|
||||
} ?: return null
|
||||
val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
|
||||
if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)"
|
||||
val min = read("${node.path}/min_freq")?.toLongOrNull()
|
||||
val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null
|
||||
return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max)
|
||||
}
|
||||
|
||||
private fun memory(context: Context) = Section("Memory", buildList<Pair<String, String>> {
|
||||
val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList
|
||||
val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo)
|
||||
add("Total", FileUtils.sizeToString(mi.totalMem))
|
||||
add("Available now", FileUtils.sizeToString(mi.availMem))
|
||||
})
|
||||
|
||||
private fun system() = Section("System", buildList<Pair<String, String>> {
|
||||
add("Device", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
add("Security patch", Build.VERSION.SECURITY_PATCH)
|
||||
add("Kernel", System.getProperty("os.version"))
|
||||
add("Build", Build.DISPLAY)
|
||||
})
|
||||
|
||||
private fun vulkan(context: Context, vk: Map<String, String>) = Section("Vulkan (the phone's own driver)", buildList<Pair<String, String>> {
|
||||
vk["error"]?.let { add("Error", it) }
|
||||
add("Device", vk["device"])
|
||||
add("Type", vk["type"])
|
||||
add("API version", vk["api"])
|
||||
add("Loader version", vk["loader"])
|
||||
add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" "))
|
||||
// Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ...").
|
||||
add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), ""))
|
||||
add("Conformance", vk["conformance"])
|
||||
add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / "))
|
||||
add("Max texture size", vk["max_image_2d"])
|
||||
add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" })
|
||||
add("Extensions", vk["extension_count"])
|
||||
val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures")
|
||||
for ((key, label) in features) add(label, vk[key])
|
||||
for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value)
|
||||
// What Android certifies the phone for.
|
||||
val pm = context.packageManager
|
||||
val hw = pm.systemAvailableFeatures.associate { it.name to it.version }
|
||||
hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let {
|
||||
add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}")
|
||||
}
|
||||
hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) }
|
||||
})
|
||||
|
||||
private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList<Pair<String, String>> {
|
||||
add("Driver mode", SessionPrefs.gpuDriverMode(context))
|
||||
val turnip = TurnipDriver(context)
|
||||
val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() }
|
||||
add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim())
|
||||
val linux = LinuxVulkanDriverManager(context)
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim())
|
||||
if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build")
|
||||
})
|
||||
}
|
||||
@@ -16,10 +16,14 @@ data class GpuInfo(
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
/** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
/** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */
|
||||
val kgslName: String = "",
|
||||
/** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */
|
||||
val modelSource: String = "",
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
@@ -56,17 +60,49 @@ data class GpuInfo(
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
// Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)".
|
||||
val soc = if (adreno) SocNames.label() else ""
|
||||
val fromKernel = raw?.let { threeDigits(it) } ?: 0
|
||||
// Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The
|
||||
// Vulkan driver's own name, when this process has asked it, then the platform's code
|
||||
// name stand in - the GPU is the same on every phone of a platform.
|
||||
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
|
||||
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
|
||||
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
|
||||
val source = when {
|
||||
fromKernel > 0 -> "kernel"
|
||||
fromVulkan > 0 -> "vulkan"
|
||||
fromPlatform > 0 -> "platform"
|
||||
else -> ""
|
||||
}
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
kgslName = raw.orEmpty(), modelSource = source,
|
||||
)
|
||||
}
|
||||
|
||||
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
|
||||
/** Qualcomm platform code names whose GPU is known for certain. */
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
|
||||
"kalama" to 740, "pineapple" to 750, "sun" to 830,
|
||||
)
|
||||
|
||||
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
|
||||
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
|
||||
|
||||
/** android.os.SystemProperties.get, which apps may read but not call directly. */
|
||||
fun systemProperty(name: String): String = try {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank
|
||||
* (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order
|
||||
* ClusterTune uses; then it is named from a table of models we are sure of, or from the platform
|
||||
* code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant.
|
||||
*/
|
||||
object SocNames {
|
||||
/** Model codes whose product name is certain. */
|
||||
private val MODELS = mapOf(
|
||||
"SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870",
|
||||
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
|
||||
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
|
||||
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
|
||||
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
|
||||
"SM6375" to "Snapdragon 695",
|
||||
)
|
||||
|
||||
/** Platform code names: every chip of one platform shares its CPU and GPU design. */
|
||||
private val PLATFORMS = mapOf(
|
||||
"msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888",
|
||||
"taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2",
|
||||
"pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite",
|
||||
)
|
||||
|
||||
/** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */
|
||||
fun model(prop: (String) -> String = GpuInfo::systemProperty): String {
|
||||
val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else ""
|
||||
return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform"))
|
||||
.map { it.trim() }
|
||||
// ro.fota.platform is often a branch name ("MSM_13.0"), not a chip.
|
||||
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
|
||||
}
|
||||
|
||||
/** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */
|
||||
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
|
||||
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
|
||||
|
||||
/** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */
|
||||
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
|
||||
val name = name(model, platform)
|
||||
return when {
|
||||
name != null && model.isNotEmpty() -> "$name ($model)"
|
||||
name != null -> name
|
||||
else -> model
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and
|
||||
* driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and
|
||||
* kept; the first [query] creates a Vulkan instance, so it belongs off the main thread.
|
||||
*/
|
||||
object VulkanInfo {
|
||||
@Volatile private var cached: Map<String, String>? = null
|
||||
|
||||
@JvmStatic private external fun nativeQuery(): String
|
||||
|
||||
/** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */
|
||||
@Synchronized
|
||||
fun query(): Map<String, String> {
|
||||
cached?.let { return it }
|
||||
val text = try {
|
||||
System.loadLibrary("deviceinfo")
|
||||
nativeQuery()
|
||||
} catch (t: Throwable) {
|
||||
Log.w("VulkanInfo", "query failed", t)
|
||||
"error=" + (t.message ?: t.javaClass.simpleName)
|
||||
}
|
||||
val map = text.lineSequence().mapNotNull { line ->
|
||||
val eq = line.indexOf('=')
|
||||
if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1)
|
||||
}.toMap(LinkedHashMap())
|
||||
cached = map
|
||||
return map
|
||||
}
|
||||
|
||||
/** What [query] found, if it has run in this process; never blocks. */
|
||||
fun cachedOrNull(): Map<String, String>? = cached
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.droiddeck.launcher.input;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Where the fake input rings live and how the guest is told about them. Kept apart from
|
||||
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
|
||||
*/
|
||||
final class FakeInputRingPaths {
|
||||
static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
|
||||
private FakeInputRingPaths() {}
|
||||
|
||||
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
|
||||
static File ringDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
static File ringFile(File fakeInputDir, int slot) {
|
||||
File ringDir = ringDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
|
||||
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
|
||||
* the export has to use that same spelling. A canonical path is not that: where
|
||||
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
|
||||
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
|
||||
*/
|
||||
static String exportPath(File ringFile) {
|
||||
return ringFile.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,6 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
@@ -198,26 +197,8 @@ public class FakeInputWriter {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static File getRingDir(File fakeInputDir) {
|
||||
if (fakeInputDir == null) {
|
||||
return null;
|
||||
}
|
||||
File inputDir = fakeInputDir.getAbsoluteFile();
|
||||
File parent = inputDir.getParentFile();
|
||||
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
|
||||
}
|
||||
|
||||
private static File getRingFile(File fakeInputDir, int slot) {
|
||||
File ringDir = getRingDir(fakeInputDir);
|
||||
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
|
||||
}
|
||||
|
||||
private static String getCanonicalOrAbsolutePath(File file) {
|
||||
try {
|
||||
return file.getCanonicalPath();
|
||||
} catch (IOException e) {
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
|
||||
}
|
||||
|
||||
private static void initializeRingHeader(ByteBuffer data) {
|
||||
@@ -325,7 +306,7 @@ public class FakeInputWriter {
|
||||
ringSlot.ringFile = ringFile.getAbsoluteFile();
|
||||
ringSlot.ringRaf = raf;
|
||||
ringSlot.ringChannel = channel;
|
||||
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
|
||||
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
|
||||
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
|
||||
return ringSlot;
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -76,6 +76,17 @@ public final class PadBridge {
|
||||
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
|
||||
private int lastDeviceId = Integer.MIN_VALUE;
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove the pad, or
|
||||
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
|
||||
* on that slot finds the motors in the player's hands (RumbleComponent).
|
||||
*/
|
||||
private static volatile int activeControllerId = NO_CONTROLLER;
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
}
|
||||
@@ -90,6 +101,7 @@ public final class PadBridge {
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -218,6 +230,7 @@ public final class PadBridge {
|
||||
*/
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
@@ -299,6 +312,7 @@ public final class PadBridge {
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
activeControllerId = device.getId();
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
|
||||
@@ -300,7 +300,10 @@ object BwrapSpawner {
|
||||
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
|
||||
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
|
||||
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
|
||||
// Android 16 refuses the size seal Firefox puts on a tab's shared
|
||||
// memory, and the tab then dies on every page (droiddeck-desktop).
|
||||
"MOZ_SHM_NO_SEALS")) {
|
||||
env.putIfAbsent(name, "1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,15 @@ import java.security.MessageDigest
|
||||
*/
|
||||
object ProotFastPath {
|
||||
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
|
||||
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
|
||||
const val MAX_BINDS = 256
|
||||
|
||||
fun enabled(context: Context): Boolean =
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
|
||||
|
||||
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
|
||||
fun key(root: File, binds: List<String>): String? {
|
||||
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
|
||||
return digest.take(12).joinToString("") { "%02x".format(it) }
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlin.math.abs
|
||||
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
|
||||
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
|
||||
* names its supply `battery` and lays it out differently, and on many phones an app may not read
|
||||
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
|
||||
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
|
||||
* from Android's own battery API - the same on every device - into a directory the session binds
|
||||
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
|
||||
*
|
||||
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
private var thread: Thread? = null
|
||||
|
||||
override fun start() {
|
||||
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
|
||||
File(dir, "BAT0").deleteRecursively()
|
||||
running = true
|
||||
write()
|
||||
thread = Thread({
|
||||
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
if (running) write()
|
||||
}
|
||||
}, "battery-sysfs").apply { isDaemon = true; start() }
|
||||
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
|
||||
pct <= 15 -> "Low"
|
||||
else -> "Normal"
|
||||
}
|
||||
for (name in listOf("BAT0", "BAT1")) {
|
||||
for (name in listOf("BAT1")) {
|
||||
val bat = File(dir, name).apply { mkdirs() }
|
||||
val attrs = linkedMapOf(
|
||||
"type" to "Battery",
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
@@ -660,6 +661,107 @@ object ComponentsManager {
|
||||
return Catalog(items, o.optLong("fetchedAt"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
|
||||
* means every model in [families]. A row the user already chose in Components is left alone;
|
||||
* a Steam update that puts the Proton's own files back is replaced on the next session.
|
||||
*/
|
||||
internal data class ForcedPackage(
|
||||
val families: Set<GpuInfo.Family>,
|
||||
val comp: String,
|
||||
val file: String,
|
||||
val release: String,
|
||||
val models: Set<Int> = emptySet(),
|
||||
) {
|
||||
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
|
||||
}
|
||||
|
||||
internal val FORCED_PACKAGES = listOf(
|
||||
// DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on
|
||||
// Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag.
|
||||
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this Proton should receive the forced package. No active swap means the Proton's
|
||||
* own copy. A record for a different Proton build means a Steam update put that copy back,
|
||||
* whichever package was recorded. A record for this build is a Components choice and stays.
|
||||
*/
|
||||
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
|
||||
activeFile.isNullOrEmpty() || !protonVersionMatches
|
||||
|
||||
/** The catalog row for this file and release, or null. */
|
||||
internal fun catalogMatch(items: List<CatalogItem>, file: String, release: String): CatalogItem? =
|
||||
items.firstOrNull { it.file == file && it.release == release }
|
||||
|
||||
/**
|
||||
* Install every [FORCED_PACKAGES] row this GPU matches into each Proton that
|
||||
* [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these
|
||||
* Protons need nothing. A failed fetch throws and the session logs it without stopping.
|
||||
*/
|
||||
fun ensureForcedPackages(context: Context): String? = ensureForcedPackages(
|
||||
context, GpuInfo.detect(),
|
||||
{ file, release -> catalogItem(context, file, release) },
|
||||
{ download(context, it) {} },
|
||||
{ id, file -> swap(context, id, file) },
|
||||
)
|
||||
|
||||
internal fun ensureForcedPackages(
|
||||
context: Context,
|
||||
gpu: GpuInfo,
|
||||
lookup: (file: String, release: String) -> CatalogItem?,
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
apply: (protonId: String, file: String) -> String,
|
||||
): String? {
|
||||
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }
|
||||
if (rules.isEmpty()) return null
|
||||
val installed = protons(context)
|
||||
val lines = mutableListOf<String>()
|
||||
for (rule in rules) {
|
||||
val state = loadState(context)
|
||||
val targets = installed.filter { p ->
|
||||
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
|
||||
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
|
||||
}
|
||||
if (targets.isEmpty()) continue
|
||||
val missing = missingPackage(context, rule, lookup, fetch)
|
||||
if (missing != null) {
|
||||
lines.add(missing)
|
||||
continue
|
||||
}
|
||||
lines.add(targets.joinToString("; ") { apply(it.id, rule.file) })
|
||||
}
|
||||
return lines.joinToString("; ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
|
||||
private fun missingPackage(
|
||||
context: Context,
|
||||
rule: ForcedPackage,
|
||||
lookup: (file: String, release: String) -> CatalogItem?,
|
||||
fetch: (CatalogItem) -> Unit,
|
||||
): String? {
|
||||
val wcp = File(packagesDir(context), safeName(rule.file))
|
||||
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
|
||||
wcp.delete()
|
||||
val item = lookup(rule.file, rule.release)
|
||||
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
|
||||
fetch(item)
|
||||
return null
|
||||
}
|
||||
|
||||
/** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */
|
||||
internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? =
|
||||
resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items })
|
||||
|
||||
/** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */
|
||||
internal fun resolveCatalogItem(
|
||||
file: String,
|
||||
release: String,
|
||||
cached: () -> List<CatalogItem>,
|
||||
refresh: () -> List<CatalogItem>,
|
||||
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
|
||||
|
||||
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
|
||||
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
|
||||
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
|
||||
|
||||
@@ -59,7 +59,7 @@ object GameEnvironmentStore {
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
|
||||
val json = encode(resolved)
|
||||
// The Display page's texture filtering, as DXVK options the launcher appends to
|
||||
// DXVK_CONFIG after the profiles - a user's own DXVK_CONFIG entry keeps its options.
|
||||
|
||||
@@ -3,43 +3,58 @@ package com.droiddeck.launcher.session
|
||||
import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.droiddeck.launcher.core.SessionPart
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.DataInputStream
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Rumble for the on-screen pad, on the phone itself.
|
||||
* Rumble for the pad, on the motors in the player's hands.
|
||||
*
|
||||
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
|
||||
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
|
||||
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
|
||||
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
|
||||
* a missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
|
||||
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
|
||||
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
|
||||
* values. This listens for those and plays the effect on the physical controller that last drove
|
||||
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
|
||||
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
|
||||
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
|
||||
* missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var phone: Motors? = null
|
||||
/** Where the last effect went, so the next one (or a stop) can end it there. */
|
||||
private var playing: Motors? = null
|
||||
private var controllerId = PadBridge.NO_CONTROLLER
|
||||
private var controller: Motors? = null
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == null || key == "rumble") refreshEnabled()
|
||||
}
|
||||
|
||||
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
|
||||
internal var controllerMotors: (Int) -> Motors? = ::lookupController
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) vibrator?.cancel()
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
val ctx = app() ?: return
|
||||
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
|
||||
vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
|
||||
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
|
||||
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
|
||||
refreshEnabled()
|
||||
server = s
|
||||
@@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
|
||||
buzz(max(strong, weak), ms)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
vibrator?.cancel()
|
||||
vibrator = null
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
phone = null
|
||||
controller = null
|
||||
controllerId = PadBridge.NO_CONTROLLER
|
||||
val s = server
|
||||
server = null
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
@@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
@Synchronized private fun buzz(strength: Int, ms: Int) {
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
if (!enabled) return
|
||||
val vibrator = vibrator ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
|
||||
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: $e")
|
||||
val target = target()
|
||||
if (target !== playing) {
|
||||
playing?.cancel()
|
||||
playing = target
|
||||
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
try {
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** The active controller's motors when it has any, else the phone's. */
|
||||
private fun target(): Motors? {
|
||||
val id = PadBridge.activeControllerId()
|
||||
if (id != controllerId) {
|
||||
controllerId = id
|
||||
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
}
|
||||
return controller ?: phone
|
||||
}
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
fun play(strong: Int, weak: Int, ms: Long)
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
|
||||
override val name = "the device's vibrator"
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
|
||||
@RequiresApi(31)
|
||||
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) {
|
||||
val combined = CombinedVibration.startParallel()
|
||||
var any = false
|
||||
fun add(id: Int, strength: Int) {
|
||||
if (strength == 0) return
|
||||
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
|
||||
any = true
|
||||
}
|
||||
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
|
||||
if (any) manager.vibrate(combined.combine()) else manager.cancel()
|
||||
}
|
||||
override fun cancel() = manager.cancel()
|
||||
}
|
||||
|
||||
/** Before Android 12 a pad has one vibrator. */
|
||||
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
|
||||
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
|
||||
override fun cancel() = vibrator.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
else VibrationEffect.DEFAULT_AMPLITUDE
|
||||
return VibrationEffect.createOneShot(ms, amplitude)
|
||||
}
|
||||
|
||||
private fun lookupController(id: Int): Motors? {
|
||||
val device = InputDevice.getDevice(id) ?: return null
|
||||
val name = "\"${device.name}\""
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val manager = device.vibratorManager
|
||||
val ids = manager.vibratorIds.sortedArray()
|
||||
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val vibrator = device.vibrator
|
||||
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ object SessionArtifacts {
|
||||
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
|
||||
private const val SCRUBBED_MARKER = ".scrubbed-2"
|
||||
|
||||
/**
|
||||
* Written by a session's own ending, and only when every file in the folder, subfolders
|
||||
* included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
|
||||
* the app wrote), went through the redactor without a failure. It lists each of those files
|
||||
* with its size and modification time, under the redactor's rules version: a share copies a
|
||||
* file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
|
||||
* a file added or changed since, a hidden one, or every file of a folder without it.
|
||||
*/
|
||||
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
|
||||
|
||||
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
|
||||
fun collect(context: Context, dir: File, reason: String) {
|
||||
try {
|
||||
@@ -44,11 +54,11 @@ object SessionArtifacts {
|
||||
}
|
||||
}
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
copySteamLogs(context, dir)
|
||||
val record = Record(dir)
|
||||
copySteamLogs(context, dir, record)
|
||||
// A session the system killed leaves its trace here and nowhere else.
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
scrubFolder(dir)
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
markScrubbed(scrubTree(record))
|
||||
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
|
||||
} catch (e: Exception) {
|
||||
@@ -71,8 +81,7 @@ object SessionArtifacts {
|
||||
if (dirs.isEmpty()) return
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
dirs.forEach { dir ->
|
||||
scrubFolder(dir)
|
||||
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
|
||||
Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
}
|
||||
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
|
||||
@@ -84,29 +93,121 @@ object SessionArtifacts {
|
||||
* through the redactor before the folder can be shared. A file is rewritten only if a line
|
||||
* changed. steam/ was scrubbed on the way in.
|
||||
*/
|
||||
private fun scrubFolder(dir: File) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
|
||||
/** A file's size and modification time as they stood right after it was scrubbed. */
|
||||
private class Scrubbed(val length: Long, val modified: Long)
|
||||
|
||||
/** Files of a folder as they stood right after scrubbing, filled by [scrubTree] and [copySteamLogs]. */
|
||||
private class Record(val dir: File) {
|
||||
val files = HashMap<File, Scrubbed>()
|
||||
var failed = false
|
||||
}
|
||||
|
||||
private fun scrubFolder(dir: File, record: Record) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
|
||||
if (!LogRedactor.isText(f)) return@forEach
|
||||
try {
|
||||
val tmp = File(dir, ".${f.name}.scrub")
|
||||
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
|
||||
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${f.name}", e)
|
||||
}
|
||||
val done = scrubFile(dir, f)
|
||||
if (done == null) record.failed = true else record.files[f] = done
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam's logs: redacted into steam/, never copied verbatim. */
|
||||
private fun copySteamLogs(context: Context, dir: File) {
|
||||
/**
|
||||
* Puts [f] through the redactor in place. Returns its size and modification time as it then
|
||||
* stands, or null if it could not, and [f] may still hold the original. A writer still
|
||||
* appending to [f] (the app's own log capture) changes its size afterwards, and a share then
|
||||
* scrubs it again.
|
||||
*/
|
||||
private fun scrubFile(dir: File, f: File): Scrubbed? {
|
||||
val tmp = File(dir, ".${f.name}.scrub")
|
||||
return try {
|
||||
val before = Scrubbed(f.length(), f.lastModified())
|
||||
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
|
||||
val scrubbed = tmp.readBytes()
|
||||
if (scrubbed.contentEquals(f.readBytes())) {
|
||||
tmp.delete()
|
||||
// Unchanged by the redactor, and by anything else while it read.
|
||||
before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
|
||||
} else {
|
||||
// Shared storage can refuse a rename; then the scrubbed bytes are written over the
|
||||
// original instead, so it never stays behind unscrubbed.
|
||||
if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() }
|
||||
Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${f.name}", e)
|
||||
tmp.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records [record]'s folder as scrubbed - every file went through without a failure - or
|
||||
* leaves it unmarked, so the next share and the pass over older folders scrub it again.
|
||||
*/
|
||||
private fun markScrubbed(record: Record) {
|
||||
val dir = record.dir
|
||||
val tree = File(dir, SCRUBBED_TREE_MARKER)
|
||||
if (record.failed) {
|
||||
tree.delete()
|
||||
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
|
||||
return
|
||||
}
|
||||
try {
|
||||
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
|
||||
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
|
||||
record.files.forEach { (f, s) ->
|
||||
lines.append(s.length).append('\t').append(s.modified).append('\t')
|
||||
.append(f.relativeTo(dir).path).append('\n')
|
||||
}
|
||||
tree.writeText(lines.toString())
|
||||
} catch (e: Exception) {
|
||||
tree.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The files of [dir] that its own ending scrubbed and that are unchanged since (same size and
|
||||
* modification time), by path relative to [dir]. Empty when the folder has no such record, or
|
||||
* one made under other redactor rules.
|
||||
*/
|
||||
fun scrubbedFiles(dir: File): Set<String> {
|
||||
val lines = try { File(dir, SCRUBBED_TREE_MARKER).takeIf { it.isFile }?.readLines() } catch (e: Exception) { null }
|
||||
?: return emptySet()
|
||||
if (lines.firstOrNull() != "rules ${LogRedactor.RULES_VERSION}") return emptySet()
|
||||
return lines.drop(1).mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 3)
|
||||
if (parts.size != 3) return@mapNotNull null
|
||||
val f = File(dir, parts[2])
|
||||
parts[2].takeIf { f.isFile && f.length().toString() == parts[0] && f.lastModified().toString() == parts[1] }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
|
||||
|
||||
/**
|
||||
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
|
||||
* copied files there verbatim, and a Steam log the app did not copy over again (gone from the
|
||||
* runtime, or past its size limit) stayed as the script left it. Files [copySteamLogs] already
|
||||
* redacted on the way in are in [record] and left alone.
|
||||
*/
|
||||
private fun scrubTree(record: Record): Record {
|
||||
record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) }
|
||||
return record
|
||||
}
|
||||
|
||||
/** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */
|
||||
private fun copySteamLogs(context: Context, dir: File, record: Record) {
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
if (!logs.isDirectory) return
|
||||
val out = File(dir, "steam").apply { mkdirs() }
|
||||
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
|
||||
try {
|
||||
File(out, src.name).bufferedWriter().use { w ->
|
||||
val dst = File(out, src.name)
|
||||
dst.bufferedWriter().use { w ->
|
||||
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
|
||||
}
|
||||
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${src.name}", e)
|
||||
}
|
||||
@@ -143,12 +244,11 @@ object SessionArtifacts {
|
||||
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
|
||||
"session died of a crash, the entry is in there unless the device rebooted.\n"
|
||||
)
|
||||
if (newest) {
|
||||
copySteamLogs(context, dir)
|
||||
}
|
||||
val record = Record(dir)
|
||||
if (newest) copySteamLogs(context, dir, record)
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
|
||||
scrubFolder(dir)
|
||||
markScrubbed(scrubTree(record))
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
|
||||
Log.i(TAG, "finished the abandoned session folder $dir")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.view.WindowManager
|
||||
|
||||
/** The virtual display advertised to the guest, before it is fitted onto the Android panel. */
|
||||
object SessionDisplay {
|
||||
const val MATCH_SCREEN = "screen"
|
||||
|
||||
/**
|
||||
* Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower
|
||||
* than 16:9 and never taller than the panel (SHAPE_AUTO). The client treats the output as the
|
||||
* games' native resolution, so the height is what bounds a game's default size on a 1440p
|
||||
* phone; the shape is what keeps the client's interface edge to edge on a 20:9 one (1600x720)
|
||||
* and keeps games at 16:9 or wider on a foldable's near-square panel.
|
||||
*/
|
||||
const val DEFAULT_RESOLUTION = "720p"
|
||||
private val PRESET_HEIGHTS = listOf(720, 900, 1080)
|
||||
|
||||
/** The height of a preset choice ("900p"), or null for Match screen and fixed sizes. */
|
||||
fun presetHeight(choice: String): Int? =
|
||||
if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null
|
||||
|
||||
fun screenSize(panel: Pair<Int, Int>): Pair<Int, Int> =
|
||||
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
|
||||
|
||||
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> {
|
||||
if (choice == MATCH_SCREEN) return screenSize(panel)
|
||||
presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) }
|
||||
// Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size,
|
||||
// including legacy ones past the custom dialog's limit.
|
||||
val parts = choice.split('x').map { it.toIntOrNull() }
|
||||
return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
|
||||
else resolve(panel, 720, SessionPrefs.SHAPE_AUTO)
|
||||
}
|
||||
|
||||
/** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */
|
||||
fun resolutionOptions(panel: Pair<Int, Int>): List<String> {
|
||||
val panelHeight = minOf(panel.first, panel.second)
|
||||
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
|
||||
.filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN
|
||||
}
|
||||
|
||||
fun panelSize(context: Context): Pair<Int, Int> {
|
||||
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
val mode = manager.defaultDisplay.mode
|
||||
return maxOf(mode.physicalWidth, mode.physicalHeight) to minOf(mode.physicalWidth, mode.physicalHeight)
|
||||
}
|
||||
|
||||
/** Interpret an older cap/aspect combination without altering the saved picture size. */
|
||||
fun resolve(panel: Pair<Int, Int>, cap: Int, shape: String, custom: Pair<Int, Int>? = null): Pair<Int, Int> {
|
||||
custom?.let { return it }
|
||||
val panelW = maxOf(panel.first, panel.second).toFloat()
|
||||
val panelH = minOf(panel.first, panel.second).toFloat().coerceAtLeast(1f)
|
||||
// Auto retains wide panels but floors squarer ones at 16:9 for game compatibility.
|
||||
// Match screen removes that floor; fixed 16:9 also keeps foldable sessions stable.
|
||||
val aspect = when (shape) {
|
||||
SessionPrefs.SHAPE_WIDE -> 16f / 9f
|
||||
SessionPrefs.SHAPE_EXACT -> panelW / panelH
|
||||
else -> maxOf(panelW / panelH, 16f / 9f)
|
||||
}
|
||||
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
|
||||
return ((height * aspect).toInt() and 1.inv()) to (height and 1.inv())
|
||||
}
|
||||
|
||||
/** Mirrors gamescope's even-width 16:9 calculation, including near-16:9 rounding. */
|
||||
fun canStretch16x9(size: Pair<Int, Int>): Boolean =
|
||||
(size.second * 16 + 4) / 9 / 2 * 2 > size.first
|
||||
}
|
||||
@@ -28,14 +28,18 @@ object SessionFiles {
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
GameEnvironmentStore.publish(context)
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
|
||||
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
|
||||
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
|
||||
"usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex",
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
|
||||
"libssbs.so" to "usr/local/lib/libssbs.so",
|
||||
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
|
||||
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
|
||||
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
|
||||
@@ -103,6 +107,10 @@ object SessionFiles {
|
||||
"usr/local/lib/mangoapp/libtraceevent.so.1",
|
||||
"usr/local/lib/mangoapp/libtracefs.so.1",
|
||||
).map { it to it }
|
||||
// What the Windows components installer reads .msi packages with (tools/msitools).
|
||||
val msitools = listOf("msiinfo", "cabextract", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0")
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
// The patched wlroots (tools/wlroots) the desktop loads for its vulkan / gles2 renderers.
|
||||
val wlroots = if (File(root, "usr/bin/labwc").isFile) {
|
||||
arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so")
|
||||
@@ -114,7 +122,7 @@ object SessionFiles {
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
|
||||
) + wlroots + mangoapp + fexPreloads).filter { (asset, _) ->
|
||||
) + wlroots + mangoapp + msitools.map { it to it } + fexPreloads).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ package com.droiddeck.launcher.session
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.FileProvider
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
@@ -16,32 +22,74 @@ import java.util.zip.ZipOutputStream
|
||||
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
|
||||
*/
|
||||
object SessionLogShare {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* How far the zip for a share has got, 0..1, or null when none is being made. The Share logs
|
||||
* buttons show it in place of their label, so a tap is answered at once.
|
||||
*/
|
||||
var progress by mutableStateOf<Float?>(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Zips [folder] on a worker thread with [progress] following along, then hands the zip (null
|
||||
* if there was nothing to zip or it failed) to [done] on the main thread. Call on the main
|
||||
* thread; a tap while a zip is being made is ignored.
|
||||
*/
|
||||
fun prepare(context: Context, folder: () -> File?, done: (File?) -> Unit) {
|
||||
if (progress != null) return
|
||||
progress = 0f
|
||||
Thread({
|
||||
val zip = runCatching { folder()?.let { dir -> zipFolder(context, dir) { p -> main.post { if (progress != null) progress = p } } } }
|
||||
.onFailure { Log.w("SessionLogShare", "could not package session logs", it) }
|
||||
.getOrNull()
|
||||
main.post { progress = null; done(zip) }
|
||||
}, "share-logs").start()
|
||||
}
|
||||
|
||||
/** The newest session folder in either place logs are written, or null if there is none. */
|
||||
fun latest(context: Context): File? = SessionPaths.sessionFolders(context).lastOrNull()
|
||||
|
||||
/** Builds the zip (blocking). Returns null when there is no session to share. */
|
||||
fun zipLatest(context: Context): File? = latest(context)?.let { zipFolder(context, it) }
|
||||
|
||||
/** Builds a zip for one specific session folder (blocking). */
|
||||
fun zipFolder(context: Context, folder: File): File? {
|
||||
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
|
||||
if (!folder.isDirectory) return null
|
||||
val files = folder.walkTopDown().filter { it.isFile }.toList()
|
||||
// The client's logs past STEAM_LOG_MAX_BYTES stay out, as they do from the app's own copy: a
|
||||
// CEF debug log or an old *.previous.txt reached 17 MB, every share scrubbed them line by
|
||||
// line, and the share sheet took ten seconds to appear.
|
||||
val files = folder.walkTopDown().filter { it.isFile }
|
||||
.filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
|
||||
if (files.isEmpty()) return null
|
||||
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
|
||||
val zip = File(out, "DroidDeck-${folder.name}.zip")
|
||||
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
|
||||
// session pass yet, and the redactor changes nothing in a line that is already clean.
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
// A finished folder was scrubbed whole when it ended. A file its record lists, unchanged
|
||||
// since, goes in as it is; anything else (added or changed since, hidden, or any file of a
|
||||
// session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on
|
||||
// every share kept the share sheet from appearing for ten seconds or more.
|
||||
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
|
||||
val live = liveSteamLogs(context, folder)
|
||||
val total = (files + live).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
var done = 0L
|
||||
var shown = -1
|
||||
fun advance(f: File) {
|
||||
done += f.length()
|
||||
val percent = (done * 100 / total).toInt()
|
||||
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
|
||||
}
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
|
||||
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
if (LogRedactor.isText(f)) {
|
||||
if (!scrubbed && LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.json.JSONObject
|
||||
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
|
||||
object SessionPrefs {
|
||||
const val SUSPEND_AUTO = "auto"
|
||||
const val SUSPEND_NATIVE = "native"
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
const val SUSPEND_NEVER = "never"
|
||||
|
||||
@@ -88,27 +89,16 @@ object SessionPrefs {
|
||||
const val SHAPE_WIDE = "16:9"
|
||||
const val SHAPE_EXACT = "exact"
|
||||
|
||||
/** The choices the settings offer, in order. */
|
||||
val shapeChoices = listOf(
|
||||
SHAPE_AUTO to "Auto (16:9+)",
|
||||
SHAPE_EXACT to "Match screen",
|
||||
SHAPE_WIDE to "Always 16:9",
|
||||
)
|
||||
|
||||
/**
|
||||
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
|
||||
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
|
||||
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
|
||||
* display cannot change size once the session is up.
|
||||
*/
|
||||
fun shapeMode(context: Context): String =
|
||||
private fun shapeMode(context: Context): String =
|
||||
prefs(context).getString("shape", null)
|
||||
?: if (context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) SHAPE_WIDE else SHAPE_AUTO
|
||||
|
||||
fun setShapeMode(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("shape", mode).apply()
|
||||
}
|
||||
|
||||
fun oscMode(context: Context): String = prefs(context).getString("osc", OSC_AUTO) ?: OSC_AUTO
|
||||
|
||||
fun setOscMode(context: Context, mode: String) {
|
||||
@@ -175,37 +165,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("clientDirectAudio", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam only: gamescope makes every game window the size of the screen. A game that resizes
|
||||
* its own window when it loses focus (FlatOut) otherwise comes back smaller, drawn in a
|
||||
* corner; a game that sets its own resolution and never looks at its window again (Quake 3)
|
||||
* instead draws small in the bottom-left of the stretched one. On unless turned off.
|
||||
*/
|
||||
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", true)
|
||||
|
||||
fun setForceFullscreen(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("forceFullscreen", on).apply()
|
||||
writeForceFullscreenFlag(context)
|
||||
}
|
||||
|
||||
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
|
||||
|
||||
fun setStretch16x9(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("stretch16x9", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The same choice as a file the running session watches, so the drawer can change it live:
|
||||
* the session hands every change to gamescope, which reads GAMESCOPE_FORCE_WINDOWS_FULLSCREEN
|
||||
* off its root window whenever it changes. Written again at every session start so a file left
|
||||
* by an earlier session never disagrees with the setting.
|
||||
*/
|
||||
fun writeForceFullscreenFlag(context: Context) {
|
||||
runCatching {
|
||||
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-fill")
|
||||
.writeText(if (forceFullscreen(context)) "1\n" else "0\n")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On
|
||||
@@ -502,6 +467,14 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("storageDiagnostics", on).apply()
|
||||
}
|
||||
|
||||
/** Experimental: Steam's space reservation on the second library reports success untouched. */
|
||||
fun skipLibraryPreallocation(context: Context): Boolean =
|
||||
prefs(context).getBoolean("skipLibraryPreallocation", false)
|
||||
|
||||
fun setSkipLibraryPreallocation(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("skipLibraryPreallocation", on).apply()
|
||||
}
|
||||
|
||||
// ── Per-mode display ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -512,11 +485,33 @@ object SessionPrefs {
|
||||
* and the emulators under it get the same GPU headroom. Read once, when the session's display
|
||||
* is sized; a cap the user chose wins over the default.
|
||||
*/
|
||||
fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode))
|
||||
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
|
||||
|
||||
/** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */
|
||||
fun resolutionChosen(context: Context, mode: String): Boolean =
|
||||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||||
prefs(context).contains("displayResolution.$mode") ||
|
||||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||||
|
||||
/** One per-mode choice now owns both dimensions; old caps/shapes are read only for migration. */
|
||||
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): String {
|
||||
val saved = prefs(context)
|
||||
saved.getString("displayResolution.$mode", null)?.let { value ->
|
||||
if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value
|
||||
parseResolution(value)?.let { return "${it.first}x${it.second}" }
|
||||
}
|
||||
if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION
|
||||
val legacy = SessionDisplay.resolve(panel, resolutionCap(context, mode), shapeMode(context), customResolution(context, mode))
|
||||
return if (legacy == SessionDisplay.screenSize(panel)) SessionDisplay.MATCH_SCREEN
|
||||
else "${legacy.first}x${legacy.second}"
|
||||
}
|
||||
|
||||
fun setResolutionChoice(context: Context, mode: String, choice: String) {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else {
|
||||
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
|
||||
"${size.first}x${size.second}"
|
||||
}
|
||||
prefs(context).edit().putString("displayResolution.$mode", value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
|
||||
@@ -528,6 +523,22 @@ object SessionPrefs {
|
||||
|
||||
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
|
||||
|
||||
/**
|
||||
* Force SSBS for Proton games: Wine resumes threads from a Windows CONTEXT that never carries
|
||||
* PSTATE.SSBS, so they run with speculative store bypass disabled; libssbs.so keeps it set
|
||||
* (on DiRT 3 / GE-Proton: from ~99% of a game's threads running without it to none). The
|
||||
* speed-up is reported on Oryon cores (Snapdragon 8 Elite) and was not measurable on an
|
||||
* 8 Gen 3, so it is off unless turned on. A game's own environment can still say
|
||||
* DROIDDECK_FORCE_SSBS=0.
|
||||
*/
|
||||
fun forceSsbs(context: Context): Boolean = prefs(context).getBoolean("forceSsbs", false)
|
||||
|
||||
fun setForceSsbs(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("forceSsbs", on).apply()
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
fun setFexPreset(context: Context, id: String) {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
@@ -596,25 +607,13 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("theme", id).apply()
|
||||
}
|
||||
|
||||
/** What a mode gets when nothing was chosen. */
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun defaultResolutionCap(mode: String): Int = 720
|
||||
|
||||
fun setResolutionCap(context: Context, mode: String, cap: Int) {
|
||||
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed size for the session's display, per mode, or null. When set it replaces both the
|
||||
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
|
||||
*/
|
||||
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
|
||||
private fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
|
||||
parseResolution(prefs(context).getString("customRes.$mode", null))
|
||||
|
||||
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
|
||||
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
|
||||
}
|
||||
|
||||
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
|
||||
fun parseResolution(text: String?): Pair<Int, Int>? {
|
||||
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
|
||||
@@ -669,20 +668,27 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* How the compositor resizes the session onto the panel (WaylandCompositor.nativeSetUpscaler's
|
||||
* modes): Off and Linear both filter bilinearly, Nearest keeps pixels square for 2D and old
|
||||
* titles, the rest sharpen where the picture is enlarged; FSR (fit) rounds the picture to
|
||||
* FSR's preferred size first. Sharpen only works at any size.
|
||||
* modes). Linear is the default; Nearest preserves hard pixel edges. Spatial filters work
|
||||
* when enlarged; Sharpen only works at any size. The old Off/Linear and FSR/FSR Fit pairs
|
||||
* are equivalent on Wayland, so saved aliases resolve to one choice.
|
||||
*/
|
||||
val upscalerChoices = listOf(
|
||||
0 to "Off", 1 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 5 to "AMD FSR 1 (fit)", 3 to "Snapdragon GSR",
|
||||
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
|
||||
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
|
||||
)
|
||||
|
||||
fun upscaler(context: Context): Int =
|
||||
prefs(context).getInt("upscaler", 0).takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
|
||||
fun canonicalUpscaler(mode: Int): Int = when (mode) {
|
||||
1 -> 0
|
||||
5 -> 4
|
||||
else -> mode.takeIf { m -> upscalerChoices.any { it.first == m } } ?: 0
|
||||
}
|
||||
|
||||
fun upscalerHasSharpness(mode: Int): Boolean = canonicalUpscaler(mode) in 3..8
|
||||
|
||||
fun upscaler(context: Context): Int = canonicalUpscaler(prefs(context).getInt("upscaler", 0))
|
||||
|
||||
fun setUpscaler(context: Context, mode: Int) {
|
||||
prefs(context).edit().putInt("upscaler", mode).apply()
|
||||
prefs(context).edit().putInt("upscaler", canonicalUpscaler(mode)).apply()
|
||||
}
|
||||
|
||||
fun upscaleSharpness(context: Context): Int = prefs(context).getInt("upscaleSharpness", 75).coerceIn(0, 100)
|
||||
@@ -751,11 +757,14 @@ object SessionPrefs {
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
|
||||
?.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_NATIVE, SUSPEND_MANUAL, SUSPEND_NEVER) }
|
||||
// Direct games share Steam's settings but have no Steam client to prepare.
|
||||
?.let { if (it == SUSPEND_NATIVE && mode != SessionService.MODE_STEAM) SUSPEND_AUTO else it }
|
||||
?: SUSPEND_MANUAL
|
||||
|
||||
fun setSuspendPolicy(context: Context, mode: String, policy: String) {
|
||||
val normalized = policy.takeIf { it == SUSPEND_AUTO || it == SUSPEND_MANUAL || it == SUSPEND_NEVER }
|
||||
val normalized = policy.takeIf { it in setOf(SUSPEND_AUTO, SUSPEND_MANUAL, SUSPEND_NEVER) ||
|
||||
(it == SUSPEND_NATIVE && mode == SessionService.MODE_STEAM) }
|
||||
?: SUSPEND_MANUAL
|
||||
prefs(context).edit().putString("suspendPolicy.${prefMode(mode)}", normalized).apply()
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Owns the running session: the proot tree, the audio daemon, the network link and the locks that
|
||||
@@ -77,6 +78,10 @@ class SessionService : Service() {
|
||||
private var manualPauseRequested = false
|
||||
/** An explicit Steam sleep request is independent of the background policy. */
|
||||
private var steamSleepToken: String? = null
|
||||
private var nativeSleepToken: String? = null
|
||||
private var nativeSleepReady = false
|
||||
private var nativeSleepFallback = false
|
||||
private var nativeSleepTimeout: Runnable? = null
|
||||
private var suspendOperationPending = false
|
||||
private var pipTask = false
|
||||
private var suspendAttemptFailed = false
|
||||
@@ -195,6 +200,7 @@ class SessionService : Service() {
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
manualPauseRequested = false
|
||||
steamSleepToken = null
|
||||
completeNativeSleep()
|
||||
suspendOperationPending = false
|
||||
suspendAttemptFailed = false
|
||||
suspendController = null
|
||||
@@ -303,6 +309,14 @@ class SessionService : Service() {
|
||||
// The desktop's Steam launchers start the client there (droiddeck-steam-launch), through the
|
||||
// same set-up as a Steam session: it gets what the client and its games are started with.
|
||||
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
// Some GPUs need a component Proton does not ship. Apply those before the guest, so the
|
||||
// next game launch copies them into the prefix. A download failure is logged and the
|
||||
// session still starts; the next session tries again.
|
||||
if (steamHere) {
|
||||
runCatching { ComponentsManager.ensureForcedPackages(this) }
|
||||
.onSuccess { if (it != null) Log.i(TAG, it) }
|
||||
.onFailure { Log.w(TAG, "forced components", it) }
|
||||
}
|
||||
addClientEnvironment(guest, steamHere)
|
||||
// Where the fast path's description of proot's view goes, once the binds are known.
|
||||
val fastPathAt = guest.size
|
||||
@@ -402,6 +416,9 @@ class SessionService : Service() {
|
||||
guest.addAll(fastPathAt, env)
|
||||
shellGuest.addAll(fastPathAt, env)
|
||||
Log.i(TAG, "proot: fast path on (${prootBinds.size} binds)")
|
||||
} ?: run {
|
||||
Log.w(TAG, "proot: fast path off - ${prootBinds.size} binds (it holds ${ProotFastPath.MAX_BINDS}) or a path it cannot be told")
|
||||
null
|
||||
}
|
||||
} else null
|
||||
|
||||
@@ -590,9 +607,7 @@ class SessionService : Service() {
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
|
||||
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
|
||||
SessionPrefs.writeForceFullscreenFlag(this)
|
||||
}
|
||||
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
|
||||
@@ -811,6 +826,20 @@ class SessionService : Service() {
|
||||
} else {
|
||||
Log.i(TAG, "game storage: internal only")
|
||||
}
|
||||
// guest already has the session command at this point. These go with the `env -i`
|
||||
// assignments so they reach the Steam process instead of becoming script args.
|
||||
val storageEnv = ArrayList<String>()
|
||||
val skipLibrary = storageDiagnosticLibrary
|
||||
if (SessionState.mode == MODE_STEAM && skipLibrary != null && SessionPrefs.skipLibraryPreallocation(this)) {
|
||||
val device = runCatching { android.system.Os.stat(skipLibrary.absolutePath).st_dev }.getOrNull()
|
||||
if (device != null) {
|
||||
storageEnv.add("BL_STORAGE_DEVICE=$device")
|
||||
storageEnv.add("BL_STORAGE_PREALLOCATE_SKIP=1")
|
||||
Log.i(TAG, "game storage: Steam's space reservation on the second library is skipped")
|
||||
} else {
|
||||
Log.w(TAG, "game storage: library could not be identified; space reservation not skipped")
|
||||
}
|
||||
}
|
||||
if (SessionState.mode == MODE_STEAM && SessionPrefs.storageDiagnosticsEnabled(this)) {
|
||||
val target = storageDiagnosticLibrary ?: File(LinuxRuntime.rootDir(this), "root/.local/share/Steam")
|
||||
val device = StorageDiagnostics.writeSnapshot(
|
||||
@@ -821,16 +850,18 @@ class SessionService : Service() {
|
||||
} ?: false,
|
||||
)
|
||||
if (device != null) {
|
||||
// guest already has the session command at this point. Put these with the `env -i`
|
||||
// assignments so they reach the Steam process instead of becoming script args.
|
||||
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
|
||||
guest.add(envAt, "BL_STORAGE_DIAGNOSTICS=1")
|
||||
guest.add(envAt + 1, "BL_STORAGE_DEVICE=$device")
|
||||
guest.add(envAt + 2, "BL_STORAGE_LOG=${File(sessionDir, "storage.log").absolutePath}")
|
||||
// Both observe the same library, so a device the skip already set is the same one.
|
||||
if (storageEnv.none { it.startsWith("BL_STORAGE_DEVICE=") }) storageEnv.add("BL_STORAGE_DEVICE=$device")
|
||||
storageEnv.add("BL_STORAGE_DIAGNOSTICS=1")
|
||||
storageEnv.add("BL_STORAGE_LOG=${File(sessionDir, "storage.log").absolutePath}")
|
||||
} else {
|
||||
Log.w(TAG, "storage diagnostics: selected library could not be identified; metadata snapshot only")
|
||||
}
|
||||
}
|
||||
if (storageEnv.isNotEmpty()) {
|
||||
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
|
||||
guest.addAll(envAt, storageEnv)
|
||||
}
|
||||
// The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so
|
||||
// the shortcuts the app writes point somewhere whatever storage the folder is on.
|
||||
for (root in com.droiddeck.launcher.frontend.AddedGames.roots(this)) {
|
||||
@@ -992,6 +1023,8 @@ class SessionService : Service() {
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -1070,11 +1103,28 @@ class SessionService : Service() {
|
||||
|
||||
private fun watchLaunchRequests(dir: File) {
|
||||
launchWatcher?.stopWatching()
|
||||
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready").forEach { File(dir, it).delete() }
|
||||
listOf("steam-sleep", "steam-sleep-state", "steam-sleep-ready", "steam-wake", "steam-wake.tmp",
|
||||
"steam-native-sleep", "steam-native-sleep.tmp", "steam-native-ready", "steam-native-ready.tmp"
|
||||
).forEach { File(dir, it).delete() }
|
||||
val gen = sessionGen
|
||||
@Suppress("DEPRECATION")
|
||||
val watcher = object : android.os.FileObserver(dir.path, CLOSE_WRITE or MOVED_TO) {
|
||||
override fun onEvent(event: Int, path: String?) {
|
||||
if (path == "steam-native-ready") {
|
||||
val request = File(dir, path)
|
||||
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
|
||||
request.delete()
|
||||
mainHandler.post {
|
||||
if (gen != sessionGen || !SessionState.running || token != nativeSleepToken) return@post
|
||||
nativeSleepReady = true
|
||||
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
nativeSleepTimeout = null
|
||||
writeSteamSleepState(token, "paused")
|
||||
SessionEvents.record("steam.native_sleep_prepared")
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (path == "steam-sleep") {
|
||||
val request = File(dir, path)
|
||||
val token = runCatching { request.readText().trim() }.getOrNull() ?: return
|
||||
@@ -1142,18 +1192,27 @@ class SessionService : Service() {
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
|
||||
manualPauseRequested = true
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden) {
|
||||
completeNativeSleep()
|
||||
} else if (nativeSleepToken == null && steamSleepToken == null &&
|
||||
!SessionState.suspended && !suspendAttemptFailed) {
|
||||
prepareNativeSleep()
|
||||
}
|
||||
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
|
||||
else -> false
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
|
||||
val gen = sessionGen
|
||||
suspendOperationPending = true
|
||||
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
|
||||
"policy" to suspendPolicy, "activityVisible" to activityVisible,
|
||||
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
|
||||
"nativeSleep" to (nativeSleepToken != null),
|
||||
))
|
||||
if (shouldSuspend) {
|
||||
controller.freeze { success ->
|
||||
@@ -1166,6 +1225,7 @@ class SessionService : Service() {
|
||||
releaseLocks()
|
||||
refreshNotification()
|
||||
} else {
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = true
|
||||
Log.w(TAG, "could not confirm that the session stopped")
|
||||
@@ -1211,7 +1271,63 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
private fun resumeSteamSleepOnReturn() {
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_AUTO && activityVisible && screenOn) completeSteamSleep()
|
||||
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) {
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
}
|
||||
}
|
||||
|
||||
/** Let Steam prepare before freezing; retain the wake lock until that freeze completes. */
|
||||
private fun prepareNativeSleep() {
|
||||
val token = UUID.randomUUID().toString().replace("-", "")
|
||||
nativeSleepToken = token
|
||||
nativeSleepReady = false
|
||||
nativeSleepFallback = false
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
runCatching {
|
||||
val staged = File(dir, "steam-native-sleep.tmp")
|
||||
staged.writeText("$token\n")
|
||||
check(staged.renameTo(File(dir, "steam-native-sleep")))
|
||||
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
|
||||
SessionEvents.record("steam.native_sleep_requested")
|
||||
val gen = sessionGen
|
||||
val timeout = Runnable {
|
||||
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
|
||||
nativeSleepTimeout = null
|
||||
nativeSleepReady = true
|
||||
nativeSleepFallback = true
|
||||
writeSteamSleepState(token, "paused")
|
||||
SessionEvents.record("steam.native_sleep_timeout")
|
||||
updateSuspendPolicy()
|
||||
}
|
||||
nativeSleepTimeout = timeout
|
||||
// A missing or stalled guest helper must not keep the device awake indefinitely.
|
||||
mainHandler.postDelayed(timeout, 4000L)
|
||||
}
|
||||
|
||||
private fun completeNativeSleep() {
|
||||
nativeSleepTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
nativeSleepTimeout = null
|
||||
val token = nativeSleepToken ?: return
|
||||
writeSteamSleepState(token, "awake")
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
File(dir, "steam-native-sleep").delete()
|
||||
File(dir, "steam-native-ready").delete()
|
||||
nativeSleepToken = null
|
||||
nativeSleepReady = false
|
||||
nativeSleepFallback = false
|
||||
}
|
||||
|
||||
/** Resume must also recover a Steam sleep whose request never reached Android. */
|
||||
private fun requestSteamWake() {
|
||||
if (!SessionState.running || SessionState.mode != MODE_STEAM) return
|
||||
val dir = LinuxRuntime.sessionRoot(this)
|
||||
runCatching {
|
||||
val staged = File(dir, "steam-wake.tmp")
|
||||
staged.writeText("wake\n")
|
||||
check(staged.renameTo(File(dir, "steam-wake")))
|
||||
}.onFailure { Log.w(TAG, "could not request Steam wake", it) }
|
||||
}
|
||||
|
||||
private fun finishSessionStop(status: Int, stoppedGen: Int) {
|
||||
@@ -1266,6 +1382,7 @@ class SessionService : Service() {
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
cancelQueuedSteamGame()
|
||||
completeNativeSleep()
|
||||
synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
SessionState.running = false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
object SteamRepair {
|
||||
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
|
||||
private fun request(context: Context) = File(home(context), ".bl-steam-repair")
|
||||
private fun unconfirmed(context: Context) = File(home(context), ".local/share/Steam/package/droiddeck-unconfirmed")
|
||||
|
||||
fun queued(context: Context): Boolean = request(context).exists()
|
||||
|
||||
fun queue(context: Context): Boolean = runCatching {
|
||||
request(context).apply { parentFile?.mkdirs() }.writeText("1\n")
|
||||
}.isSuccess
|
||||
|
||||
fun clientShown(context: Context) {
|
||||
unconfirmed(context).delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
/**
|
||||
* What to call a catalog component on screen. Bannerlator's catalog keys (vcredist2010_dll,
|
||||
* oalinst_dll, d3dcompiler_47...) have no display name, and some descriptions do not fit the entry,
|
||||
* so names come from here: families by pattern, the rest from a table, the key itself otherwise.
|
||||
* The "_dll" twin of an installer (the direct DLL copy) shares the installer's name.
|
||||
*/
|
||||
object WinComponentNames {
|
||||
private val TABLE = mapOf(
|
||||
"oalinst" to "OpenAL", "XLiveRedist" to "Games for Windows Live", "gfw" to "Games for Windows Live",
|
||||
"physx" to "NVIDIA PhysX", "xact" to "DirectX XACT audio", "xact_x64" to "DirectX XACT audio (64-bit)",
|
||||
"xinput" to "XInput", "xna31" to "XNA Framework 3.1", "xna40" to "XNA Framework 4.0",
|
||||
"d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
|
||||
"directmusic" to "DirectMusic", "directplay" to "DirectPlay", "directshow" to "DirectShow",
|
||||
"dsound" to "DirectSound", "dx8vb" to "DirectX 8 for Visual Basic", "mediafoundation" to "Media Foundation",
|
||||
"quartz" to "DirectShow (quartz)", "amstream" to "DirectShow streams (amstream)", "devenum" to "DirectShow devices (devenum)",
|
||||
"cnc-ddraw" to "cnc-ddraw (DirectDraw)", "gdiplus" to "GDI+", "gecko" to "Wine Gecko", "mono" to "Wine Mono",
|
||||
"vcredist6" to "Visual C++ 6", "vcredist6sp6" to "Visual C++ 6 SP6",
|
||||
"vcredist2015" to "Visual C++ 2015", "vcredist2019" to "Visual C++ 2015-2019", "vcredist2022" to "Visual C++ 2015-2022",
|
||||
"vbrun6" to "Visual Basic 6 runtime", "mfc40" to "MFC 4.0", "mfc42" to "MFC 4.2",
|
||||
"dotnetcore3" to ".NET Core 3", "dotnetcoredesktop3" to ".NET Desktop 3", "dotnetcoredesktop6" to ".NET Desktop 6",
|
||||
"dotnetcoredesktop7" to ".NET Desktop 7", "dotnetcoredesktop8" to ".NET Desktop 8", "dotnet50" to ".NET 5",
|
||||
"webview2" to "Microsoft WebView2", "powershell" to "PowerShell", "powershell_core" to "PowerShell Core",
|
||||
"K-Lite" to "K-Lite Codec Pack", "VulkanRT" to "Vulkan Runtime", "ffdshow" to "ffdshow codecs",
|
||||
"lavfilters702" to "LAV Filters 0.70.2", "lavfilters741" to "LAV Filters 0.74.1", "quicktime72" to "QuickTime 7.2",
|
||||
"cjkfonts" to "CJK fonts", "jet40" to "Jet 4.0 database", "mdac28" to "MDAC 2.8", "wsh57" to "Windows Script Host 5.7",
|
||||
"riched20" to "RichEdit 2.0", "msftedit" to "RichEdit 4.1 (msftedit)", "sqlite3" to "SQLite 3",
|
||||
"win7" to "Windows 7 mode", "winXP" to "Windows XP mode", "gmdls" to "General MIDI sounds (gm.dls)",
|
||||
)
|
||||
private val VCREDIST = Regex("vcredist(\\d{4})")
|
||||
private val DOTNET = Regex("dotnet(\\d)(\\d)(\\d?)(sp\\d)?")
|
||||
private val D3DCOMPILER = Regex("d3dcompiler_(\\d+)")
|
||||
private val MSXML = Regex("msxml(\\d)")
|
||||
private val MONO = Regex("mono-([\\d.]+)")
|
||||
private val XAUDIO = Regex("xaudio([\\d.]+)")
|
||||
|
||||
fun of(id: String): String {
|
||||
val base = id.removeSuffix("_dll")
|
||||
TABLE[base]?.let { return it }
|
||||
VCREDIST.matchEntire(base)?.let { return "Visual C++ " + it.groupValues[1] }
|
||||
DOTNET.matchEntire(base)?.let { m ->
|
||||
val (major, minor, patch, sp) = m.destructured
|
||||
return ".NET Framework $major.$minor" + (if (patch.isNotEmpty()) ".$patch" else "") +
|
||||
(if (sp.isNotEmpty()) " " + sp.uppercase() else "")
|
||||
}
|
||||
D3DCOMPILER.matchEntire(base)?.let { return "D3DCompiler " + it.groupValues[1] }
|
||||
MSXML.matchEntire(base)?.let { return "MSXML " + it.groupValues[1] }
|
||||
MONO.matchEntire(base)?.let { return "Wine Mono " + it.groupValues[1] }
|
||||
XAUDIO.matchEntire(base)?.let { return "XAudio " + it.groupValues[1] }
|
||||
if (base.startsWith("dm") || base == "dswave" || base == "dsdmo") return "DirectMusic ($base)"
|
||||
return id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.ArchivePaths
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.GuestCommand
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Windows components (OpenAL, d3dx9, XAudio, VC++ DLLs...) for a game's Proton prefix, picked per
|
||||
* game, from Bannerlator's catalog (components.json on winlator-contents, Bottles' manifest format).
|
||||
*
|
||||
* Installing one runs its steps once, into the runtime: the archives it names are downloaded and
|
||||
* its DLLs copied to /opt/droiddeck/wincomponents/<name>/{system32,syswow64}, with component.json
|
||||
* holding its DLL overrides and the components it bundles. The picks go to the runtime as
|
||||
* ~/.config/droiddeck/wincomponents.json, and droiddeck-wincomponents copies the picked components
|
||||
* into the game's prefix at every launch (see that script for why every launch).
|
||||
*
|
||||
* A component whose installer is a Windows Installer package (.msi: XNA, PhysX, Games for Windows
|
||||
* Live, MSXML...) installs here too, without running it: droiddeck-msi-install reads the package's
|
||||
* tables in the runtime and lays out its files (Program Files, the .NET GAC, winsxs) and registry
|
||||
* values the way Wine's msiexec would, under the same store folder. One that runs any other
|
||||
* installer (.exe), unpacks a cabinet or edits the prefix's registry by itself is listed with the
|
||||
* reason it cannot be installed yet.
|
||||
*/
|
||||
object WinComponents {
|
||||
private const val TAG = "WinComponents"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
|
||||
private const val SELECTION = "wincomponents.json"
|
||||
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
|
||||
private const val STORE = "opt/droiddeck/wincomponents"
|
||||
private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
// register_dll is left out on purpose: the DLLs it names are ones Wine ships and has registered
|
||||
// at the same path, so the native file at that path is the one the registration loads.
|
||||
private val FILE_STEPS = setOf("download_archive", "archive_extract", "copy_dll", "copy_file", "override_dll", "register_dll")
|
||||
private val INSTALLER_STEPS = setOf("install_exe", "install_msi")
|
||||
private const val MSI_INSTALL = "/usr/local/bin/droiddeck-msi-install"
|
||||
/** Where a downloaded .msi waits for the installer, inside the runtime so it can read it. */
|
||||
private const val MSI_CACHE = "$STORE/.packages"
|
||||
/** Catalog entries Proton already provides newer copies of, which an install would only shadow. */
|
||||
private val PROTON_PROVIDES = setOf("gecko")
|
||||
|
||||
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
|
||||
|
||||
class Step(val action: String, val json: JSONObject) {
|
||||
fun str(key: String): String = json.optString(key, "")
|
||||
}
|
||||
|
||||
class Component(
|
||||
val name: String, val description: String, val provider: String, val status: String,
|
||||
val dependencies: List<String>, val steps: List<Step>,
|
||||
)
|
||||
|
||||
enum class Support { READY, NEEDS_INSTALLER, UNSUPPORTED }
|
||||
|
||||
/** A step that installs a Windows Installer package, which droiddeck-msi-install does here. */
|
||||
private fun isMsi(step: Step): Boolean = step.action in INSTALLER_STEPS && step.str("url").startsWith("https://") &&
|
||||
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
|
||||
.any { it.endsWith(".msi", ignoreCase = true) })
|
||||
|
||||
fun fetch(): List<Component>? {
|
||||
val body = Downloader.downloadString(CATALOG_URL) ?: return null
|
||||
return try {
|
||||
val arr = JSONObject(body).getJSONArray("components")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name")
|
||||
if (!validId(name)) return@mapNotNull null
|
||||
val steps = o.optJSONArray("steps") ?: JSONArray()
|
||||
val deps = o.optJSONArray("dependencies") ?: JSONArray()
|
||||
Component(
|
||||
name, o.optString("description"), o.optString("provider"), o.optString("status"),
|
||||
(0 until deps.length()).map { deps.getString(it) }.filter { validId(it) },
|
||||
(0 until steps.length()).map { steps.getJSONObject(it).let { s -> Step(s.optString("action"), s) } },
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "catalog: $e"); null
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether [c] can be installed here, its bundled components included. */
|
||||
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
|
||||
if (c.status != "ready" || depth > 8 || c.name in PROTON_PROVIDES) return Support.UNSUPPORTED
|
||||
val installers = c.steps.filter { it.action in INSTALLER_STEPS }
|
||||
val own = when {
|
||||
installers.isNotEmpty() && installers.all { isMsi(it) } &&
|
||||
c.steps.all { it in installers || it.action == "delete_dlls" || it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
|
||||
installers.isNotEmpty() -> Support.NEEDS_INSTALLER
|
||||
c.steps.all { it.action in FILE_STEPS && fileStepOk(it) } -> Support.READY
|
||||
else -> Support.UNSUPPORTED
|
||||
}
|
||||
if (own != Support.READY) return own
|
||||
return c.dependencies.map { all[it]?.let { d -> support(d, all, depth + 1) } ?: Support.UNSUPPORTED }
|
||||
.maxOrNull() ?: Support.READY
|
||||
}
|
||||
|
||||
private fun fileStepOk(step: Step): Boolean = when (step.action) {
|
||||
"download_archive", "archive_extract" -> step.str("url").let { url ->
|
||||
!url.startsWith("http") || url.startsWith("https://github.com/") &&
|
||||
url.substringBefore('?').let { it.endsWith(".tar.xz") || it.endsWith(".zip") }
|
||||
}
|
||||
"copy_dll", "copy_file" -> ".." !in step.str("dest")
|
||||
else -> true
|
||||
}
|
||||
|
||||
/** The installed version of a component, or null. */
|
||||
fun installed(context: Context, id: String): String? = runCatching {
|
||||
JSONObject(File(LinuxRuntime.rootDir(context), "$STORE/$id/component.json").readText()).optString("version", "?")
|
||||
}.getOrNull()
|
||||
|
||||
/** Every component in the runtime, by id. */
|
||||
fun installedIds(context: Context): List<String> =
|
||||
File(LinuxRuntime.rootDir(context), STORE).listFiles { f -> f.isDirectory && validId(f.name) }
|
||||
.orEmpty().map { it.name }.filter { installed(context, it) != null }.sorted()
|
||||
|
||||
/**
|
||||
* Installs [c] and the components it bundles that are not installed yet. [onProgress] gets a
|
||||
* line for the step and 0..100 (or -1). Returns null on success, else a message.
|
||||
*/
|
||||
fun install(context: Context, c: Component, all: Map<String, Component>, onProgress: (String, Int) -> Unit): String? {
|
||||
if (support(c, all) != Support.READY) return "${c.name} cannot be installed here yet"
|
||||
val order = ArrayList<Component>()
|
||||
fun visit(x: Component, depth: Int) {
|
||||
if (depth > 8 || order.any { it.name == x.name }) return
|
||||
x.dependencies.mapNotNull { all[it] }.forEach { visit(it, depth + 1) }
|
||||
if (x.name == c.name || installed(context, x.name) == null) order.add(x)
|
||||
}
|
||||
visit(c, 0)
|
||||
// One download per archive however many components copy out of it (DirectMusic's eleven
|
||||
// parts all come from dxnt, 100 MB).
|
||||
val work = File(context.cacheDir, "wincomponents").apply { FileUtils.delete(this); mkdirs() }
|
||||
val archives = HashMap<String, File>()
|
||||
return try {
|
||||
for (x in order) {
|
||||
onProgress(x.name, -1)
|
||||
installOne(context, x, work, archives, onProgress)?.let { return "${x.name}: $it" }
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "install ${c.name}", e)
|
||||
e.message ?: "Install failed"
|
||||
} finally {
|
||||
FileUtils.delete(work)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installOne(context: Context, c: Component, work: File, archives: HashMap<String, File>, onProgress: (String, Int) -> Unit): String? {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (!root.isDirectory) return "The Linux runtime is not installed"
|
||||
val staging = File(root, "$STORE/.${c.name}.new").apply { FileUtils.delete(this); mkdirs() }
|
||||
val overrides = ArrayList<String>()
|
||||
var source: File? = null
|
||||
var msi: JSONObject? = null
|
||||
for (step in c.steps) when (step.action) {
|
||||
// A component may have several packages (PowerShell's 32- and 64-bit): each adds to the
|
||||
// same folder, the first one names the component.
|
||||
"install_msi", "install_exe" -> {
|
||||
val (result, problem) = installMsi(context, c, step, staging, onProgress)
|
||||
if (result == null) return problem
|
||||
msi = msi?.apply {
|
||||
val notes = optJSONArray("notes") ?: JSONArray().also { put("notes", it) }
|
||||
result.optJSONArray("notes")?.let { for (i in 0 until it.length()) notes.put(it.get(i)) }
|
||||
} ?: result
|
||||
}
|
||||
// A catalog step that makes room for the installer's own copy; ours is copied anyway.
|
||||
"delete_dlls" -> Unit
|
||||
"download_archive", "archive_extract" -> {
|
||||
val url = step.str("url")
|
||||
if (!url.startsWith("http")) continue
|
||||
source = archives[url] ?: run {
|
||||
val name = url.substringBefore('?').substringAfterLast('/')
|
||||
val file = File(work, "${archives.size}-$name")
|
||||
val ok = Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }
|
||||
if (!ok) return "download failed: $name"
|
||||
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
|
||||
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return "checksum mismatch: $name"
|
||||
}
|
||||
onProgress("${c.name}: unpacking $name", -1)
|
||||
val dir = File(work, "${archives.size}-x").apply { mkdirs() }
|
||||
if (!extract(file, dir)) return "could not unpack $name"
|
||||
file.delete()
|
||||
dir.also { archives[url] = it }
|
||||
}
|
||||
}
|
||||
"copy_dll", "copy_file" -> {
|
||||
val from = source ?: return "a copy step before any archive"
|
||||
val dest = step.str("dest")
|
||||
val (arch, base) = when (dest.substringBefore('/').lowercase()) {
|
||||
"win64", "system32" -> "win64" to "system32"
|
||||
"win32", "syswow64" -> "win32" to "syswow64"
|
||||
else -> null to "system32"
|
||||
}
|
||||
val sub = dest.substringAfter('/', "")
|
||||
copyMatching(from, step.str("file_name"), File(staging, if (sub.isEmpty()) base else "$base/$sub"), arch)
|
||||
}
|
||||
"override_dll" -> {
|
||||
fun add(dll: String, type: String) {
|
||||
if (dll.isEmpty()) return
|
||||
overrides += when (type.ifEmpty { "native,builtin" }) {
|
||||
"native,builtin" -> dll
|
||||
"native" -> "$dll=n"
|
||||
"builtin" -> "$dll=b"
|
||||
"builtin,native" -> "$dll=b,n"
|
||||
else -> dll
|
||||
}
|
||||
}
|
||||
add(step.str("dll"), step.str("type"))
|
||||
step.json.optJSONArray("bundle")?.let { b ->
|
||||
for (i in 0 until b.length()) b.getJSONObject(i).let { add(it.optString("value"), it.optString("data")) }
|
||||
}
|
||||
}
|
||||
"register_dll" -> Unit
|
||||
else -> return "unsupported step ${step.action}"
|
||||
}
|
||||
val version = msi?.optString("version")?.takeIf { it.isNotEmpty() }
|
||||
?: Regex("""\b(\d+\.\d+(\.\d+)*)\b""").find(c.description)?.value ?: "catalog"
|
||||
val meta = JSONObject()
|
||||
.put("id", c.name).put("version", version)
|
||||
.put("overrides", JSONArray(overrides.distinct()))
|
||||
.put("requires", JSONArray(c.dependencies))
|
||||
msi?.let { meta.put("kind", "msi").put("product", it.optString("product")).put("notes", it.optJSONArray("notes") ?: JSONArray()) }
|
||||
FileUtils.writeString(File(staging, "component.json"), meta.toString(1))
|
||||
val target = File(root, "$STORE/${c.name}")
|
||||
FileUtils.delete(target)
|
||||
if (!staging.renameTo(target)) return "could not place the files"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the component's .msi into the runtime and has droiddeck-msi-install lay it out in
|
||||
* [staging]. Returns the installer's result (product, version, counts, notes), or null and why.
|
||||
* Everything the installer prints also goes to Download/DroidDeck/tools.
|
||||
*/
|
||||
private fun installMsi(context: Context, c: Component, step: Step, staging: File, onProgress: (String, Int) -> Unit): Pair<JSONObject?, String> {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
val url = step.str("url")
|
||||
val name = url.substringBefore('?').substringAfterLast('/')
|
||||
val cache = File(root, MSI_CACHE).apply { mkdirs() }
|
||||
val file = File(cache, "${c.name}.msi")
|
||||
try {
|
||||
if (!Downloader.downloadFile(url, file, false) { f -> onProgress("${c.name}: $name", if (f < 0) -1 else Math.round(f * 100f)) }) {
|
||||
return null to "download failed: $name"
|
||||
}
|
||||
step.str("file_checksum").takeIf { it.length == 32 }?.let { md5 ->
|
||||
if (!digest(file, "MD5").equals(md5, ignoreCase = true)) return null to "checksum mismatch: $name"
|
||||
}
|
||||
onProgress("${c.name}: installing", -1)
|
||||
var result: JSONObject? = null
|
||||
var problem: String? = null
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> onProgress("${c.name}: ${line.removePrefix("progress ")}", -1)
|
||||
line.startsWith("result ") -> result = runCatching { JSONObject(line.removePrefix("result ")) }.getOrNull()
|
||||
line.startsWith("error ") -> problem = line.removePrefix("error ")
|
||||
}
|
||||
}
|
||||
val done = result
|
||||
if (status != 0 || done == null) return null to (problem ?: "the installer stopped (status $status)")
|
||||
return done to ""
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "msi ${c.name}", e)
|
||||
return null to (e.message ?: "the installer failed")
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun digest(file: File, algorithm: String): String {
|
||||
val md = MessageDigest.getInstance(algorithm)
|
||||
FileInputStream(file).use { input ->
|
||||
val buffer = ByteArray(1 shl 16)
|
||||
while (true) { val n = input.read(buffer); if (n < 0) break; md.update(buffer, 0, n) }
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun extract(archive: File, dest: File): Boolean = try {
|
||||
if (archive.name.endsWith(".zip", ignoreCase = true)) ZipInputStream(BufferedInputStream(FileInputStream(archive))).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val out = ArchivePaths.inside(dest, entry.name)
|
||||
if (out != null && !entry.isDirectory) {
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { FileUtils.copy(zip, it) }
|
||||
}
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
} else TarArchiveInputStream(XZCompressorInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use { tar ->
|
||||
var entry = tar.nextEntry
|
||||
while (entry != null) {
|
||||
val out = ArchivePaths.inside(dest, entry.name)
|
||||
if (out != null && entry.isFile) {
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { FileUtils.copy(tar, it) }
|
||||
}
|
||||
entry = tar.nextEntry
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "extract ${archive.name}", e); false
|
||||
}
|
||||
|
||||
/**
|
||||
* Bannerlator's rule: the manifest's own temp paths are Bottles', so the files are found by name
|
||||
* anywhere in the unpacked tree, preferring the ones under the matching win32/win64 folder.
|
||||
*/
|
||||
private fun copyMatching(srcRoot: File, pattern: String, dest: File, arch: String?) {
|
||||
if (pattern.isEmpty()) return
|
||||
val rx = Regex("^" + pattern.split("*").joinToString(".*") { Regex.escape(it) } + "$", RegexOption.IGNORE_CASE)
|
||||
var matches = srcRoot.walkTopDown().filter { it.isFile && rx.matches(it.name) }.toList()
|
||||
if (arch != null) {
|
||||
val inArch = matches.filter { it.path.contains("/$arch/", true) }
|
||||
val other = if (arch == "win64") "win32" else "win64"
|
||||
matches = inArch.ifEmpty { matches.filterNot { it.path.contains("/$other/", true) } }
|
||||
}
|
||||
dest.mkdirs()
|
||||
matches.forEach { f -> f.copyTo(File(dest, f.name.lowercase()), overwrite = true) }
|
||||
}
|
||||
|
||||
/** Removes a component from the runtime and from every game's picks. */
|
||||
@Synchronized
|
||||
fun uninstall(context: Context, id: String) {
|
||||
require(validId(id))
|
||||
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
|
||||
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
/** Each game's picks, by the appid its prefix is named after. */
|
||||
@Synchronized
|
||||
fun read(context: Context): Map<String, List<String>> = try {
|
||||
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
|
||||
val games = json.optJSONObject("games") ?: JSONObject()
|
||||
games.keys().asSequence().associateWith { app ->
|
||||
val ids = games.getJSONArray(app)
|
||||
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
|
||||
}
|
||||
} catch (_: java.io.FileNotFoundException) {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
|
||||
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = read(context).toMutableMap()
|
||||
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, List<String>>) {
|
||||
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
|
||||
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
|
||||
}).toString()
|
||||
write(File(context.filesDir, SELECTION), text)
|
||||
publish(context, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the picks where the launch script reads them; SessionFiles calls this at every session
|
||||
* start too, so a runtime reinstalled since keeps them. An empty selection still writes a file,
|
||||
* which is how a component turned off for every game is taken out of their prefixes.
|
||||
*/
|
||||
@Synchronized
|
||||
fun publish(context: Context, text: String? = null) {
|
||||
val body = text ?: runCatching { AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8) }.getOrNull() ?: return
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
if (root.isDirectory) write(File(root, GUEST_SELECTION), body)
|
||||
}
|
||||
|
||||
private fun write(file: File, text: String) {
|
||||
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
try {
|
||||
output.write(text.toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal fun scaledAppDensity(base: Density, percent: Int): Density {
|
||||
val scale = AppUiPrefs.normalizeScale(percent) / 100f
|
||||
return if (scale == 1f) base else Density(base.density * scale, base.fontScale)
|
||||
}
|
||||
|
||||
/** Responsive layouts and popup height limits must see the same dp space as the scaled UI. */
|
||||
internal fun scaledAppConfiguration(base: Configuration, percent: Int): Configuration {
|
||||
val scale = AppUiPrefs.normalizeScale(percent) / 100f
|
||||
if (scale == 1f) return base
|
||||
return Configuration(base).apply {
|
||||
if (densityDpi > 0) densityDpi = (densityDpi * scale).roundToInt()
|
||||
if (screenWidthDp > 0) screenWidthDp = (screenWidthDp / scale).roundToInt()
|
||||
if (screenHeightDp > 0) screenHeightDp = (screenHeightDp / scale).roundToInt()
|
||||
if (smallestScreenWidthDp > 0) smallestScreenWidthDp = (smallestScreenWidthDp / scale).roundToInt()
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
}
|
||||
}
|
||||
Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
|
||||
Rise(2) { LaunchSettings(s, a, host) }
|
||||
Rise(2) { LaunchSettings(s, a, host, current) }
|
||||
Rise(3) { GameFileFolderActions(s, a) }
|
||||
}
|
||||
return
|
||||
@@ -109,7 +109,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
|
||||
GameActions(current, s, a)
|
||||
}
|
||||
SectionTitle(stringResource(R.string.games_launch_settings), null)
|
||||
LaunchSettings(s, a, host)
|
||||
LaunchSettings(s, a, host, current)
|
||||
GameFileFolderActions(s, a)
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/** A page's title, with room at its right for a status chip. */
|
||||
/** A page's title, with room at its right for controls or status. */
|
||||
@Composable
|
||||
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
Row(
|
||||
|
||||
@@ -101,6 +101,7 @@ class FrontEndState(
|
||||
val gameSyncFolder: String? = null,
|
||||
val pageKey: String? = null,
|
||||
val theme: String = Themes.GRAPHITE,
|
||||
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
|
||||
val isHomeApp: Boolean = false,
|
||||
val homeScreenEnabled: Boolean = false,
|
||||
val defaultHomeLabel: String? = null,
|
||||
@@ -168,6 +169,7 @@ class FrontEndActions(
|
||||
val onOffline: () -> Unit,
|
||||
val onPageBack: () -> Unit = {},
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onAppScale: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
|
||||
@@ -162,8 +162,6 @@ internal fun SetupPanel(
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
val checks = 4
|
||||
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
@@ -187,9 +185,8 @@ internal fun SetupPanel(
|
||||
),
|
||||
) {
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
|
||||
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
|
||||
}
|
||||
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
@@ -303,7 +300,8 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
|
||||
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
|
||||
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
|
||||
ToggleRow(
|
||||
host, "offline", stringResource(R.string.setup_offline),
|
||||
@@ -314,6 +312,15 @@ internal fun SetupPanel(
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_launcher)) {
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
percent to stringResource(
|
||||
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
|
||||
else R.string.ctrl_percent, percent,
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
@@ -415,10 +422,20 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
* app-wide settings - each card opens the same page or menu Setup does.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
|
||||
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) {
|
||||
// Three across, two on a narrow page; each row's cards share one height.
|
||||
val columns = if (LocalNarrowPane.current) 2 else 3
|
||||
val controller = a.controller
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
|
||||
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
|
||||
val wincompKey = game?.let {
|
||||
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
|
||||
else it.appId.takeIf { id -> id > 0 }?.toString()
|
||||
}
|
||||
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
||||
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
|
||||
val cards = buildList<@Composable (Modifier) -> Unit> {
|
||||
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
|
||||
add { m ->
|
||||
@@ -430,7 +447,19 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
}
|
||||
}
|
||||
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
|
||||
if (wincompKey != null) add { m ->
|
||||
SettingCard(
|
||||
stringResource(R.string.wincomp_title),
|
||||
if (wincompPicks.isEmpty()) stringResource(R.string.wincomp_card_none) else wincompPicks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
|
||||
"card:wincomp", m,
|
||||
) { wincompOpen = inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard }
|
||||
}
|
||||
}
|
||||
val opened = wincompOpen
|
||||
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
|
||||
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
|
||||
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
|
||||
) { wincompOpen = null }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -206,8 +207,27 @@ internal fun PrimaryButton(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [label] while idle; while [progress] is set, a spinner and how far along it is in its place.
|
||||
* The label stays laid out underneath, invisible, so the button keeps its width. The spinner
|
||||
* turns until there is a first figure to show.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
internal fun ProgressLabel(label: @Composable (Modifier) -> Unit, progress: Float?, fontSize: androidx.compose.ui.unit.TextUnit = 15.sp) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
label(Modifier.alpha(if (progress == null) 1f else 0f))
|
||||
if (progress != null) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (progress > 0f) CircularProgressIndicator(progress = { progress }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.22f), modifier = Modifier.size(16.dp))
|
||||
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
|
||||
Text("${(progress * 100).toInt()}%", fontSize = fontSize, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src) && enabled
|
||||
@@ -222,7 +242,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp),
|
||||
) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) }
|
||||
) {
|
||||
ProgressLabel({ m -> Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1, modifier = m) },
|
||||
progress, fontSize = if (compact) 12.sp else 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -52,13 +52,13 @@ import kotlinx.coroutines.withContext
|
||||
// dialog, with each variable's actions in a menu off its value as everywhere else.
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
|
||||
// would otherwise give the pad nothing to move from.
|
||||
var byPad by remember { mutableStateOf(false) }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
|
||||
SettingsRow(stringResource(R.string.game_env_title), if (showHint) stringResource(R.string.game_env_hint) else null) {
|
||||
SecondaryButton(stringResource(R.string.game_env_edit), modifier = modifier) {
|
||||
byPad = inputMode.inputMode == InputMode.Keyboard
|
||||
open = true
|
||||
@@ -70,7 +70,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier) {
|
||||
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun PadFocus(byPad: Boolean, target: FocusRequester) {
|
||||
internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
|
||||
val inputMode = LocalInputModeManager.current
|
||||
LaunchedEffect(Unit) {
|
||||
if (!byPad) return@LaunchedEffect
|
||||
@@ -95,6 +95,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
val forceSsbs = SessionPrefs.forceSsbs(context)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var editByPad by remember { mutableStateOf(false) }
|
||||
val dialogInput = LocalInputModeManager.current
|
||||
@@ -159,7 +160,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
if (current == null && !error) Small(stringResource(R.string.game_env_loading))
|
||||
if (current != null) {
|
||||
val own = current.entries(scope)
|
||||
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
|
||||
val entries = GameEnvironment.effective(current, preset, scope, forceSsbs).toSortedMap()
|
||||
if (entries.isEmpty()) Small(stringResource(R.string.game_env_empty))
|
||||
else Panel {
|
||||
entries.entries.forEachIndexed { i, (name, value) ->
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.material.icons.outlined.ExpandLess
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.droiddeck.launcher.gpu.DeviceInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -180,29 +191,104 @@ private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: St
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
|
||||
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
|
||||
* off the main thread, with a button that copies it for a bug report.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val tint = when {
|
||||
s.unsupported -> pal.error
|
||||
s.supported -> pal.good
|
||||
else -> AttentionAmber
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(open) {
|
||||
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
|
||||
}
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val toggle = { open = !open; copied = false }
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
|
||||
}
|
||||
Icon(
|
||||
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
|
||||
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
|
||||
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
AnimatedVisibility(open) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
|
||||
HorizontalDivider(color = pal.line)
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 2)
|
||||
}
|
||||
}
|
||||
|
||||
/** One part of the device details: its title, then label/value rows. Focusable, so the d-pad walks the list. */
|
||||
@Composable
|
||||
private fun DeviceInfoSection(section: DeviceInfo.Section) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape12)
|
||||
.glideBorder(hot, Shape12, pal.signal)
|
||||
.focusable(interactionSource = src).padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
for ((label, value) in section.rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
|
||||
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +143,12 @@ internal fun LaunchFlood(
|
||||
val start = fromColors ?: (colors.primary to pal.primary2)
|
||||
VeilRing()
|
||||
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
|
||||
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
|
||||
// edge, off the page, so the wobble is felt in the pull. An underdamped spring then swings back
|
||||
// short of the edge (about 3% at this damping), so once an edge has reached the page's it holds
|
||||
// there, or the page would show through in a sliver while the next screen opens.
|
||||
val edges = remember { List(4) { Animatable(0f) } }
|
||||
val reached = remember { BooleanArray(4) }
|
||||
fun edge(i: Int) = edges[i].value.let { if (reached[i]) maxOf(it, 1f) else it }
|
||||
val covered by rememberUpdatedState(onCovered)
|
||||
val progress by rememberUpdatedState(onProgress)
|
||||
var page by remember { mutableStateOf(Size.Zero) }
|
||||
@@ -152,6 +156,7 @@ internal fun LaunchFlood(
|
||||
if (page == Size.Zero) return@LaunchedEffect
|
||||
if (Motion.scale == 0f) {
|
||||
edges.forEach { it.snapTo(1f) }
|
||||
reached.fill(true)
|
||||
progress(1f)
|
||||
covered()
|
||||
return@LaunchedEffect
|
||||
@@ -164,18 +169,19 @@ internal fun LaunchFlood(
|
||||
launch {
|
||||
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
|
||||
edge.animateTo(1f, spring(dampingRatio = 0.48f, stiffness = lerp(110f, 190f, lead))) {
|
||||
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
|
||||
if (value >= 1f) reached[i] = true
|
||||
progress((0 until 4).sumOf { edge(it).coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Covered once every edge has reached the page's, the first time round.
|
||||
snapshotFlow { edges.all { it.value >= 0.995f } }.first { it }
|
||||
// Covered once every edge has reached the page's.
|
||||
snapshotFlow { (0 until 4).all { edge(it) >= 1f } }.first { it }
|
||||
covered()
|
||||
runs.forEach { it.join() }
|
||||
}
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
|
||||
drawFlood(from, edges.map { it.value }, start, pal.signal, cornerAtRest.toPx())
|
||||
drawFlood(from, List(4) { edge(it) }, start, pal.signal, cornerAtRest.toPx())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,8 @@ class DownloadRow(val key: String, val label: String, val detail: String, val pr
|
||||
|
||||
class ModeSettings(
|
||||
val mode: String,
|
||||
val resolutionCap: Int,
|
||||
/** A fixed session size, or null for the cap and shape. */
|
||||
val customResolution: Pair<Int, Int>? = null,
|
||||
val shapeMode: String,
|
||||
val resolution: String,
|
||||
val panelSize: Pair<Int, Int> = 1280 to 720,
|
||||
val hdr: Boolean,
|
||||
val hdrReason: String?,
|
||||
/** The GPU drivers in use, as the row that opens them on the Components page says it. */
|
||||
@@ -71,16 +69,19 @@ class ModeSettings(
|
||||
val storageOptions: List<Pair<String, String>> = emptyList(),
|
||||
/** Steam only: record allocation and sampled storage timings in the next session's Share logs. */
|
||||
val storageDiagnostics: Boolean = false,
|
||||
/** Steam only: report Steam's space reservation on the second library as done (experimental). */
|
||||
val skipPreallocation: Boolean = false,
|
||||
val fexPreset: String? = null,
|
||||
/** Steam only: Force SSBS for Proton games (SessionPrefs.forceSsbs). */
|
||||
val forceSsbs: Boolean = false,
|
||||
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
|
||||
val syncBackend: String? = null,
|
||||
/** Steam only: games are stretched to fill the screen (null = not a Steam page). */
|
||||
val forceFullscreen: Boolean? = null,
|
||||
val stretch16x9: Boolean? = null,
|
||||
/** Steam only: the client branch forced on the command line. */
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: enable the SteamOS client interface and its performance controls. */
|
||||
val steamDeckMode: Boolean = false,
|
||||
val steamRepairQueued: Boolean = false,
|
||||
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
|
||||
val mangoapp: Boolean = true,
|
||||
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
|
||||
@@ -112,10 +113,7 @@ class ModeSettings(
|
||||
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
|
||||
|
||||
class ModeSettingsActions(
|
||||
val onResolution: (Int) -> Unit,
|
||||
/** Null clears it. */
|
||||
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
|
||||
val onShape: (String) -> Unit,
|
||||
val onResolution: (String) -> Unit,
|
||||
val onHdr: (Boolean) -> Unit,
|
||||
/** Opens the GPU drivers on the Components page: they are shared by every session. */
|
||||
val onGpuDrivers: () -> Unit = {},
|
||||
@@ -134,11 +132,13 @@ class ModeSettingsActions(
|
||||
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
|
||||
val onPickGameStorageFolder: () -> Unit = {},
|
||||
val onStorageDiagnostics: (Boolean) -> Unit = {},
|
||||
val onSkipPreallocation: (Boolean) -> Unit = {},
|
||||
val onFexPreset: (String) -> Unit = {},
|
||||
val onForceSsbs: (Boolean) -> Unit = {},
|
||||
val onSyncBackend: (String) -> Unit = {},
|
||||
val onForceFullscreen: (Boolean) -> Unit = {},
|
||||
val onStretch16x9: (Boolean) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onSteamRepair: () -> Unit = {},
|
||||
val onSteamDeckMode: (Boolean) -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
@@ -158,139 +158,158 @@ class ModeSettingsActions(
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
private enum class ModeSettingsTab(val label: Int) {
|
||||
DISPLAY(R.string.drawer_page_display),
|
||||
CONTROLS(R.string.drawer_page_controls),
|
||||
GAMES(R.string.game_settings_title),
|
||||
AUDIO(R.string.mode_audio),
|
||||
SESSION(R.string.mode_session),
|
||||
STEAM(R.string.mode_tab_steam),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
var explainWifiDiscovery by remember { mutableStateOf(false) }
|
||||
val pageScroll = androidx.compose.foundation.rememberScrollState()
|
||||
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
|
||||
val tabs = if (steam) ModeSettingsTab.entries else listOf(
|
||||
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
|
||||
)
|
||||
val tab = tabs[tabIndex]
|
||||
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
|
||||
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
// One frame first: the box has to be laid out before it can take focus. Opened from the
|
||||
// cog, focus starts on the first control (Resolution) so the d-pad works at once.
|
||||
fun selectTab(index: Int) { host.open = null; tabIndex = index }
|
||||
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { firstChip.requestFocus() }
|
||||
runCatching {
|
||||
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
|
||||
else tabFocus[tabIndex].requestFocus()
|
||||
}
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
|
||||
title = null,
|
||||
onBack = a.onDismiss,
|
||||
scroll = pageScroll,
|
||||
scroll = scrolls[tabIndex],
|
||||
modifier = Modifier.bumpers(
|
||||
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
|
||||
onNext = { selectTab((tabIndex + 1) % tabs.size) },
|
||||
),
|
||||
header = {
|
||||
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
|
||||
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
|
||||
},
|
||||
) {
|
||||
SettingsGroup(stringResource(R.string.mode_display)) {
|
||||
val default = SessionPrefs.defaultResolutionCap(s.mode)
|
||||
var editCustom by remember { mutableStateOf(false) }
|
||||
val custom = s.customResolution
|
||||
ChoiceRow(
|
||||
host, "res", stringResource(R.string.mode_resolution), stringResource(R.string.common_applies_next_session),
|
||||
listOf(720 to stringResource(R.string.mode_res_720), 900 to stringResource(R.string.mode_res_900), 1080 to stringResource(R.string.mode_res_1080), 0 to stringResource(R.string.mode_res_native))
|
||||
.map { (cap, label) -> cap to (if (cap == default) stringResource(R.string.mode_res_default, label) else label) } +
|
||||
(CUSTOM to (custom?.let { stringResource(R.string.mode_res_custom_value, it.first, it.second) } ?: stringResource(R.string.mode_res_custom))),
|
||||
if (custom != null) CUSTOM else s.resolutionCap, note = stringResource(R.string.mode_res_note),
|
||||
chipModifier = androidx.compose.ui.Modifier.focusRequester(firstChip),
|
||||
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "shape", stringResource(R.string.mode_ratio),
|
||||
if (custom != null) stringResource(R.string.mode_ratio_custom) else stringResource(R.string.mode_ratio_auto),
|
||||
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
com.droiddeck.launcher.session.SessionPrefs.fpsLimitChoices, s.fpsLimit,
|
||||
note = stringResource(R.string.mode_fps_note),
|
||||
onPick = a.onFpsLimit,
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.drawer_scaling), "How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.",
|
||||
com.droiddeck.launcher.session.SessionPrefs.upscalerChoices, s.upscaler,
|
||||
note = "The sharpening modes work only when the session is smaller than the screen; Linear, Nearest and Sharpen only work at any size. Costs a little GPU time.",
|
||||
onPick = a.onUpscaler,
|
||||
)
|
||||
SliderRow(
|
||||
stringResource(R.string.drawer_scaling_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
enabled = s.upscaler != 0, format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
if (editCustom) CustomResolutionDialog(
|
||||
initial = custom,
|
||||
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
|
||||
onDismiss = { editCustom = false },
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
if (tab == ModeSettingsTab.DISPLAY) {
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
|
||||
chipModifier = Modifier.focusRequester(firstChip))
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
|
||||
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
|
||||
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
|
||||
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices, s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices, s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
if (tab == ModeSettingsTab.CONTROLS) {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), SessionPrefs.backActionsOrder(s.backActionsInverted),
|
||||
listOf(
|
||||
false to SessionPrefs.BACK_MENU_THEN_QAM,
|
||||
true to SessionPrefs.BACK_QAM_THEN_MENU,
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (tab == ModeSettingsTab.SESSION) {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
s.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
s.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
@@ -349,7 +368,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "steamdeck", stringResource(R.string.mode_deck_mode),
|
||||
stringResource(R.string.mode_deck_mode_hint),
|
||||
@@ -368,8 +387,14 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
note = stringResource(R.string.mode_branch_note),
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
) {
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
if (steam && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
|
||||
@@ -388,7 +413,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
|
||||
for (dir in s.addedGamesDirs) {
|
||||
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
|
||||
ActionRow(
|
||||
@@ -406,14 +431,16 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
stringResource(R.string.mode_added_art_hint),
|
||||
s.addedGamesArt, onChange = a.onAddedGamesArt,
|
||||
)
|
||||
for (g in s.addedGames) ChoiceRow(
|
||||
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
|
||||
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
|
||||
note = stringResource(R.string.mode_added_exe_note),
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
for (g in s.addedGames) {
|
||||
ChoiceRow(
|
||||
host, "added:" + g.folderPath, g.folderName, if (s.addedGamesDirs.size > 1) stringResource(R.string.mode_added_launches_in, g.exeName, g.folderPath.substringBeforeLast('/').substringAfterLast('/')) else stringResource(R.string.mode_added_launches, g.exeName),
|
||||
g.candidates + ("__pick__" to stringResource(R.string.mode_added_choose)), g.exePath,
|
||||
note = stringResource(R.string.mode_added_exe_note),
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (s.syncBackend != null) SettingsRow(
|
||||
stringResource(R.string.sync_backend_title),
|
||||
stringResource(R.string.sync_backend_hint),
|
||||
@@ -433,19 +460,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
|
||||
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
if (s.forceFullscreen != null) ToggleRow(
|
||||
host, "fill", stringResource(R.string.mode_fill),
|
||||
stringResource(R.string.mode_fill_hint),
|
||||
s.forceFullscreen, onChange = a.onForceFullscreen,
|
||||
)
|
||||
if (s.stretch16x9 != null) ToggleRow(
|
||||
host, "stretch169", stringResource(R.string.mode_stretch169),
|
||||
stringResource(R.string.mode_stretch169_hint),
|
||||
s.stretch16x9, onChange = a.onStretch16x9,
|
||||
)
|
||||
|
||||
}
|
||||
if (steam && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
|
||||
ChoiceRow(
|
||||
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
|
||||
@@ -454,7 +473,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
|
||||
}
|
||||
if (steam && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
|
||||
val options = buildList {
|
||||
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
|
||||
@@ -489,8 +508,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
stringResource(R.string.mode_storage_diagnostics_hint), s.storageDiagnostics,
|
||||
onChange = a.onStorageDiagnostics,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "skipPreallocation", stringResource(R.string.mode_skip_preallocation),
|
||||
stringResource(R.string.mode_skip_preallocation_hint), s.skipPreallocation,
|
||||
onChange = a.onSkipPreallocation,
|
||||
)
|
||||
}
|
||||
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
|
||||
if (!steam && tab == ModeSettingsTab.DISPLAY && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
|
||||
ChoiceRow(
|
||||
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
|
||||
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
|
||||
@@ -523,13 +547,10 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The Resolution menu's "Custom…" entry. */
|
||||
private const val CUSTOM = -1
|
||||
|
||||
/** Width × height for the session, with the common handheld shapes one tap away. */
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
|
||||
internal fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
|
||||
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
|
||||
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
|
||||
val parsed = SessionPrefs.parseResolution("${w}x$h")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.session.SessionDisplay
|
||||
|
||||
/** Pre-session resolution choice, applied when the session starts. */
|
||||
@Composable
|
||||
fun ResolutionRow(
|
||||
host: MenuHost, choice: String, panel: Pair<Int, Int>, onPick: (String) -> Unit,
|
||||
chipModifier: Modifier = Modifier,
|
||||
) {
|
||||
val custom = "custom"
|
||||
val size = SessionDisplay.resolveChoice(panel, choice)
|
||||
val screen = SessionDisplay.screenSize(panel)
|
||||
val choices = SessionDisplay.resolutionOptions(panel)
|
||||
val selected = if (size == screen) SessionDisplay.MATCH_SCREEN else choice
|
||||
var editCustom by remember { mutableStateOf(false) }
|
||||
val options = choices.map { value ->
|
||||
val dimensions = SessionDisplay.resolveChoice(panel, value)
|
||||
value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second)
|
||||
else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}")
|
||||
else "${dimensions.first}×${dimensions.second}"
|
||||
} + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
|
||||
else stringResource(R.string.mode_res_custom_value, size.first, size.second))
|
||||
ChoiceRow(host, "resolution", stringResource(R.string.display_resolution),
|
||||
stringResource(R.string.common_applies_next_session),
|
||||
options, if (selected in choices) selected else custom, chipModifier = chipModifier,
|
||||
onPick = { value -> if (value == custom) editCustom = true else onPick(value) })
|
||||
if (editCustom) CustomResolutionDialog(size,
|
||||
onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") },
|
||||
onDismiss = { editCustom = false })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
/** Stable page identities keep focus and scroll positions when a secondary display comes and goes. */
|
||||
internal enum class SessionDrawerPage(val firstControl: String) {
|
||||
CONTROLLER("touch"),
|
||||
SECOND_SCREEN("second-screen:none"),
|
||||
DISPLAY("hud"),
|
||||
EFFECTS("look"),
|
||||
GAMES("anisotropy"),
|
||||
SESSION("suspend"),
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPages(hasSecondScreen: Boolean): List<SessionDrawerPage> =
|
||||
SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN }
|
||||
|
||||
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
|
||||
val index = indexOf(current).coerceAtLeast(0)
|
||||
return this[Math.floorMod(index + direction, size)]
|
||||
}
|
||||
@@ -70,6 +70,9 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
// The session's loading screen, its paused screen and the HUD text style.
|
||||
@@ -226,7 +229,8 @@ private fun EndedActions(onRetry: (() -> Unit)?, onShareLogs: (() -> Unit)?, onC
|
||||
var firstUsed = false
|
||||
fun claim(): Modifier = if (firstUsed) Modifier else { firstUsed = true; Modifier.focusRequester(first) }
|
||||
if (onRetry != null) PrimaryButton(stringResource(R.string.store_try_again), modifier = claim(), onClick = onRetry)
|
||||
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(), onClick = onShareLogs)
|
||||
if (onShareLogs != null) SecondaryButton(stringResource(R.string.drawer_share_logs), modifier = claim(),
|
||||
progress = com.droiddeck.launcher.session.SessionLogShare.progress, onClick = onShareLogs)
|
||||
if (onClose != null) SecondaryButton(stringResource(R.string.mode_back), modifier = claim(), onClick = onClose)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +277,11 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
|
||||
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
|
||||
Text("B", color = colors.background, style = TextStyle(
|
||||
fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold,
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
|
||||
))
|
||||
}
|
||||
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@@ -28,15 +28,24 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.outlined.AutoFixHigh
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.ScreenShare
|
||||
import androidx.compose.material.icons.outlined.PictureInPictureAlt
|
||||
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.SportsEsports
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import android.view.Display
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -79,6 +88,8 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
@@ -93,7 +104,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -129,22 +139,33 @@ import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import java.util.Locale
|
||||
|
||||
private val drawerPageTitles = listOf(R.string.drawer_page_display, R.string.drawer_page_effects, R.string.drawer_page_controls, R.string.drawer_page_components, R.string.drawer_page_settings)
|
||||
private val SessionDrawerPage.title: Int get() = when (this) {
|
||||
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
|
||||
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
|
||||
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
|
||||
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
|
||||
SessionDrawerPage.GAMES -> R.string.game_settings_title
|
||||
SessionDrawerPage.SESSION -> R.string.mode_session
|
||||
}
|
||||
|
||||
/** One icon per drawer page, in page order (QAM-style tabs). */
|
||||
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.AutoFixHigh, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
|
||||
|
||||
private val drawerPageEntries = listOf("hud", "look", "touch", "cmp-proton", "suspend")
|
||||
private val SessionDrawerPage.icon get() = when (this) {
|
||||
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
|
||||
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
|
||||
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
|
||||
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
|
||||
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
|
||||
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
|
||||
}
|
||||
|
||||
private class DrawerFocus {
|
||||
private val requesters = HashMap<String, FocusRequester>()
|
||||
private val last = arrayOfNulls<String>(DRAWER_PAGES)
|
||||
private val last = mutableMapOf<SessionDrawerPage, String>()
|
||||
var focused by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() }
|
||||
|
||||
fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
|
||||
fun track(page: SessionDrawerPage, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
last[page] = key
|
||||
focused = key
|
||||
@@ -152,9 +173,9 @@ private class DrawerFocus {
|
||||
}
|
||||
|
||||
/** Where focus was last on [page], if anywhere. */
|
||||
fun target(page: Int) = last[page]
|
||||
fun target(page: SessionDrawerPage) = last[page]
|
||||
fun request(key: String) = runCatching { requester(key).requestFocus() }
|
||||
fun forget(page: Int) { last[page] = null }
|
||||
fun forget(page: SessionDrawerPage) { last.remove(page) }
|
||||
}
|
||||
|
||||
/** Everything the drawer shows and does. */
|
||||
@@ -168,14 +189,12 @@ class DrawerActions(
|
||||
val frameGen: FrameGen.Mode,
|
||||
val lossless: Lossless.State,
|
||||
val oscMode: String,
|
||||
val onScreenButtonsVisible: Boolean,
|
||||
val suspendPolicy: String,
|
||||
val backActionsInverted: Boolean,
|
||||
val touchMode: String,
|
||||
val touchAuto: String,
|
||||
val shapeMode: String,
|
||||
val fexPreset: String,
|
||||
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
|
||||
val fillScreen: Boolean? = null,
|
||||
val upscaler: Int = 0,
|
||||
val upscaleSharpness: Int = 75,
|
||||
/** The compositor's post chain (gpu/ScreenEffects), changed live. */
|
||||
@@ -204,9 +223,7 @@ class DrawerActions(
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onBackActionsInverted: (Boolean) -> Unit,
|
||||
val onTouch: (String) -> Unit,
|
||||
val onShape: (String) -> Unit,
|
||||
val onFexPreset: (String) -> Unit,
|
||||
val onFillScreen: (Boolean) -> Unit = {},
|
||||
val onUpscaler: (Int) -> Unit = {},
|
||||
val onUpscaleSharpness: (Int) -> Unit = {},
|
||||
val onEffects: (ScreenEffects) -> Unit = {},
|
||||
@@ -225,7 +242,7 @@ class DrawerActions(
|
||||
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
|
||||
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
|
||||
val onClose: () -> Unit,
|
||||
/** Components tab: every Proton with what it uses; null until first read. */
|
||||
/** Games tab: every Proton with what it uses; null until first read. */
|
||||
val components: ComponentsManager.Snapshot? = null,
|
||||
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
|
||||
val onComponentsRefresh: () -> Unit = {},
|
||||
@@ -235,16 +252,20 @@ class DrawerActions(
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) {
|
||||
internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) {
|
||||
val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty())
|
||||
val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER
|
||||
LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) }
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val host = rememberMenuHost()
|
||||
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var advancedEffectsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
|
||||
var confirmStop by remember { mutableStateOf(false) }
|
||||
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } }
|
||||
val pageScroll = remember { SessionDrawerPage.entries.associateWith { ScrollState(0) } }
|
||||
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
|
||||
val focus = remember { DrawerFocus() }
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
@@ -254,13 +275,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
BackHandler(enabled = open && confirmStop) { confirmStop = false }
|
||||
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
|
||||
LaunchedEffect(open, page) { if (open && page == DRAWER_PAGE_COMPONENTS) a.onComponentsRefresh() }
|
||||
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
|
||||
LaunchedEffect(open, controllerActive) {
|
||||
if (open && !controllerActive) focusManager.clearFocus(force = true)
|
||||
}
|
||||
// The page focus was last put on: turning to another (LB, RB, a tab) starts it at its first
|
||||
// option; anything else (a menu closing, the drawer opening again) goes back where it was.
|
||||
var focusedPage by remember { mutableIntStateOf(page) }
|
||||
var focusedPage by remember { mutableStateOf(page) }
|
||||
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
|
||||
if (!open) {
|
||||
host.open = null
|
||||
@@ -270,8 +291,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
|
||||
val turned = page != focusedPage
|
||||
focusedPage = page
|
||||
// The page's top control: on Settings that is Android apps, when it is shown.
|
||||
val first = if (page == drawerPageEntries.lastIndex && a.isHomeApp) "apps" else drawerPageEntries[page]
|
||||
// The page's top control: on Session that is Android apps, when it is shown.
|
||||
val first = if (page == SessionDrawerPage.SESSION && a.isHomeApp) "apps" else page.firstControl
|
||||
if (turned) focus.forget(page)
|
||||
val target = focus.target(page) ?: first
|
||||
repeat(24) {
|
||||
@@ -284,7 +305,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
if (open || veil > 0.01f) Box(
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||
@@ -350,31 +371,21 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
modifier = Modifier.fillMaxWidth().padding(top = if (short) 6.dp else 12.dp),
|
||||
) {
|
||||
DrawerBumper("LB", stringResource(R.string.drawer_prev_page), modifier = focus.track(page, "prev")) {
|
||||
host.open = null; onPageChange((page + DRAWER_PAGES - 1) % DRAWER_PAGES)
|
||||
host.open = null; onPageChange(pages.step(page, -1))
|
||||
}
|
||||
DrawerPageTabs(page = page, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
|
||||
DrawerPageTabs(page = page, pages = pages, compact = short, modifier = Modifier.weight(1f)) { index -> host.open = null; onPageChange(index) }
|
||||
DrawerBumper("RB", stringResource(R.string.drawer_next_page), modifier = focus.track(page, "next")) {
|
||||
host.open = null; onPageChange((page + 1) % DRAWER_PAGES)
|
||||
host.open = null; onPageChange(pages.step(page, 1))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll.getValue(page)),
|
||||
) { CompositionLocalProvider(LocalChipMinWidth provides 120.dp) {
|
||||
when (page) {
|
||||
0 -> {
|
||||
SessionDrawerPage.DISPLAY -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_display)) {
|
||||
ToggleRow(host, "hud", stringResource(R.string.drawer_hud), null, a.hudOn,
|
||||
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
|
||||
if (a.fillScreen != null) ToggleRow(
|
||||
host, "fill", stringResource(R.string.drawer_fill), null, a.fillScreen,
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
|
||||
)
|
||||
ChoiceRow(host, "upscaler", stringResource(R.string.drawer_scaling), null,
|
||||
SessionPrefs.upscalerChoices, a.upscaler,
|
||||
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
|
||||
SliderRow(stringResource(R.string.drawer_scaling_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
|
||||
enabled = a.upscaler != 0, format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"),
|
||||
onChange = a.onUpscaleSharpness)
|
||||
val fgOpen = host.open == "fg"
|
||||
val fgLabel = FrameGen.label(LocalContext.current, a.frameGen)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), null, highlighted = fgOpen) {
|
||||
@@ -384,14 +395,22 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices, a.upscaler,
|
||||
note = stringResource(R.string.display_filter_note),
|
||||
chipModifier = focus.track(page, "upscaler"), onPick = a.onUpscaler)
|
||||
if (SessionPrefs.upscalerHasSharpness(a.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
|
||||
}
|
||||
}
|
||||
DRAWER_PAGE_EFFECTS -> {
|
||||
ScreenEffectsGroup(host, a) { key -> focus.track(page, key) }
|
||||
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
|
||||
SessionDrawerPage.EFFECTS -> {
|
||||
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
|
||||
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
|
||||
}
|
||||
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
|
||||
DRAWER_PAGE_CONTROLS -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controls)) {
|
||||
SessionDrawerPage.CONTROLLER -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
|
||||
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
@@ -402,12 +421,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
a.controller?.let { c ->
|
||||
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
|
||||
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
|
||||
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
|
||||
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
|
||||
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
|
||||
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
|
||||
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
|
||||
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
|
||||
if (a.onScreenButtonsVisible) {
|
||||
if (a.steam) {
|
||||
ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton,
|
||||
chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton)
|
||||
ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton,
|
||||
chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton)
|
||||
}
|
||||
ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton,
|
||||
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
|
||||
}
|
||||
}
|
||||
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
|
||||
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
@@ -423,18 +446,23 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup(stringResource(R.string.drawer_second_screen)) {
|
||||
ChoiceRow(host, "second-screen-mode", stringResource(R.string.drawer_second_screen_shows), null,
|
||||
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
|
||||
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
|
||||
.map { it to it.label },
|
||||
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
|
||||
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
|
||||
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
|
||||
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
SessionDrawerPage.SECOND_SCREEN -> {
|
||||
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.GAMES -> {
|
||||
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
|
||||
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
|
||||
}
|
||||
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.SESSION -> {
|
||||
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
|
||||
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
|
||||
DrawerOutlineButton(if (androidAppsExpanded) stringResource(R.string.common_hide) else stringResource(R.string.common_show), modifier = focus.track(page, "apps")) {
|
||||
@@ -466,32 +494,22 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
SettingsGroup(stringResource(R.string.drawer_session_behavior)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.drawer_suspend_hint),
|
||||
null,
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
),
|
||||
) + if (a.steam) listOf(SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native)) else emptyList(),
|
||||
a.suspendPolicy,
|
||||
note = stringResource(R.string.mode_suspend_note),
|
||||
note = stringResource(if (a.steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
chipModifier = focus.track(page, "suspend"),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_next_session)) {
|
||||
ChoiceRow(host, "shape", stringResource(R.string.mode_ratio), null,
|
||||
SessionPrefs.shapeChoices, a.shapeMode,
|
||||
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
|
||||
}
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_support)) {
|
||||
SettingsRow(stringResource(R.string.drawer_logs), stringResource(R.string.drawer_logs_hint)) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs")) {
|
||||
DrawerOutlineButton(stringResource(R.string.drawer_share_logs), modifier = focus.track(page, "share-logs"),
|
||||
progress = com.droiddeck.launcher.session.SessionLogShare.progress) {
|
||||
host.open = null
|
||||
a.onShareLogs()
|
||||
}
|
||||
@@ -501,12 +519,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
a.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
if (a.pipSupported) DrawerOutlineButton(stringResource(R.string.pip_title), modifier = focus.track(page, "pip")) {
|
||||
host.open = null; a.onPip()
|
||||
}
|
||||
if (!a.isHomeApp) DrawerOutlineButton(stringResource(R.string.drawer_background), modifier = focus.track(page, "background")) {
|
||||
host.open = null; a.onBackground()
|
||||
Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (a.pipSupported) DrawerActionButton(stringResource(R.string.pip_title), Icons.Outlined.PictureInPictureAlt,
|
||||
modifier = focus.track(page, "pip")) { host.open = null; a.onPip() }
|
||||
if (!a.isHomeApp) DrawerActionButton(stringResource(R.string.drawer_background), Icons.AutoMirrored.Outlined.ExitToApp,
|
||||
modifier = focus.track(page, "background")) { host.open = null; a.onBackground() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +564,28 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(22.dp))
|
||||
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val source = remember { MutableInteractionSource() }
|
||||
@@ -560,7 +598,7 @@ private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onC
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
border = BorderStroke(if (hot) 2.dp else 1.dp, if (hot) pal.signal else colors.outline),
|
||||
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
|
||||
) { Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) }
|
||||
) { ProgressLabel({ m -> Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, modifier = m) }, progress, fontSize = 13.sp) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -636,58 +674,60 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier
|
||||
/**
|
||||
* The drawer's page tabs, QAM-style: one icon per page standing in a line in page order. The
|
||||
* selected one steps forward - full size, bright, a soft glow - while the others step back to
|
||||
* half size and fade, and the whole line leans a little toward the selection. LB / RB still turn
|
||||
* a little smaller and fade, and the whole line leans a little toward the selection. LB / RB still turn
|
||||
* the page (the buttons beside it and the pad's bumpers); a tab can be tapped or picked with the
|
||||
* pad like the dots it replaces.
|
||||
*/
|
||||
@Composable
|
||||
private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (Int) -> Unit) {
|
||||
private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPage>, modifier: Modifier = Modifier, compact: Boolean = false, onSelect: (SessionDrawerPage) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val count = drawerPageIcons.size
|
||||
val count = pages.size
|
||||
val selectedIndex = pages.indexOf(page)
|
||||
val middle = (count - 1) / 2f
|
||||
val motion = Motion.tw<Float>(340, easing = FastOutSlowInEasing)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.height(if (compact) 44.dp else 64.dp).clipToBounds(),
|
||||
) {
|
||||
val tab = if (compact) 40f else 56f
|
||||
val tab = if (compact) 36f else 44f
|
||||
// Fit the line to the room between LB and RB. Worst case is an end tab selected: full size
|
||||
// ([tab] dp) and pushed outward by the lean, which scales with the spacing. So
|
||||
// (count-1) * spacing * (1 + lean/64) + 56 <= width
|
||||
// - never more than the mock's 64 dp, never tighter than 32 dp.
|
||||
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(32f, DRAWER_TAB_SPACING_DP)
|
||||
// (count-1) * spacing * (1 + lean/spacingMax) + tab <= width
|
||||
val spacing = ((maxWidth.value - tab) / ((count - 1) * (1f + DRAWER_TAB_LEAN_DP / DRAWER_TAB_SPACING_DP))).coerceIn(24f, DRAWER_TAB_SPACING_DP)
|
||||
val leanStep = DRAWER_TAB_LEAN_DP * spacing / DRAWER_TAB_SPACING_DP
|
||||
val lean by animateFloatAsState(-(page - middle) * leanStep, motion, label = "tabLean")
|
||||
drawerPageIcons.forEachIndexed { index, icon ->
|
||||
val selected = index == page
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val focused = source.collectIsFocusedAsState().value
|
||||
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
|
||||
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
|
||||
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
|
||||
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(drawerPageTitles[index]))
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
|
||||
val select = { onSelect(index) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset(x = ((index - middle) * spacing + lean).dp)
|
||||
.size(tab.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
|
||||
),
|
||||
)
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = pageDescription }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
|
||||
val lean by animateFloatAsState(-(selectedIndex - middle) * leanStep, motion, label = "tabLean")
|
||||
pages.forEachIndexed { index, item ->
|
||||
androidx.compose.runtime.key(item) {
|
||||
val selected = item == page
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val focused = source.collectIsFocusedAsState().value
|
||||
val scale by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_SCALE, motion, label = "tabScale")
|
||||
val alpha by animateFloatAsState(if (selected) 1f else DRAWER_TAB_SIDE_ALPHA, motion, label = "tabAlpha")
|
||||
val glow by animateFloatAsState(if (selected) 1f else 0f, motion, label = "tabGlow")
|
||||
val pageDescription = stringResource(R.string.drawer_page_desc, stringResource(item.title))
|
||||
val tint by animateColorAsState(if (selected) colors.onBackground else colors.onSurfaceVariant, Motion.tw(340, easing = FastOutSlowInEasing), label = "tabTint")
|
||||
val select = { onSelect(item) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset(x = ((index - middle) * spacing + lean).dp)
|
||||
.size(tab.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent),
|
||||
),
|
||||
)
|
||||
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
|
||||
.semantics { contentDescription = pageDescription }
|
||||
.hoverable(source)
|
||||
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
|
||||
.controllerConfirm(onClick = select),
|
||||
) {
|
||||
Icon(item.icon, contentDescription = null, tint = tint, modifier = Modifier.size(if (compact) 22.dp else 30.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,54 +736,114 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
/** The Look row's value when the rows below match no Look. */
|
||||
private const val LOOK_CUSTOM = "custom"
|
||||
|
||||
/**
|
||||
* Screen effects on the Effects page: a Look row that moves every row under it, then the rows
|
||||
* themselves: switches and sliders the d-pad steps (SliderRow), so a pad drives them.
|
||||
*/
|
||||
/** Presets and simple effects first; detailed adjustments stay behind Advanced. */
|
||||
@Composable
|
||||
private fun ScreenEffectsGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
private fun ScreenEffectsGroup(
|
||||
host: MenuHost, a: DrawerActions, advancedExpanded: Boolean, onAdvancedToggle: () -> Unit,
|
||||
track: (String) -> Modifier,
|
||||
) {
|
||||
val e = a.effects
|
||||
val look = ScreenEffectLooks.match(e, a.upscaler)
|
||||
val preset = ScreenEffectLooks.match(e, a.upscaler)
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
SettingsGroup(stringResource(R.string.drawer_effects)) {
|
||||
val looks = ScreenEffectLooks.LOOKS.map { it.name to it.name }
|
||||
ChoiceRow(host, "look", stringResource(R.string.drawer_look),
|
||||
look?.desc ?: stringResource(R.string.drawer_effects_hint),
|
||||
if (look == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + looks else looks,
|
||||
look?.name ?: LOOK_CUSTOM, chipModifier = track("look"), hintLines = 2) { name ->
|
||||
val presets = ScreenEffectLooks.LOOKS.map { it.name to it.name }
|
||||
ChoiceRow(host, "look", stringResource(R.string.drawer_look), null,
|
||||
if (preset == null) listOf(LOOK_CUSTOM to stringResource(R.string.drawer_look_custom)) + presets else presets,
|
||||
preset?.name ?: LOOK_CUSTOM, chipModifier = track("look")) { name ->
|
||||
ScreenEffectLooks.LOOKS.firstOrNull { it.name == name }?.let { picked ->
|
||||
picked.scalingMode?.let(a.onUpscaler)
|
||||
a.onEffects(picked.effects)
|
||||
}
|
||||
}
|
||||
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5, enabled = e.cas,
|
||||
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
|
||||
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
|
||||
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5, enabled = e.deband,
|
||||
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
|
||||
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
|
||||
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
|
||||
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
|
||||
ToggleRow(host, "fxaa", stringResource(R.string.drawer_fxaa), null, e.fxaa, chipModifier = track("fxaa")) { a.onEffects(e.copy(fxaa = it)) }
|
||||
ToggleRow(host, "toon", stringResource(R.string.drawer_toon), null, e.toon, chipModifier = track("toon")) { a.onEffects(e.copy(toon = it)) }
|
||||
ToggleRow(host, "crt", stringResource(R.string.drawer_crt), null, e.crt, chipModifier = track("crt")) { a.onEffects(e.copy(crt = it)) }
|
||||
ToggleRow(host, "ntsc", stringResource(R.string.drawer_ntsc), null, e.ntsc, chipModifier = track("ntsc")) { a.onEffects(e.copy(ntsc = it)) }
|
||||
ToggleRow(host, "fake-hdr", stringResource(R.string.drawer_fake_hdr), null, e.hdr, chipModifier = track("fake-hdr")) { a.onEffects(e.copy(hdr = it)) }
|
||||
}
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val rotation by animateFloatAsState(if (advancedExpanded) 180f else 0f, Motion.tw(180), label = "advancedArrow")
|
||||
val description = stringResource(if (advancedExpanded) R.string.widgets_expanded else R.string.widgets_collapsed)
|
||||
val toggle = { host.open = null; onAdvancedToggle() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = track("effects-advanced").fillMaxWidth().padding(top = 12.dp).heightIn(min = 48.dp)
|
||||
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle).semantics { stateDescription = description }.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.drawer_effects_advanced).uppercase(), fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
|
||||
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
|
||||
Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp).rotate(rotation))
|
||||
}
|
||||
AnimatedVisibility(advancedExpanded) {
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp))) {
|
||||
ToggleRow(host, "cas", stringResource(R.string.drawer_cas), null, e.cas, chipModifier = track("cas")) { a.onEffects(e.copy(cas = it)) }
|
||||
if (e.cas) SliderRow(stringResource(R.string.drawer_cas_level), null, e.casLevel, 0..100, step = 5,
|
||||
format = { "$it%" }, modifier = track("cas-level")) { a.onEffects(e.copy(casLevel = it)) }
|
||||
ToggleRow(host, "deband", stringResource(R.string.drawer_deband), null, e.deband, chipModifier = track("deband")) { a.onEffects(e.copy(deband = it)) }
|
||||
if (e.deband) SliderRow(stringResource(R.string.drawer_deband_strength), null, e.debandStrength, 0..200, step = 5,
|
||||
format = { "$it%" }, modifier = track("deband-strength")) { a.onEffects(e.copy(debandStrength = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_brightness), null, e.brightness, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("brightness")) { a.onEffects(e.copy(brightness = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_contrast), null, e.contrast, -100..100, step = 2,
|
||||
format = ::signed, modifier = track("contrast")) { a.onEffects(e.copy(contrast = it)) }
|
||||
SliderRow(stringResource(R.string.drawer_gamma), null, (e.gamma * 100f).roundToInt(), 50..300, step = 5,
|
||||
format = { String.format(Locale.US, "%.2f", it / 100f) }, modifier = track("gamma")) { a.onEffects(e.copy(gamma = it / 100f)) }
|
||||
SliderRow(stringResource(R.string.drawer_saturation), null, e.saturation, 0..200, step = 5,
|
||||
format = { "$it%" }, modifier = track("saturation")) { a.onEffects(e.copy(saturation = it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture filtering on the Effects page: DXVK options for the next DirectX 9-11 launch. */
|
||||
/** Secondary-display modes are mutually exclusive and apply immediately. */
|
||||
@Composable
|
||||
private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
|
||||
if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()
|
||||
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
|
||||
Column(Modifier.fillMaxWidth().selectableGroup()) {
|
||||
modes.forEach { mode ->
|
||||
val selected = a.secondScreenMode == mode
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val pick = { host.open = null; a.onSecondScreenMode(mode) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = track("second-screen:${mode.id}").fillMaxWidth().heightIn(min = 56.dp)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(source)
|
||||
.selectable(selected, interactionSource = source, indication = null, role = Role.RadioButton, onClick = pick)
|
||||
.controllerConfirm(onClick = pick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(mode.label, fontSize = 15.sp, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
RadioButton(selected, onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", stringResource(R.string.drawer_page_display), null,
|
||||
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
|
||||
chipModifier = track("second-screen-display"), onPick = a.onSecondScreenDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
|
||||
@Composable
|
||||
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
SettingsGroup(stringResource(R.string.drawer_texture)) {
|
||||
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), stringResource(R.string.drawer_texture_hint),
|
||||
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
|
||||
SessionPrefs.textureAnisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
|
||||
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), stringResource(R.string.drawer_texture_hint),
|
||||
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
|
||||
SessionPrefs.textureLodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
|
||||
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
|
||||
}
|
||||
@@ -751,16 +851,8 @@ private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (Stri
|
||||
|
||||
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
|
||||
|
||||
/** Display, Effects, Controls, Components, Settings. */
|
||||
const val DRAWER_PAGES = 5
|
||||
|
||||
private const val DRAWER_PAGE_EFFECTS = 1
|
||||
private const val DRAWER_PAGE_CONTROLS = 2
|
||||
|
||||
private const val DRAWER_PAGE_COMPONENTS = 3
|
||||
|
||||
/**
|
||||
* The drawer's Components tab: quick swaps between what is already installed, per Proton. A swap
|
||||
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
|
||||
* into the Proton a running game uses waits until that game closes; downloading, importing and
|
||||
* deleting stay on the Components page in the app.
|
||||
*/
|
||||
@@ -884,14 +976,14 @@ private fun <T> DrawerStackedChoice(
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
|
||||
private const val DRAWER_TAB_SPACING_DP = 64f
|
||||
/** A close row of icons: inactive tabs stay legible while the active tab leads. */
|
||||
private const val DRAWER_TAB_SPACING_DP = 48f
|
||||
|
||||
private const val DRAWER_TAB_LEAN_DP = 12f
|
||||
private const val DRAWER_TAB_LEAN_DP = 8f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.8f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.8f
|
||||
|
||||
/** Room between the Stop pill and the box that steps out under it: the pull. */
|
||||
private val StepPull = 6.dp
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -60,6 +61,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -82,6 +84,10 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.setProgress
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.input.InputMode
|
||||
@@ -468,8 +474,16 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul
|
||||
fun SliderRow(
|
||||
label: String, hint: String?, value: Int, range: IntRange, step: Int = 1, enabled: Boolean = true,
|
||||
format: (Int) -> String = { it.toString() }, modifier: Modifier = Modifier, onChange: (Int) -> Unit,
|
||||
) = SettingsRow(label, hint) {
|
||||
ValueSlider(value, range, step, enabled, label, format, modifier, onChange)
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
ValueSlider(value, range, step, enabled, label, format,
|
||||
modifier = modifier.padding(horizontal = 6.dp, vertical = 3.dp), onChange = onChange)
|
||||
if (hint != null) Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 8.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -479,34 +493,39 @@ fun ValueSlider(
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
|
||||
val currentOnChange by rememberUpdatedState(onChange)
|
||||
val span = (range.last - range.first).coerceAtLeast(1)
|
||||
val fraction = ((value - range.first).toFloat() / span).coerceIn(0f, 1f)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
val thumbInset = with(LocalDensity.current) { 8.dp.toPx() }
|
||||
var trackPx by remember { mutableIntStateOf(0) }
|
||||
val text = format(value)
|
||||
// A step from the d-pad lands on the grid of [step] from the range's start; a Look's own
|
||||
// value, which need not be on it, is kept until the user moves.
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
// Preserve off-grid preset values until the user adjusts; every input snaps to the same step.
|
||||
fun stepped(v: Int) = (((v - range.first).toFloat() / step).roundToInt() * step + range.first).coerceIn(range.first, range.last)
|
||||
fun fromX(x: Float) { if (trackPx > 0) onChange(stepped(range.first + (x / trackPx * span).roundToInt())) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.heightIn(min = 48.dp)
|
||||
fun fromX(x: Float) {
|
||||
val width = trackPx - 2 * thumbInset
|
||||
if (width > 0) {
|
||||
val position = ((x - thumbInset) / width).coerceIn(0f, 1f)
|
||||
currentOnChange(stepped(range.first + (position * span).roundToInt()))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 72.dp)
|
||||
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.hoverable(src)
|
||||
.focusable(enabled, src)
|
||||
.hoverable(source).focusable(enabled, source)
|
||||
.onPreviewKeyEvent { event ->
|
||||
val keyEvent = event.nativeKeyEvent
|
||||
val left = keyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
||||
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) {
|
||||
false
|
||||
} else {
|
||||
// Every down counts, repeats included, so a held direction keeps stepping.
|
||||
if (!left && keyEvent.keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) false
|
||||
else {
|
||||
// Repeated key-down events let a held direction keep adjusting.
|
||||
if (enabled && keyEvent.action == KeyEvent.ACTION_DOWN) {
|
||||
val next = stepped(value + if (left) -step else step)
|
||||
if (next != value) onChange(next)
|
||||
if (next != value) currentOnChange(next)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -515,28 +534,33 @@ fun ValueSlider(
|
||||
stateDescription = text
|
||||
progressBarRangeInfo = ProgressBarRangeInfo(value.toFloat(), range.first.toFloat()..range.last.toFloat())
|
||||
if (label != null) contentDescription = label
|
||||
},
|
||||
if (!enabled) disabled()
|
||||
setProgress { next ->
|
||||
if (!enabled) false else { currentOnChange(stepped(next.roundToInt())); true }
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, textAlign = TextAlign.End,
|
||||
maxLines = 1, modifier = Modifier.width(56.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.size(width = 164.dp, height = 30.dp).clip(shape).background(colors.surfaceVariant)
|
||||
.glideBorder(hot, shape, colors.onBackground, pal.line2)
|
||||
.onSizeChanged { trackPx = it.width }
|
||||
.pointerInput(enabled, range, step) { if (enabled) detectTapGestures { fromX(it.x) } }
|
||||
.pointerInput(enabled, range, step) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (label != null) Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
else Spacer(Modifier.weight(1f))
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1)
|
||||
}
|
||||
Canvas(
|
||||
Modifier.fillMaxWidth().height(40.dp).onSizeChanged { trackPx = it.width }
|
||||
.pointerInput(enabled, range, step, thumbInset) { if (enabled) detectTapGestures { fromX(it.x) } }
|
||||
.pointerInput(enabled, range, step, thumbInset) {
|
||||
if (enabled) detectHorizontalDragGestures { change, _ -> fromX(change.position.x); change.consume() }
|
||||
},
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(pal.signal))
|
||||
Box(
|
||||
Modifier.padding(4.dp).size(22.dp)
|
||||
.graphicsLayer { translationX = fraction * (trackPx - 30.dp.toPx()).coerceAtLeast(0f) }
|
||||
.clip(CircleShape)
|
||||
.background(pal.onSignal),
|
||||
)
|
||||
val start = Offset(thumbInset, size.height / 2f)
|
||||
val end = Offset(size.width - thumbInset, start.y)
|
||||
val thumb = Offset(start.x + (end.x - start.x) * fraction, start.y)
|
||||
val fill = if (hot) pal.signal else colors.onBackground
|
||||
drawLine(colors.onSurfaceVariant.copy(alpha = 0.35f), start, end, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
|
||||
if (fraction > 0) drawLine(fill, start, thumb, strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round)
|
||||
drawCircle(fill, radius = 7.dp.toPx(), center = thumb)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,14 +591,14 @@ fun MultiRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit) {
|
||||
SettingsRow(label, hint) { SecondaryButton(button, onClick = onClick) }
|
||||
fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit, progress: Float? = null) {
|
||||
SettingsRow(label, hint) { SecondaryButton(button, progress = progress, onClick = onClick) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPage(
|
||||
host: MenuHost,
|
||||
title: String,
|
||||
title: String?,
|
||||
onBack: () -> Unit,
|
||||
eyebrow: String? = null,
|
||||
lede: String? = null,
|
||||
@@ -584,6 +608,9 @@ fun SettingsPage(
|
||||
scroll: androidx.compose.foundation.ScrollState? = null,
|
||||
scrollContent: Boolean = true,
|
||||
compactLayout: Boolean = false,
|
||||
/** Fixed controls below the title, or beside Back when the title is omitted. */
|
||||
header: (@Composable () -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollState = scroll ?: rememberScrollState()
|
||||
@@ -591,13 +618,24 @@ fun SettingsPage(
|
||||
val dim by animateFloatAsState(if (host.open != null) 0.6f else 1f, Motion.tw(220), label = "pageDim")
|
||||
val narrow = LocalNarrowPane.current && !compactLayout
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(
|
||||
modifier = modifier.fillMaxSize().padding(
|
||||
horizontal = if (compactLayout) 14.dp else if (narrow) 16.dp else 22.dp,
|
||||
vertical = if (compactLayout) 6.dp else if (narrow) 12.dp else 18.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp),
|
||||
) {
|
||||
if (narrow) {
|
||||
if (title == null) {
|
||||
Rise(0) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
|
||||
if (header != null) Box(Modifier.weight(1f)) { header() }
|
||||
}
|
||||
}
|
||||
} else if (narrow) {
|
||||
// Back and the title share one line: a 4:3 screen has no height to spare.
|
||||
Rise(0) {
|
||||
Row(
|
||||
@@ -656,6 +694,7 @@ fun SettingsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (header != null && title != null) header()
|
||||
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -5,9 +5,13 @@ import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
class Palette(
|
||||
@@ -67,7 +71,7 @@ val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) }
|
||||
val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White
|
||||
|
||||
@Composable
|
||||
fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
|
||||
fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) {
|
||||
val id = theme ?: SessionPrefs.theme(LocalContext.current)
|
||||
val p = Themes.byId(id)
|
||||
val scheme = darkColorScheme(
|
||||
@@ -77,5 +81,13 @@ fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
|
||||
surfaceVariant = p.surfaceVariant, onSurfaceVariant = p.onSurfaceVariant,
|
||||
error = p.error,
|
||||
)
|
||||
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, content = content) }
|
||||
val baseDensity = LocalDensity.current
|
||||
val baseConfiguration = LocalConfiguration.current
|
||||
val density = remember(baseDensity, appScale) { scaledAppDensity(baseDensity, appScale) }
|
||||
val configuration = remember(baseConfiguration, appScale) { scaledAppConfiguration(baseConfiguration, appScale) }
|
||||
CompositionLocalProvider(
|
||||
LocalPalette provides p,
|
||||
LocalDensity provides density,
|
||||
LocalConfiguration provides configuration,
|
||||
) { MaterialTheme(colorScheme = scheme, content = content) }
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.DependencyDetector
|
||||
import com.droiddeck.launcher.frontend.PrefixInstalledDetector
|
||||
import com.droiddeck.launcher.frontend.SteamRedists
|
||||
import com.droiddeck.launcher.session.WinComponentNames
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.session.WinComponents.Support
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
// Windows components for one added game, Bannerlator's list: what the game's folder suggests first,
|
||||
// then everything that installs here, then the ones that need a Windows installer (not yet). Each
|
||||
// one is a switch, downloaded the first time it is turned on for any game; the launch puts the
|
||||
// picked ones into the game's prefix (droiddeck-wincomponents).
|
||||
|
||||
/** Bannerlator's detector names installers; here the DLL-copy twin is the one that installs. */
|
||||
private fun installable(name: String, all: Map<String, WinComponents.Component>): String {
|
||||
val dll = when (name) {
|
||||
"oalinst" -> "oalinst_dll"
|
||||
else -> "${name}_dll"
|
||||
}
|
||||
val twin = all[dll]
|
||||
return if (twin != null && WinComponents.support(twin, all) == Support.READY) dll else name
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
internal fun WinComponentsDialog(
|
||||
appKey: String, gameName: String, gameDir: File?, byPad: Boolean,
|
||||
/** compatdata/<id>: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */
|
||||
compat: File? = null, steamAppId: Int? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
// null while the catalog loads; empty when it could not be had.
|
||||
var catalog by remember { mutableStateOf<Map<String, WinComponents.Component>?>(null) }
|
||||
var offline by remember { mutableStateOf(false) }
|
||||
var recommended by remember { mutableStateOf(emptyList<DependencyDetector.Recommendation>()) }
|
||||
var installed by remember { mutableStateOf(emptySet<String>()) }
|
||||
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
|
||||
var present by remember { mutableStateOf(emptySet<String>()) }
|
||||
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
|
||||
var progress by remember { mutableStateOf<Pair<String, Int>?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// The component the error is about, so it shows on its own row and not only at the bottom.
|
||||
var failed by remember { mutableStateOf<String?>(null) }
|
||||
var showWaiting by remember { mutableStateOf(false) }
|
||||
// Done holds focus while the list loads, then the top switch takes it - opened by touch or by
|
||||
// pad, so the d-pad always has somewhere to start; LB and RB jump between the sections, since
|
||||
// the full list runs past sixty switches.
|
||||
val recFocus = remember { FocusRequester() }
|
||||
val allFocus = remember { FocusRequester() }
|
||||
val waitFocus = remember { FocusRequester() }
|
||||
val doneFocus = remember { FocusRequester() }
|
||||
val inputMode = LocalInputModeManager.current
|
||||
var switchFocused by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val (entries, found) = withContext(Dispatchers.IO) {
|
||||
// The folder's own installers first, then what Steam installs with the game.
|
||||
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
|
||||
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
|
||||
WinComponents.fetch() to (own + steam)
|
||||
}
|
||||
offline = entries == null
|
||||
catalog = entries.orEmpty().associateBy { it.name }
|
||||
recommended = found
|
||||
present = withContext(Dispatchers.IO) { PrefixInstalledDetector.detect(compat) }
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
}
|
||||
fun setPicks(next: List<String>) {
|
||||
picks = next
|
||||
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
}
|
||||
fun toggle(id: String, on: Boolean) {
|
||||
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
|
||||
// then is ignored here.
|
||||
if (progress != null) return
|
||||
error = null
|
||||
failed = null
|
||||
if (!on) { setPicks(picks - id); return }
|
||||
val all = catalog.orEmpty()
|
||||
val c = all[id]
|
||||
if (id in installed || c == null) { setPicks(picks + id); return }
|
||||
progress = id to -1
|
||||
coroutine.launch {
|
||||
val problem = withContext(Dispatchers.IO) {
|
||||
WinComponents.install(context, c, all) { stage, percent -> progress = stage to percent }
|
||||
}
|
||||
progress = null
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
if (problem != null) { error = problem; failed = id } else setPicks(picks + id)
|
||||
}
|
||||
}
|
||||
|
||||
val all = catalog
|
||||
val here = if (all == null) emptySet() else present.map { installable(it, all) }.toSet()
|
||||
fun supportOf(id: String): Support =
|
||||
all?.get(id)?.let { WinComponents.support(it, all) } ?: if (id in installed) Support.READY else Support.UNSUPPORTED
|
||||
val recIds = if (all == null) emptyList() else recommended.distinctBy { installable(it.componentName, all) }
|
||||
val ready = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.READY }.map { it.name }
|
||||
val extra = (installed + picks).filter { all?.containsKey(it) != true }
|
||||
val list = (ready + extra).distinct().sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val waiting = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.NEEDS_INSTALLER }
|
||||
.map { it.name }.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val hasRec = all != null && recIds.any { supportOf(installable(it.componentName, all)) == Support.READY }
|
||||
val sections = listOfNotNull(recFocus.takeIf { hasRec }, allFocus.takeIf { list.isNotEmpty() },
|
||||
waitFocus.takeIf { waiting.isNotEmpty() }, doneFocus)
|
||||
var section by remember { mutableStateOf(0) }
|
||||
fun jump(step: Int) {
|
||||
section = (section + step + sections.size) % sections.size
|
||||
runCatching { sections[section].requestFocus() }
|
||||
}
|
||||
LaunchedEffect(all != null) {
|
||||
// The dialog's window starts in touch mode, where a switch cannot hold focus at all.
|
||||
inputMode.requestInputMode(InputMode.Keyboard)
|
||||
section = 0
|
||||
val target = if (all == null) doneFocus else sections.first()
|
||||
switchFocused = false
|
||||
// It animates in and the list composes over a few frames: ask until the switch has it.
|
||||
repeat(60) {
|
||||
withFrameNanos { }
|
||||
runCatching { target.requestFocus() }
|
||||
withFrameNanos { }
|
||||
if (switchFocused || (target !== recFocus && target !== allFocus)) return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
|
||||
AppDialog(shown, close, "winComponents", wide = true, modifier = Modifier.bumpers({ jump(-1) }, { jump(1) })) {
|
||||
DialogHeader(gameName, stringResource(R.string.wincomp_title))
|
||||
Small(stringResource(R.string.wincomp_applies))
|
||||
if (byPad) Small(stringResource(R.string.wincomp_pad_hint))
|
||||
if (all == null) Small(stringResource(R.string.wincomp_loading))
|
||||
if (offline) Small(stringResource(R.string.wincomp_offline), error = true)
|
||||
@Composable
|
||||
fun Item(id: String, reason: String?, focus: FocusRequester?) {
|
||||
val c = all?.get(id)
|
||||
val support = supportOf(id)
|
||||
val status = when {
|
||||
id == failed && error != null -> stringResource(R.string.wincomp_failed, error.orEmpty())
|
||||
id in here && id !in picks -> stringResource(R.string.wincomp_already_here)
|
||||
id in installed -> stringResource(R.string.wincomp_downloaded)
|
||||
support == Support.NEEDS_INSTALLER -> stringResource(R.string.wincomp_needs_installer)
|
||||
support == Support.UNSUPPORTED -> stringResource(R.string.wincomp_unsupported)
|
||||
else -> null
|
||||
}
|
||||
// The catalog key stays in the detail: it is what a log or a bug report names.
|
||||
val detail = listOfNotNull(reason, c?.description?.takeIf { it.isNotEmpty() }, status, id).joinToString(" · ")
|
||||
val usable = support == Support.READY
|
||||
ComponentRow(
|
||||
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || id in picks,
|
||||
dim = !usable, modifier = (if (usable && focus != null) Modifier.focusRequester(focus) else Modifier)
|
||||
.onFocusChanged { if (it.isFocused) switchFocused = true },
|
||||
) { on -> toggle(id, on) }
|
||||
}
|
||||
/** The section's requester goes to its first switch that can take focus. */
|
||||
fun firstUsable(ids: List<String>, focus: FocusRequester): (String) -> FocusRequester? {
|
||||
val first = ids.firstOrNull { supportOf(it) == Support.READY }
|
||||
return { id -> focus.takeIf { id == first } }
|
||||
}
|
||||
|
||||
if (all != null && recIds.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_recommended))
|
||||
val focusOf = firstUsable(recIds.map { installable(it.componentName, all) }, recFocus)
|
||||
Panel {
|
||||
recIds.forEachIndexed { i, rec ->
|
||||
if (i > 0) Divider()
|
||||
val reason = stringResource(
|
||||
when (rec.kind) {
|
||||
DependencyDetector.Kind.BUNDLED -> R.string.wincomp_found_bundled
|
||||
DependencyDetector.Kind.SHIPPED -> R.string.wincomp_found_shipped
|
||||
DependencyDetector.Kind.STEAM -> R.string.wincomp_found_steam
|
||||
}, rec.reason,
|
||||
)
|
||||
val id = installable(rec.componentName, all)
|
||||
Item(id, reason, focusOf(id))
|
||||
}
|
||||
}
|
||||
} else if (all != null && gameDir != null) Small(stringResource(R.string.wincomp_no_recommendation))
|
||||
|
||||
if (list.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_all, list.size))
|
||||
val focusOf = firstUsable(list, allFocus)
|
||||
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (waiting.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Section(stringResource(R.string.wincomp_installers, waiting.size))
|
||||
Spacer(Modifier.weight(1f))
|
||||
SecondaryButton(
|
||||
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
|
||||
modifier = Modifier.focusRequester(waitFocus),
|
||||
) { showWaiting = !showWaiting }
|
||||
}
|
||||
Small(stringResource(R.string.wincomp_installers_note))
|
||||
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
|
||||
}
|
||||
|
||||
progress?.let { (stage, percent) ->
|
||||
Small(if (percent >= 0) "$stage · $percent%" else stage)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
else LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
error?.let { Small(it, error = true) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Small(stringResource(R.string.wincomp_next_launch))
|
||||
Spacer(Modifier.weight(1f))
|
||||
PrimaryButton(stringResource(R.string.game_env_done), enabled = progress == null, modifier = Modifier.focusRequester(doneFocus), onClick = close)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String) =
|
||||
Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@Composable
|
||||
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
|
||||
Column(Modifier.fillMaxWidth().clip(Shape14).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line, Shape14), content = content)
|
||||
|
||||
@Composable
|
||||
private fun Divider() =
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).heightIn(min = 1.dp, max = 1.dp).background(LocalPalette.current.line))
|
||||
|
||||
@Composable
|
||||
private fun ComponentRow(name: String, detail: String, checked: Boolean, enabled: Boolean, dim: Boolean, modifier: Modifier, onChange: (Boolean) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp).alpha(if (dim) 0.55f else 1f),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
if (detail.isNotEmpty()) Text(detail, fontSize = 12.sp, lineHeight = 16.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
ToggleSwitch(checked, enabled, name, modifier, onChange)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.wayland
|
||||
|
||||
import android.view.Choreographer
|
||||
import android.view.Surface
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* The compositor is a process-wide thing, not an activity's: it is started once, keeps running
|
||||
@@ -35,13 +36,17 @@ object CompositorHost {
|
||||
fpsLimit: Int,
|
||||
): Boolean {
|
||||
pace(refreshHz, fpsLimit)
|
||||
// The compositor outlives sessions, and the next one may be on the other screen of a dual-screen
|
||||
// device: gamescope reads the size from the toplevel configure and the wl_output mode when it
|
||||
// connects, and kept the first session's (the Thor's 1920x1080 top screen letterboxed the bottom
|
||||
// one's session to 16:9). A running session keeps the size it was started at.
|
||||
if (!SessionState.running) WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
|
||||
if (started) {
|
||||
attached = surface
|
||||
WaylandCompositor.nativeSetSurface(surface)
|
||||
resumeVsync()
|
||||
return false
|
||||
}
|
||||
WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
|
||||
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
|
||||
WaylandCompositor.nativeStartWithSurface(
|
||||
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
|
||||
|
||||
@@ -115,6 +115,11 @@ public final class WaylandCompositor {
|
||||
|
||||
public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; }
|
||||
|
||||
/** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */
|
||||
public static void clearPointerLockListener(PointerLockListener l) {
|
||||
if (pointerLockListener == l) pointerLockListener = null;
|
||||
}
|
||||
|
||||
/** Invoked from native (droiddeck_on_pointer_lock). */
|
||||
@SuppressWarnings("unused")
|
||||
static void onPointerLock(boolean locked, int x, int y) {
|
||||
@@ -500,4 +505,11 @@ public final class WaylandCompositor {
|
||||
if (dx == 0 && dy == 0) return;
|
||||
nativeSendSceneInput(6, dx * 256, dy * 256);
|
||||
}
|
||||
|
||||
/** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */
|
||||
public static void sendPointerDelta(float dx, float dy) {
|
||||
int x = Math.round(dx * 256f), y = Math.round(dy * 256f);
|
||||
if (x == 0 && y == 0) return;
|
||||
nativeSendSceneInput(6, x, y);
|
||||
}
|
||||
}
|
||||
@@ -285,8 +285,6 @@
|
||||
<string name="mode_added_launches_in">Inicia %1$s · en %2$s</string>
|
||||
<string name="mode_added_choose">Elegir otro archivo…</string>
|
||||
<string name="mode_added_exe_note">Por defecto, el .exe con el nombre de la carpeta o, si no, el más grande.</string>
|
||||
<string name="mode_fill">Estirar juegos para llenar la pantalla</string>
|
||||
<string name="mode_fill_hint">Mantiene a pantalla completa los juegos que se redimensionan solos. Desactívalo si un juego se ve pequeño. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_stretch169">Estirar juegos 16:9</string>
|
||||
<string name="mode_stretch169_hint">En pantallas más cuadradas que 16:9, estira los juegos para llenar en lugar de mostrar barras. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
@@ -444,7 +442,6 @@
|
||||
<string name="drawer_prev_page">Página anterior</string>
|
||||
<string name="drawer_next_page">Página siguiente</string>
|
||||
<string name="drawer_hud">HUD de rendimiento</string>
|
||||
<string name="drawer_fill">Estirar juegos para llenar</string>
|
||||
<string name="drawer_scaling">Modo de escalado</string>
|
||||
<string name="drawer_scaling_sharpness">Nitidez del escalado</string>
|
||||
<string name="drawer_effects">Efectos de pantalla</string>
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>Le premier lancement télécharge Steam. Cela peut prendre quelques minutes.</item>
|
||||
<item>Par défaut, quitter l\'application met Steam en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
|
||||
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>Le premier lancement configure le bureau. Cela peut prendre quelques minutes.</item>
|
||||
<item>Par défaut, quitter l\'application met la session en pause ; y revenir reprend là où vous vous étiez arrêté.</item>
|
||||
<item>Le tactile fonctionne comme un pavé tactile : faites glisser pour déplacer le pointeur, tapotez pour cliquer.</item>
|
||||
<item>Votre manette apparaîtra comme une manette Xbox 360. Sinon, utilisez les commandes à l\'écran.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Affiché lorsqu\'une session Steam ou Bureau est ouverte</string>
|
||||
<string name="session_notification">Steam est en cours d\'exécution</string>
|
||||
<string name="session_notification_paused">Session en pause - Appuyez pour reprendre quand vous êtes prêt</string>
|
||||
<string name="resume_session">Reprendre</string>
|
||||
<string name="stop_session">Terminer cette session</string>
|
||||
<string name="frame_gen_title">Génération d\'images</string>
|
||||
<string name="frame_gen_hint">Sélectionnez le mode de génération d\'images à utiliser</string>
|
||||
<string name="frame_gen_off">Désactivé</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Adaptatif %1$d</string>
|
||||
<string name="lsfg_needs_steam">Nécessite Lossless Scaling sur Steam</string>
|
||||
<string name="lsfg_needs_install">Installez Lossless Scaling sur Steam ou importez Lossless.dll</string>
|
||||
<string name="lsfg_import">Importer Lossless.dll…</string>
|
||||
<string name="lsfg_using_steam">Utilise la version sur Steam</string>
|
||||
<string name="lsfg_using_import">Utilise la version importée</string>
|
||||
<string name="lsfg_pick_title">Sélectionner le fichier Lossless.dll</string>
|
||||
<string name="lsfg_imported">Lossless.dll importé</string>
|
||||
<string name="lsfg_import_unchanged">Ce Lossless.dll est déjà utilisé</string>
|
||||
<string name="lsfg_import_not_owned">Ce compte Steam ne possède pas Lossless Scaling</string>
|
||||
<string name="lsfg_import_invalid">Ce fichier n\'est pas le Lossless.dll de Lossless Scaling</string>
|
||||
<string name="lsfg_import_failed">Impossible de préparer LSFG à partir de ce Lossless.dll</string>
|
||||
<string name="lsfg_problem_missing">LSFG nécessite Lossless Scaling depuis Steam, ou un fichier Lossless.dll importé</string>
|
||||
<string name="lsfg_problem_failed">LSFG ne peut pas préparer ses shaders à partir de ce Lossless.dll</string>
|
||||
<string name="hud_fg_unsupported">%1$s : non pris en charge</string>
|
||||
<string name="hud_fg_failed">%1$s : échec du moteur</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s : nécessite Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · Démarrage en cours</string>
|
||||
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam.</string>
|
||||
<string name="second_library_import_hint">Ajoutez une bibliothèque Steam et analysez-y les jeux Windows. Les jeux déjà possédés utiliseront les entrées Steam correspondantes, les autres deviendront des raccourcis non-Steam.</string>
|
||||
<string name="added_games_forget_hint">« Oublier » supprimera ces raccourcis lors de la prochaine session. Mais les fichiers resteront sur le disque.</string>
|
||||
<string name="game_env_title">Environnement de jeu</string>
|
||||
<string name="game_env_hint">Variables d\'environnement pour les jeux Proton.</string>
|
||||
<string name="game_env_edit">Modifier</string>
|
||||
<string name="game_env_shared">Tous les jeux Proton</string>
|
||||
<string name="game_env_scope">Profil</string>
|
||||
<string name="game_env_app_id">ID d\'application Steam</string>
|
||||
<string name="game_env_open">Ouvrir</string>
|
||||
<string name="game_env_profile">Jeu %1$s</string>
|
||||
<string name="game_env_add">Ajouter une variable</string>
|
||||
<string name="game_env_name">Nom de la variable</string>
|
||||
<string name="game_env_value">Valeur</string>
|
||||
<string name="game_env_save">Enregistrer</string>
|
||||
<string name="game_env_remove">Supprimer</string>
|
||||
<string name="game_env_restore">Rétablir l\'héritage</string>
|
||||
<string name="game_env_reset">Réinitialiser ce profil</string>
|
||||
<string name="game_env_unset">Annuler la définition pour ce profil</string>
|
||||
<string name="game_env_empty">Aucune variable définie.</string>
|
||||
<string name="game_env_inherited">Hérité</string>
|
||||
<string name="game_env_custom">Variable personnalisée</string>
|
||||
<string name="game_env_error">Impossible de charger ou d\'enregistrer. Vérifiez l\'espace de stockage restant et réessayez.</string>
|
||||
<string name="game_env_invalid">Veuillez saisir un nom et une valeur valides, jusqu\'à 8192 caractères.</string>
|
||||
<string name="game_env_unused">L\'environnement d\'exécution Proton standard ne lit pas cette variable.</string>
|
||||
<string name="game_env_loading">Chargement…</string>
|
||||
<string name="game_env_feature">Remplacement du niveau de fonctionnalité D3D12. 12_2 signalera les capacités DirectX 12 Ultimate ; les fonctionnalités non prises en charge peuvent entraîner un échec. Laissez vide pour la détection automatique.</string>
|
||||
<string name="game_env_shader">Remplacement du modèle de shader D3D12. 6_9 nécessite un VKD3D suffisamment récent et des fonctionnalités GPU correspondantes. Laissez vide pour la détection automatique.</string>
|
||||
<string name="game_env_cache">Maintient le cache de shaders Mesa activé pour réduire les recompilations. Définir sur false pour l\'activer.</string>
|
||||
<string name="game_env_cache_size">Taille maximale du cache de shaders Mesa, par exemple 1G. Un cache plus grand occupe plus d\'espace de stockage.</string>
|
||||
<string name="game_env_vkd3d_config">Options VKD3D pour D3D12. nodxr désactive le ray tracing ; à utiliser uniquement si le jeu le nécessite.</string>
|
||||
<string name="game_env_proton_log">Définir sur 1 pour générer un journal de diagnostic Proton. La journalisation peut réduire les performances et consommer de l\'espace de stockage.</string>
|
||||
<string name="game_env_wined3d">Définir sur 1 pour utiliser WineD3D (basé sur OpenGL) au lieu de DXVK, pour tester la compatibilité D3D9–11.</string>
|
||||
<string name="game_env_xalia">Définir sur 0 pour désactiver l\'assistant de navigation à la manette de Proton pour ce jeu.</string>
|
||||
<string name="game_env_hud">Superposition DXVK pour D3D9–11. fps affiche le taux de rafraîchissement ; annuler la définition masque la superposition.</string>
|
||||
<string name="game_env_dxvk_config">Paramètres DXVK séparés par des points-virgules. Les versions récentes utilisent dxvk.maxFrameRate pour limiter le taux de rafraîchissement ; pour un jeu quotidien, il est recommandé d\'utiliser le limiteur de taux de rafraîchissement de session.</string>
|
||||
<string name="game_env_vkd3d_limit">Limite de taux de rafraîchissement D3D12. 0 désactive la limite.</string>
|
||||
<string name="game_env_glthread">Mesa OpenGL multithread. Pour les jeux OpenGL limités par le CPU, true peut aider ; à tester jeu par jeu.</string>
|
||||
<string name="fex_preset_title">Préréglages FEX</string>
|
||||
<string name="fex_next_launch">Prendra effet au prochain lancement du jeu.</string>
|
||||
<string name="game_settings_title">Jeu</string>
|
||||
<string name="fex_default_label">FEX par défaut</string>
|
||||
<string name="fex_default_detail">Utiliser les paramètres par défaut de FEX.</string>
|
||||
<string name="fex_stability_label">Stabilité</string>
|
||||
<string name="fex_stability_detail">Paramètres les plus sûrs, les plus lents.</string>
|
||||
<string name="fex_compatibility_label">Compatibilité</string>
|
||||
<string name="fex_compatibility_detail">TSO complet + multiblock.</string>
|
||||
<string name="fex_intermediate_label">Intermédiaire</string>
|
||||
<string name="fex_intermediate_detail">Paramètres TSO équilibrés, avec une précision x87 réduite.</string>
|
||||
<string name="fex_performance_label">Performance</string>
|
||||
<string name="fex_performance_detail">Désactive tous les TSO ; active le multiblock.</string>
|
||||
<string name="fex_performance_tso_label">Performance + TSO</string>
|
||||
<string name="fex_performance_tso_detail">Paramètres de performance, avec TSO principal activé.</string>
|
||||
<string name="fex_extreme_label">Extrême</string>
|
||||
<string name="fex_extreme_detail">Désactive la vérification du code auto-modifiant. Les jeux JIT et les DRM peuvent ne pas fonctionner.</string>
|
||||
<string name="fex_extreme_tso_label">Extrême + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Paramètres extrêmes, avec TSO principal activé.</string>
|
||||
<string name="fex_extreme_gn_label">Extrême-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Paramètres de performance, active TSC et les astuces de métadonnées. Il est recommandé d\'essayer d\'abord ce mode avant « Extrême ».</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Paramètres de performance, active la vérification complète du code et masque les bits de l\'hyperviseur.</string>
|
||||
<string name="game_env_custom_value">Valeur personnalisée</string>
|
||||
<string name="game_env_select_value">Sélectionner une valeur</string>
|
||||
<string name="user_apps_section">Émulateurs et applications</string>
|
||||
<string name="user_apps_add">Ajouter</string>
|
||||
<string name="user_apps_add_detail">Votre propre application ou jeu</string>
|
||||
<string name="user_apps_kind_script">Script</string>
|
||||
<string name="user_apps_kind_appimage">AppImage</string>
|
||||
<string name="user_apps_kind_flatpak">Flatpak</string>
|
||||
<string name="user_apps_progress">%1$s · %2$s</string>
|
||||
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
|
||||
<string name="user_apps_starting">Démarrage…</string>
|
||||
<string name="user_apps_added">%1$s ajouté</string>
|
||||
<string name="user_apps_removed">%1$s supprimé</string>
|
||||
<string name="user_apps_back">Bureau</string>
|
||||
<string name="user_apps_gone">Cette application n\'existe plus. Veuillez la resélectionner depuis le bureau.</string>
|
||||
<string name="user_apps_open">Ouvrir %1$s</string>
|
||||
<string name="user_apps_remove">Supprimer</string>
|
||||
<string name="user_apps_remove_confirm">Appuyez à nouveau pour supprimer</string>
|
||||
<string name="user_apps_stop_session">Veuillez d\'abord terminer la session avant de supprimer</string>
|
||||
<string name="user_apps_script_linked">Exécute %1$s depuis son dossier et lie ce dossier à la session. La suppression de l\'application n\'affectera pas ce dossier.</string>
|
||||
<string name="user_apps_script_copied">Exécute %1$s depuis un dossier copié à l\'intérieur de DroidDeck. La suppression de l\'application supprimera la copie, mais pas le fichier d\'origine.</string>
|
||||
<string name="user_apps_runtime_required">Veuillez d\'abord installer l\'environnement d\'exécution Linux</string>
|
||||
<string name="user_apps_file_gone">Le fichier n\'existe plus</string>
|
||||
<string name="user_apps_no_space">Espace disponible insuffisant : %1$s est nécessaire pour copier ce dossier</string>
|
||||
<string name="user_apps_copying">Copie de %1$s en cours</string>
|
||||
<string name="user_apps_failed">Échec de l\'ajout de l\'application</string>
|
||||
<string name="user_apps_github_checking">Recherche des versions de %1$s</string>
|
||||
<string name="user_apps_github_unreachable">Impossible de lire les versions de %1$s</string>
|
||||
<string name="user_apps_github_no_appimage">%1$s ne fournit pas de version ARM64 AppImage</string>
|
||||
<string name="user_apps_downloading">Téléchargement de %1$s en cours</string>
|
||||
<string name="user_apps_download_failed">Échec du téléchargement</string>
|
||||
<string name="user_apps_update_failed">Échec de la mise à jour de l\'application</string>
|
||||
<string name="user_apps_icon_failed">Impossible de lire cette icône</string>
|
||||
<string name="user_apps_saved">%1$s enregistré</string>
|
||||
<string name="user_apps_edit">Modifier</string>
|
||||
<string name="user_apps_check_updates">Vérifier les mises à jour</string>
|
||||
<string name="user_apps_checking_updates">Vérification des mises à jour…</string>
|
||||
<string name="user_apps_update_to">Mettre à jour vers %1$s</string>
|
||||
<string name="user_apps_up_to_date">À jour · %1$s</string>
|
||||
<string name="user_apps_updated">%1$s mis à jour</string>
|
||||
<string name="user_apps_github_version">%1$s · %2$s</string>
|
||||
<string name="add_app_title">Ajouter une application</string>
|
||||
<string name="add_app_tab_script">Script</string>
|
||||
<string name="add_app_tab_appimage">AppImage</string>
|
||||
<string name="add_app_tab_github">GitHub</string>
|
||||
<string name="add_app_tab_flatpak">Flatpak</string>
|
||||
<string name="add_app_script_hint">Un fichier .sh. Il s\'exécutera depuis son propre dossier et s\'affichera en plein écran via gamescope.</string>
|
||||
<string name="add_app_appimage_hint">Un AppImage ARM64 (aarch64). Il sera décompressé dans l\'environnement d\'exécution Linux lors de la première utilisation.</string>
|
||||
<string name="add_app_github_hint">Installer l\'AppImage ARM64 depuis la dernière version du dépôt.</string>
|
||||
<string name="add_app_flatpak_hint">ID d\'application Flathub ou sa page Flathub. Si Flatpak n\'est pas encore configuré, il le sera d\'abord.</string>
|
||||
<string name="add_app_file">Fichier</string>
|
||||
<string name="add_app_no_file">Aucun fichier sélectionné</string>
|
||||
<string name="add_app_choose_file">Choisir un fichier</string>
|
||||
<string name="add_app_change_file">Modifier</string>
|
||||
<string name="add_app_pick_script">Sélectionner le script</string>
|
||||
<string name="add_app_pick_appimage">Sélectionner l\'AppImage</string>
|
||||
<string name="add_app_pick_icon">Sélectionner l\'icône</string>
|
||||
<string name="add_app_repository">Dépôt</string>
|
||||
<string name="add_app_repository_placeholder">owner/repo ou lien github.com</string>
|
||||
<string name="add_app_repository_invalid">N\'est pas un dépôt GitHub</string>
|
||||
<string name="add_app_flatpak_id">ID d\'application</string>
|
||||
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
|
||||
<string name="add_app_flatpak_invalid">N\'est pas un ID d\'application Flathub valide</string>
|
||||
<string name="add_app_source">Source</string>
|
||||
<string name="add_app_appearance">Afficher sur le bureau</string>
|
||||
<string name="add_app_preview_name">Nouvelle application</string>
|
||||
<string name="add_app_name">Nom</string>
|
||||
<string name="add_app_name_placeholder">Depuis l\'application</string>
|
||||
<string name="add_app_icon">Icône</string>
|
||||
<string name="add_app_icon_default">Icône par défaut</string>
|
||||
<string name="add_app_choose_icon">Choisir une icône</string>
|
||||
<string name="add_app_clear_icon">Effacer</string>
|
||||
<string name="add_app_icon_suggestions">Depuis le dépôt</string>
|
||||
<string name="add_app_icon_searching">Recherche d\'icônes…</string>
|
||||
<string name="add_app_icon_reset">Par défaut</string>
|
||||
<string name="edit_app_title">Modifier %1$s</string>
|
||||
<string name="edit_app_save">Enregistrer</string>
|
||||
<string name="add_app_checking_folder">Vérification du dossier…</string>
|
||||
<string name="add_app_folder_linked">Son dossier est lié à la session : %1$s</string>
|
||||
<string name="add_app_folder_copied">Ce dossier contient des programmes Linux qui ne peuvent pas s\'exécuter depuis le stockage partagé. Il sera donc copié dans DroidDeck (%1$s).</string>
|
||||
<string name="add_app_confirm">Ajouter</string>
|
||||
<string name="add_app_cancel">Annuler</string>
|
||||
<string name="add_app_runtime_required">Veuillez d\'abord installer l\'environnement d\'exécution Linux depuis les « Paramètres ».</string>
|
||||
<string name="add_app_busy">Veuillez attendre que %1$s soit terminé.</string>
|
||||
<string name="common_cancel">Annuler</string>
|
||||
<string name="common_auto">Auto</string>
|
||||
<string name="common_never">Jamais</string>
|
||||
<string name="common_always">Toujours</string>
|
||||
<string name="common_uninstall">Désinstaller</string>
|
||||
<string name="common_check">Vérifier</string>
|
||||
<string name="common_update">Mettre à jour</string>
|
||||
<string name="common_checking">Vérification…</string>
|
||||
<string name="common_working">Traitement en cours…</string>
|
||||
<string name="common_forget">Oublier</string>
|
||||
<string name="common_add_ellipsis">Ajouter…</string>
|
||||
<string name="common_use">Utiliser</string>
|
||||
<string name="common_applies_next_session">Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_steam_title">Session Steam</string>
|
||||
<string name="mode_desktop_title">Session Bureau</string>
|
||||
<string name="mode_display">Affichage</string>
|
||||
<string name="mode_resolution">Résolution</string>
|
||||
<string name="mode_res_720">Max 720p</string>
|
||||
<string name="mode_res_900">Max 900p</string>
|
||||
<string name="mode_res_1080">Max 1080p</string>
|
||||
<string name="mode_res_native">Native</string>
|
||||
<string name="mode_res_default">%1$s (par défaut)</string>
|
||||
<string name="mode_res_custom_value">Personnalisée · %1$d×%2$d</string>
|
||||
<string name="mode_res_custom">Personnalisée…</string>
|
||||
<string name="mode_res_note">720p peut rendre les menus plus réactifs.</string>
|
||||
<string name="mode_ratio">Format d\'écran</string>
|
||||
<string name="mode_ratio_custom">Défini par la résolution personnalisée.</string>
|
||||
<string name="mode_ratio_auto">Le mode automatique utilise au moins 16:9.</string>
|
||||
<string name="mode_fps">Limite de taux de rafraîchissement</string>
|
||||
<string name="mode_fps_note">Limite le taux de rafraîchissement de toute la session. Le moniteur basculera vers un taux de rafraîchissement divisible par cette limite.</string>
|
||||
<string name="mode_hdr">HDR</string>
|
||||
<string name="mode_hdr10">Sortie HDR10</string>
|
||||
<string name="mode_hdr_unavailable">Non disponible : %1$s.</string>
|
||||
<string name="mode_hdr_restart">Prendra effet après le redémarrage de l\'application.</string>
|
||||
<string name="mode_drivers">Pilotes</string>
|
||||
<string name="mode_gpu_drivers">Pilotes GPU</string>
|
||||
<string name="mode_gpu_drivers_hint">Partagés entre Steam et le bureau. À configurer dans la page « Composants ».</string>
|
||||
<string name="mode_touch_controls">Tactile et contrôles</string>
|
||||
<string name="mode_touch">Tactile</string>
|
||||
<string name="mode_touch_touchpad">Pavé tactile</string>
|
||||
<string name="mode_touch_direct">Direct</string>
|
||||
<string name="mode_touch_note">Auto : pavé tactile pour le bureau, tactile direct pour Steam.</string>
|
||||
<string name="mode_osc">Commandes à l\'écran</string>
|
||||
<string name="mode_osc_qam">Steam + QAM</string>
|
||||
<string name="mode_osc_note">Le mode automatique affiche les commandes à l\'écran lorsqu\'aucune manette n\'est connectée.</string>
|
||||
<string name="mode_controller">Manette</string>
|
||||
<string name="mode_controller_hint">Comment Steam reconnaît votre manette. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_controller_deck">Manette Steam Deck</string>
|
||||
<string name="mode_controller_x360">Manette Xbox 360</string>
|
||||
<string name="mode_controller_note">Deck : fournit un bouton d\'accès rapide et un gyroscope. Xbox 360 : utilisez Guide+A pour ouvrir l\'accès rapide.</string>
|
||||
<string name="mode_back">Retour</string>
|
||||
<string name="mode_session">Session</string>
|
||||
<string name="mode_suspend">Comportement en arrière-plan</string>
|
||||
<string name="mode_suspend_hint">Comportement lorsque l\'application est masquée ou que l\'écran est éteint.</string>
|
||||
<string name="mode_suspend_manual">Manuel</string>
|
||||
<string name="mode_suspend_note">Le mode automatique met en pause en arrière-plan et reprend à l\'affichage. Le mode manuel nécessite d\'appuyer sur « Reprendre ». Ne reste jamais en cours d\'exécution.</string>
|
||||
<string name="mode_startup">Démarrage</string>
|
||||
<string name="mode_steam_startup">Lancer Steam à l\'ouverture de DroidDeck</string>
|
||||
<string name="mode_steam_startup_hint">Ouvre une session Steam au démarrage de DroidDeck.</string>
|
||||
<string name="mode_decky">Decky</string>
|
||||
<string name="mode_decky_checking">Vérification des versions…</string>
|
||||
<string name="mode_decky_ready">Prêt à installer %1$s.</string>
|
||||
<string name="mode_decky_none">Aucune version compatible trouvée. Veuillez réessayer plus tard.</string>
|
||||
<string name="mode_decky_installed">Installé · %1$s</string>
|
||||
<string name="mode_decky_update">Mise à jour disponible · %1$s</string>
|
||||
<string name="mode_decky_current">À jour · %1$s</string>
|
||||
<string name="mode_decky_loader">Chargeur</string>
|
||||
<string name="mode_decky_install_latest">Installer la dernière version</string>
|
||||
<string name="mode_decky_on">Démarre avec Steam. Une fois activé, d\'autres applications sur cet appareil peuvent contrôler Steam.</string>
|
||||
<string name="mode_decky_off">Désactivé. Le port de débogage distant de Steam reste fermé.</string>
|
||||
<string name="mode_decky_remove_title">Désinstaller Decky Loader ?</string>
|
||||
<string name="mode_decky_remove_text">Supprime le chargeur. Vos plugins et paramètres seront conservés.</string>
|
||||
<string name="mode_client">Client</string>
|
||||
<string name="mode_deck_mode">Mode Steam Deck</string>
|
||||
<string name="mode_deck_mode_hint">Interface Steam Deck et contrôles de performance à accès rapide. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_mangoapp">Superposition de performance</string>
|
||||
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_branch">Branche du client</string>
|
||||
<string name="mode_branch_deck">Bêta Steam Deck (requis pour le mode Deck).</string>
|
||||
<string name="mode_branch_hint">Prendra effet à la prochaine session. Steam peut se mettre à jour une fois de lui-même.</string>
|
||||
<string name="mode_branch_public">Bêta</string>
|
||||
<string name="mode_branch_deck_beta">Bêta Steam Deck</string>
|
||||
<string name="mode_branch_note">Le mode Deck utilise toujours la version bêta de Steam Deck.</string>
|
||||
<string name="mode_added_games">Jeux ajoutés</string>
|
||||
<string name="mode_added_none">Aucun dossier de jeu contenant un .exe trouvé</string>
|
||||
<string name="mode_games_folder">Dossier de jeux</string>
|
||||
<string name="mode_games_folder_another">Autre dossier de jeux</string>
|
||||
<string name="mode_added_art">Images depuis Steam</string>
|
||||
<string name="mode_added_art_hint">Complète les images manquantes selon le nom du dossier. Les fichiers cover.jpg, hero.jpg, logo.png ou icon.png dans le dossier du jeu sont prioritaires.</string>
|
||||
<string name="mode_added_launches">Lancer %1$s</string>
|
||||
<string name="mode_added_launches_in">Lancer %1$s · dans %2$s</string>
|
||||
<string name="mode_added_choose">Choisir un autre fichier…</string>
|
||||
<string name="mode_added_exe_note">Utilise par défaut le .exe portant le même nom que le dossier, sinon le .exe le plus volumineux.</string>
|
||||
<string name="mode_stretch169">Étirer les jeux 16:9</string>
|
||||
<string name="mode_stretch169_hint">Sur les écrans dont le rapport d\'aspect est inférieur à 16:9, étire le jeu pour remplir l\'écran au lieu d\'afficher des bandes noires. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
<string name="mode_directaudio">Utiliser DirectAudio pour le jeu</string>
|
||||
<string name="mode_directaudio_hint">Contourne PulseAudio pour réduire la latence.</string>
|
||||
<string name="mode_client_audio">Audio du client Steam</string>
|
||||
<string name="mode_client_audio_hint">Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_client_audio_classic">Classique</string>
|
||||
<string name="mode_client_audio_direct">DirectAudio</string>
|
||||
<string name="mode_mic">Microphone</string>
|
||||
<string name="mode_mic_hint">Utilisé pour le chat vocal.</string>
|
||||
<string name="mode_network">Réseau</string>
|
||||
<string name="mode_wifi_discovery">Découverte Wi-Fi</string>
|
||||
<string name="mode_wifi_discovery_hint">Affiche les noms Wi-Fi et les réseaux à proximité dans Steam.</string>
|
||||
<string name="mode_wifi_location_off">Les services de localisation sont désactivés.</string>
|
||||
<string name="mode_wifi_permission_denied">L\'autorisation de localisation n\'a pas été accordée.</string>
|
||||
<string name="mode_wifi_open_settings">Ouvrir les paramètres</string>
|
||||
<string name="mode_wifi_continue">Continuer</string>
|
||||
<string name="mode_wifi_explain_title">Activer la découverte Wi-Fi ?</string>
|
||||
<string name="mode_wifi_explain_text">Android a besoin de l\'autorisation de localisation pour lire les noms Wi-Fi et découvrir les réseaux à proximité. DroidDeck utilise cette autorisation pour afficher ces réseaux dans Steam. Nous ne suivons pas votre position. L\'accès à Internet reste possible sans cette autorisation.</string>
|
||||
<string name="mode_storage">Stockage des jeux</string>
|
||||
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible</string>
|
||||
<string name="mode_storage_auto_none">Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement)</string>
|
||||
<string name="mode_storage_internal">Stockage interne uniquement</string>
|
||||
<string name="mode_storage_folder">Dossier : %1$s</string>
|
||||
<string name="mode_second_library">Bibliothèque secondaire</string>
|
||||
<string name="mode_storage_note">Les jeux lisant des ressources depuis une carte SD ou un stockage partagé peuvent saccader.</string>
|
||||
<string name="mode_choose_folder">Choisir un dossier…</string>
|
||||
<string name="mode_renderer">Rendu</string>
|
||||
<string name="mode_desktop_renderer">Moteur de rendu du bureau</string>
|
||||
<string name="mode_renderer_hint">Responsable du dessin du bureau.</string>
|
||||
<string name="mode_renderer_vulkan">vulkan - GPU</string>
|
||||
<string name="mode_renderer_gles2">gles2 - GPU (expérimental)</string>
|
||||
<string name="mode_renderer_pixman">pixman - Logiciel</string>
|
||||
<string name="mode_renderer_note">Vulkan utilise le GPU pour exécuter les programmes de bureau, et bascule vers pixman en cas d\'échec. Avec pixman, les jeux s\'exécutent toujours en plein écran sur le GPU.</string>
|
||||
<string name="mode_custom_res_title">Résolution personnalisée</string>
|
||||
<string name="mode_custom_res_text">Remplace la limite de résolution et le format d\'écran. Les dimensions ne correspondant pas à l\'écran afficheront des bandes noires.</string>
|
||||
<string name="mode_width">Largeur</string>
|
||||
<string name="mode_height">Hauteur</string>
|
||||
<string name="mode_custom_res_range">Plage : 320×240 à 3840×2160.</string>
|
||||
<plurals name="mode_added_count">
|
||||
<item quantity="one">%1$d jeu</item>
|
||||
<item quantity="other">%1$d jeux</item>
|
||||
</plurals>
|
||||
<string name="common_ok">OK</string>
|
||||
<string name="common_delete">Supprimer</string>
|
||||
<string name="common_restore">Restaurer</string>
|
||||
<string name="common_download">Télécharger</string>
|
||||
<string name="common_delete_named">Supprimer %1$s</string>
|
||||
<string name="common_size_mb">%1$.1f Mo</string>
|
||||
<string name="comp_runtime_driver">Pilote d\'environnement d\'exécution</string>
|
||||
<string name="comp_runtime_driver_hint">Utilisé par Steam, les jeux et le bureau. Prendra effet à la prochaine session.</string>
|
||||
<string name="comp_import_turnip">Importer Turnip zip…</string>
|
||||
<string name="comp_display_driver">Pilote d\'affichage</string>
|
||||
<string name="comp_display_driver_hint">Responsable de la sortie vidéo vers l\'écran dans les deux modes. Prendra effet après le redémarrage de l\'application.</string>
|
||||
<string name="comp_import_adrenotools">Importer AdrenoTools zip…</string>
|
||||
<string name="comp_never">Jamais</string>
|
||||
<string name="comp_nightlies_checked">Nightlies vérifié le %1$s</string>
|
||||
<string name="comp_title">Composants</string>
|
||||
<string name="comp_about">À propos des composants</string>
|
||||
<string name="comp_check_drivers">Vérifier les nouveaux pilotes</string>
|
||||
<string name="comp_check_nightlies">Vérifier les nouveaux paquets dans Nightlies</string>
|
||||
<string name="comp_proton">Proton</string>
|
||||
<string name="comp_proton_note">Les mises à jour de Steam remplacent le Proton de Valve ; les fichiers originaux de chaque version sont conservés.</string>
|
||||
<string name="comp_swapped">%1$s · %2$d remplacé(s)</string>
|
||||
<string name="comp_all_original">%1$s · Tout est original</string>
|
||||
<string name="comp_game_running_suffix">%1$s · Jeu en cours d\'exécution</string>
|
||||
<string name="comp_gpu_drivers">Pilotes GPU</string>
|
||||
<string name="comp_reapplied">%1$s · Réappliqué au lancement de %2$s</string>
|
||||
<string name="comp_next_queued">Suivant : %1$s · Après la fermeture du jeu</string>
|
||||
<string name="comp_in_use">%1$s en cours d\'utilisation : %2$s</string>
|
||||
<string name="comp_waits">En attente de la fermeture du jeu</string>
|
||||
<string name="comp_applies_next_game">Prendra effet au prochain lancement du jeu</string>
|
||||
<string name="comp_import_zip">Importer .zip</string>
|
||||
<string name="comp_reading">Lecture de Proton…</string>
|
||||
<string name="comp_no_proton">Proton n\'est pas encore installé. Lancez Steam une fois pour télécharger son Proton ARM64, ou ajoutez GE-Proton ou CachyOS depuis les « Paramètres ».</string>
|
||||
<string name="comp_original">Original · %1$s</string>
|
||||
<string name="comp_this_build">%1$s · Version actuelle</string>
|
||||
<string name="comp_earlier_build">%1$s · Version antérieure</string>
|
||||
<string name="comp_installed_count">Installé · %1$d</string>
|
||||
<string name="comp_nothing_stored">Rien n\'a encore été enregistré pour %1$s. Les fichiers fournis avec ce Proton seront enregistrés comme fichiers originaux.</string>
|
||||
<string name="comp_waits_long">Un jeu utilise actuellement ce Proton, il faut donc attendre sa fermeture.</string>
|
||||
<string name="comp_applies_long">Prendra effet au prochain lancement du jeu.</string>
|
||||
<string name="comp_swap_title">Remplacer %1$s ?</string>
|
||||
<string name="comp_swap_body">Placer %1$s dans %2$s ? Ses fichiers originaux continueront d\'être conservés. %3$s</string>
|
||||
<string name="comp_restore_title">Restaurer %1$s ?</string>
|
||||
<string name="comp_restore_body">Remettre les fichiers originaux de %1$s dans %2$s ? %3$s</string>
|
||||
<string name="comp_delete_package_body">Supprime ce paquet de l\'application. Il pourra être téléchargé à nouveau ultérieurement.</string>
|
||||
<string name="comp_delete_original_title">Supprimer cette version originale ?</string>
|
||||
<string name="comp_delete_original_body">Cette version originale de %1$s provient d\'une version antérieure de %2$s. La version originale de la version actuelle sera toujours conservée.</string>
|
||||
<string name="comp_nightlies_count">Dans Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Actualiser pour lister les paquets dans Nightlies.</string>
|
||||
<string name="comp_nothing_new">Aucun nouvel élément depuis la dernière vérification.</string>
|
||||
<string name="comp_import_wcp">Importer .wcp</string>
|
||||
<string name="comp_restore_original">Restaurer les fichiers originaux de ce Proton</string>
|
||||
<string name="comp_restore_original_body">Remettre les fichiers originaux %2$s de %1$s ?</string>
|
||||
<string name="comp_swap">Basculer</string>
|
||||
<string name="comp_about_text">FEX, DXVK et VKD3D-Proton : pour chaque Proton utilisé par Steam pour exécuter des jeux.\n\nSélectionnez un élément installé pour le remplacer. Cela prendra effet au prochain lancement du jeu, ou après la fermeture du jeu en cours d\'exécution.\n\nVotre sélection sera revérifiée avant chaque lancement ; si elle a été modifiée, elle sera restaurée. Les fichiers originaux de chaque Proton sont conservés, ils ne seront donc jamais perdus lors des mises à jour de Steam.</string>
|
||||
<string name="comp_tag_original">Original</string>
|
||||
<string name="comp_tag_in_use">Utilisé</string>
|
||||
<string name="comp_tag_new">Nouveau</string>
|
||||
<string name="comp_tag_stored">Enregistré</string>
|
||||
<string name="perf_title">Performance</string>
|
||||
<string name="perf_lede">Cœurs CPU, correctifs de session et nom d\'hôte. Prendra effet à la prochaine session.</string>
|
||||
<string name="perf_client_cores_group">Cœurs du client Steam</string>
|
||||
<string name="perf_override">Remplacer la sélection des cœurs de Steam</string>
|
||||
<string name="perf_override_hint">Fixe Steam, l\'assistant d\'interface et gamescope sur les cœurs sélectionnés ci-dessous.</string>
|
||||
<string name="perf_client_cores">Cœurs du client</string>
|
||||
<string name="perf_client_cores_on">Cœurs utilisés par Steam.</string>
|
||||
<string name="perf_client_cores_off">Activer le remplacement pour pouvoir sélectionner.</string>
|
||||
<string name="perf_game_cores_group">Cœurs de jeu</string>
|
||||
<string name="perf_game_cores">Cœurs de jeu</string>
|
||||
<string name="perf_game_cores_hint">Cœurs utilisés par les jeux. Si tous sont sélectionnés, Android planifie librement.</string>
|
||||
<string name="perf_client_ui">Interface du client</string>
|
||||
<string name="perf_glthread">Multithread GL</string>
|
||||
<string name="perf_glthread_hint">Peut rendre les menus Steam plus réactifs.</string>
|
||||
<string name="perf_zink">Zink : descripteurs différés</string>
|
||||
<string name="perf_zink_hint">Recommandé pour les pilotes sans tampon de descripteurs.</string>
|
||||
<string name="perf_noglerror">Ignorer la vérification des erreurs GL</string>
|
||||
<string name="perf_noglerror_hint">Désactive la validation GL par appel.</string>
|
||||
<string name="perf_gsrealtime">gamescope : file d\'attente GPU temps réel</string>
|
||||
<string name="perf_gsrealtime_hint">Augmente la priorité du travail GPU du compositeur. Peut améliorer la stabilité du temps de frame, mais consomme une partie des performances du jeu.</string>
|
||||
<string name="perf_gpu">GPU</string>
|
||||
<string name="perf_gpuclock">Maintenir le GPU à sa fréquence maximale</string>
|
||||
<string name="perf_gpuclock_hint">Uniquement pour Adreno. Moins de saccades, mais plus de consommation d\'énergie et de chaleur.</string>
|
||||
<string name="perf_fixes">Correctifs de session</string>
|
||||
<string name="perf_sysmem">Turnip : rendu sysmem</string>
|
||||
<string name="perf_sysmem_hint">Steam et la plupart des jeux seront plus rapides. Requis pour Adreno 710/720/722.</string>
|
||||
<string name="perf_xalia">Ignorer l\'assistant xalia de Steam</string>
|
||||
<string name="perf_xalia_hint">Désactive l\'assistant de navigation à la manette de Proton, qui consomme du CPU dans chaque jeu.</string>
|
||||
<string name="perf_seccomp">Exécuter proot sans seccomp</string>
|
||||
<string name="perf_seccomp_hint">Peut corriger certaines erreurs d\'appels système manquants sur certains noyaux, mais peut réduire les performances.</string>
|
||||
<string name="perf_fastpath">Chemin rapide proot</string>
|
||||
<string name="perf_fastpath_needs_seccomp">Nécessite le filtre seccomp de proot.</string>
|
||||
<string name="perf_fastpath_hint">Recherche de fichiers plus rapide, moins de saccades. Désactivez cette option si un programme ne trouve pas ses fichiers.</string>
|
||||
<string name="perf_identity">Identité de la session</string>
|
||||
<string name="perf_hostname">Nom d\'hôte</string>
|
||||
<string name="perf_hostname_hint">Le nom sous lequel Steam voit cet appareil. Laisser vide pour restaurer %1$s.</string>
|
||||
<string name="perf_hostname_invalid">Ne peut contenir que des lettres, des chiffres et des tirets au milieu, jusqu\'à 63 caractères.</string>
|
||||
<string name="perf_phantom_warning">Android est configuré pour terminer cette session</string>
|
||||
<string name="driver_installed">Installé</string>
|
||||
<string name="driver_available">Téléchargeable</string>
|
||||
<string name="driver_nothing_new">Aucun nouvel élément depuis la dernière vérification.</string>
|
||||
<string name="driver_restore_builtin">Restaurer le pilote intégré</string>
|
||||
<string name="driver_delete_builtin">Pilote intégré à l\'application : cette opération supprimera les fichiers décompressés et masquera le pilote. Utilisez « Restaurer le pilote intégré » pour le restaurer.</string>
|
||||
<string name="driver_delete_bundle">Moitié du bundle Android + Linux : les deux parties seront supprimées. Le pilote en cours d\'utilisation basculera vers le pilote par défaut.</string>
|
||||
<string name="driver_delete_plain">Ses fichiers seront supprimés de l\'application.</string>
|
||||
<string name="driver_delete_in_use">Ses fichiers seront supprimés. Comme il est en cours d\'utilisation, le pilote par défaut sera utilisé à la place.</string>
|
||||
<string name="drawer_page_display">Affichage</string>
|
||||
<string name="drawer_page_controls">Contrôles</string>
|
||||
<string name="drawer_page_components">Composants</string>
|
||||
<string name="drawer_page_settings">Paramètres</string>
|
||||
<string name="drawer_page_desc">Page %1$s</string>
|
||||
<string name="drawer_steam">Steam</string>
|
||||
<string name="drawer_desktop">Bureau</string>
|
||||
<string name="drawer_steam_menu">Menu Steam</string>
|
||||
<string name="drawer_quick_access">Accès rapide</string>
|
||||
<string name="drawer_open_qam">Ouvrir le menu d\'accès rapide</string>
|
||||
<string name="drawer_prev_page">Page précédente</string>
|
||||
<string name="drawer_next_page">Page suivante</string>
|
||||
<string name="drawer_hud">HUD de performance</string>
|
||||
<string name="drawer_touch_auto">Auto (%1$s)</string>
|
||||
<string name="drawer_keyboard">Clavier</string>
|
||||
<string name="drawer_pc_keyboard">Clavier PC</string>
|
||||
<string name="drawer_android_keyboard">Android</string>
|
||||
<string name="drawer_second_screen">Deuxième écran</string>
|
||||
<string name="drawer_second_screen_shows">Afficher</string>
|
||||
<string name="drawer_android_apps">Applications Android</string>
|
||||
<string name="drawer_launch_app">Lancer l\'application</string>
|
||||
<string name="common_hide">Masquer</string>
|
||||
<string name="common_show">Afficher</string>
|
||||
<string name="drawer_no_apps">Aucune application à lancer</string>
|
||||
<string name="drawer_session_behavior">Comportement de la session</string>
|
||||
<string name="drawer_suspend_hint">Appliqué immédiatement, et prendra effet pour les futures sessions de ce mode.</string>
|
||||
<string name="drawer_next_session">Prochaine session</string>
|
||||
<string name="drawer_support">Assistance</string>
|
||||
<string name="drawer_logs">Journaux de session</string>
|
||||
<string name="drawer_logs_hint">Joindre au rapport d\'erreur</string>
|
||||
<string name="drawer_share_logs">Partager les journaux</string>
|
||||
<string name="drawer_background">Arrière-plan</string>
|
||||
<string name="drawer_stop">Arrêter</string>
|
||||
<string name="drawer_stop_title">Arrêter la session ?</string>
|
||||
<string name="drawer_running_now">En cours d\'exécution</string>
|
||||
<string name="drawer_no_game">Aucun jeu n\'utilise actuellement Proton</string>
|
||||
<string name="drawer_no_proton">Proton n\'est pas encore installé.</string>
|
||||
<string name="drawer_game_running">Jeu en cours d\'exécution</string>
|
||||
<string name="drawer_reapplied">Réappliqué au lancement de %1$s</string>
|
||||
<string name="drawer_original">Original</string>
|
||||
<string name="drawer_components_note">Veuillez télécharger, importer et supprimer depuis la page « Composants ».</string>
|
||||
<string name="setup_check_done">Terminé</string>
|
||||
<string name="setup_check_warn">À traiter</string>
|
||||
<string name="setup_check_busy">Traitement en cours</string>
|
||||
<string name="setup_limit_adb_failed">Veuillez vérifier l\'adresse IP et le port du débogage sans fil ; si Android a supprimé cet appareil, veuillez le réappairer.</string>
|
||||
<string name="setup_install">Installer</string>
|
||||
<string name="setup_manage">Gérer</string>
|
||||
<string name="setup_tab_overview">Aperçu</string>
|
||||
<string name="setup_tab_controller">Manette</string>
|
||||
<string name="setup_tab_session">Session</string>
|
||||
<string name="setup_tab_launcher">Lanceur</string>
|
||||
<string name="setup_title">Paramètres</string>
|
||||
<string name="setup_all_set">● Tout est prêt</string>
|
||||
<string name="setup_n_ready">%1$d/%2$d prêt(s)</string>
|
||||
<string name="setup_system_check">Vérification du système</string>
|
||||
<string name="setup_gpu_supported">Appareil pris en charge</string>
|
||||
<string name="setup_gpu_untested">GPU non testé</string>
|
||||
<string name="setup_gpu_unsupported">GPU non pris en charge</string>
|
||||
<string name="setup_gpu_detail">%1$s · %2$s</string>
|
||||
<string name="setup_gpu_untested_detail">%1$s : %2$s</string>
|
||||
<string name="setup_gpu_unsupported_detail">Steam nécessite un pilote Adreno ; %1$s peut afficher un écran noir.</string>
|
||||
<string name="setup_runtime">Environnement d\'exécution Linux</string>
|
||||
<string name="setup_runtime_progress">%1$s · %2$d%%</string>
|
||||
<string name="setup_runtime_missing">Non installé · Environ 3 Go, installé lors du premier lancement d\'un jeu</string>
|
||||
<string name="setup_installed">Installé</string>
|
||||
<string name="setup_runtime_update">%1$s · Mise à jour disponible</string>
|
||||
<string name="setup_runtime_current">%1$s · À jour</string>
|
||||
<string name="setup_limit">Limite des processus enfants</string>
|
||||
<string name="setup_limit_on">Activée · Android peut fermer Steam. La désactivation de cette limite prend environ une minute.</string>
|
||||
<string name="setup_limit_unset">Non définie · Android peut fermer Steam. La désactivation de cette limite prend environ une minute.</string>
|
||||
<string name="setup_limit_unknown">Impossible de vérifier · La désactivation de cette limite prend environ une minute.</string>
|
||||
<string name="setup_fix_it">Corriger</string>
|
||||
<string name="setup_details">Détails</string>
|
||||
<string name="setup_dev_options">Options pour les développeurs</string>
|
||||
<string name="setup_use_wireless">Utiliser le débogage sans fil</string>
|
||||
<string name="setup_check_again">Vérifier à nouveau</string>
|
||||
<string name="setup_limit_turn_on">Activer la limite</string>
|
||||
<string name="setup_account">Compte Steam</string>
|
||||
<string name="setup_signed_in_offline">Connecté en tant que %1$s · Mode hors ligne</string>
|
||||
<string name="setup_signed_in">Connecté en tant que %1$s</string>
|
||||
<string name="setup_sign_in">Appuyez sur Play et connectez-vous à Steam</string>
|
||||
<string name="setup_tools">Outils</string>
|
||||
<string name="setup_controller">Manette</string>
|
||||
<string name="setup_controller_unavailable">Les paramètres de la manette ne sont pas disponibles.</string>
|
||||
<string name="setup_session">Session</string>
|
||||
<string name="setup_logs">Journaux de session</string>
|
||||
<string name="setup_logs_hint">Enregistré après chaque fin de session</string>
|
||||
<string name="setup_latest_logs">Derniers journaux de session</string>
|
||||
<string name="setup_saved_logs">Journaux de session enregistrés</string>
|
||||
<string name="setup_saved_logs_hint">Conserver les %1$d plus récents</string>
|
||||
<string name="setup_clear_logs">Effacer les journaux</string>
|
||||
<string name="setup_offline">Mode hors ligne</string>
|
||||
<string name="setup_sign_in_first">Veuillez d\'abord vous connecter à Steam</string>
|
||||
<string name="setup_launcher">Lanceur</string>
|
||||
<string name="setup_theme">Thème</string>
|
||||
<string name="setup_theme_hint">Apparence du lanceur</string>
|
||||
<string name="setup_home">Utiliser comme écran d\'accueil</string>
|
||||
<string name="setup_home_on">DroidDeck peut être utilisé comme application d\'écran d\'accueil</string>
|
||||
<string name="setup_home_off">DroidDeck ne sera pas proposé comme application d\'écran d\'accueil</string>
|
||||
<string name="setup_fullscreen">Plein écran</string>
|
||||
<string name="setup_fullscreen_on">Masquer la barre d\'état et la barre de navigation Android</string>
|
||||
<string name="setup_fullscreen_off">Afficher la barre d\'état et la barre de navigation Android</string>
|
||||
<string name="setup_default_home">Application d\'écran d\'accueil par défaut</string>
|
||||
<string name="setup_choose_home">Choisir l\'application d\'écran d\'accueil</string>
|
||||
<string name="setup_choose">Choisir</string>
|
||||
<string name="setup_linux_apps">Applications Linux (bêta)</string>
|
||||
<string name="setup_store">Boutique Flathub</string>
|
||||
<string name="setup_store_on">Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Installer des applications et des jeux Linux depuis Flathub</string>
|
||||
<string name="setup_tool_files">Fichiers</string>
|
||||
<string name="setup_tool_files_hint">Parcourir et gérer les fichiers</string>
|
||||
<string name="setup_tool_protons">Versions de Proton</string>
|
||||
<string name="setup_tool_protons_hint">Installer les versions ARM64 de Proton</string>
|
||||
<string name="setup_tool_performance">Performance</string>
|
||||
<string name="setup_tool_performance_hint">Attribution des cœurs CPU</string>
|
||||
<string name="setup_tool_roms">Dossiers ROM</string>
|
||||
<string name="setup_tool_roms_hint">Emplacement de stockage des jeux d\'émulateur</string>
|
||||
<string name="setup_card_components">Composants</string>
|
||||
<string name="setup_card_components_hint">FEX, DXVK, VKD3D</string>
|
||||
<string name="setup_card_controls">Contrôles</string>
|
||||
<string name="setup_card_controls_hint">Mappage des boutons</string>
|
||||
<string name="store_tab_discover">Découvrir</string>
|
||||
<string name="store_tab_search">Rechercher</string>
|
||||
<string name="store_tab_installed">Installé</string>
|
||||
<string name="store_tab_count">%1$s · %2$d</string>
|
||||
<string name="store_title">Boutique</string>
|
||||
<string name="store_chip_flathub">Flathub · ARM64</string>
|
||||
<string name="store_chip_ready">● Flatpak prêt</string>
|
||||
<string name="store_needs_runtime">Veuillez d\'abord installer l\'environnement d\'exécution Linux depuis les « Paramètres ».</string>
|
||||
<string name="store_setup_title">Configurer Flatpak</string>
|
||||
<string name="store_setup_text">Ajouter Flatpak et se connecter à Flathub (environ 20 Mo). La première application téléchargera également son environnement d\'exécution.</string>
|
||||
<string name="store_setting_up">Configuration en cours…</string>
|
||||
<string name="store_flatpak">Flatpak</string>
|
||||
<string name="store_updates">Mises à jour</string>
|
||||
<string name="store_busy">%1$s · %2$s</string>
|
||||
<string name="store_busy_percent">%1$s · %2$s · %3$d%%</string>
|
||||
<string name="store_starting">Démarrage…</string>
|
||||
<string name="store_categories">Catégories</string>
|
||||
<string name="store_unreachable">Impossible de se connecter à Flathub. Veuillez vérifier votre connexion réseau et réessayer.</string>
|
||||
<string name="store_try_again">Réessayer</string>
|
||||
<string name="store_search_label">Rechercher sur Flathub</string>
|
||||
<string name="store_search_placeholder">Firefox, jeux, émulateurs…</string>
|
||||
<string name="store_search">Rechercher</string>
|
||||
<string name="store_all">Tout</string>
|
||||
<string name="store_searching">Recherche en cours…</string>
|
||||
<string name="store_search_hint">Recherchez par nom ou sélectionnez une catégorie. Seules les applications ARM64 sont répertoriées ici.</string>
|
||||
<string name="store_no_results">Aucune application ARM64 correspondante sur Flathub.</string>
|
||||
<string name="store_results">Résultats</string>
|
||||
<string name="store_check_updates">Vérifier les mises à jour</string>
|
||||
<string name="store_update_all">Tout mettre à jour (%1$d)</string>
|
||||
<string name="store_all_current">● Tout est à jour</string>
|
||||
<string name="store_no_apps">Aucune application pour le moment. Recherchez dans « Découvrir » ou « Rechercher ».</string>
|
||||
<string name="store_setup_first_note">Configurez Flatpak pour pouvoir installer des applications.</string>
|
||||
<string name="store_update_available_prefix">Mise à jour disponible · %1$s</string>
|
||||
<string name="store_open">Ouvrir</string>
|
||||
<string name="store_details">Détails</string>
|
||||
<string name="store_remove">Supprimer</string>
|
||||
<string name="store_press_again">Appuyez à nouveau pour supprimer</string>
|
||||
<string name="store_installing">Installation…</string>
|
||||
<string name="store_install">Installer</string>
|
||||
<string name="store_setup_first">Configurer d\'abord Flatpak</string>
|
||||
<string name="store_download_size">Télécharger %1$s</string>
|
||||
<string name="store_installed_chip">● Installé</string>
|
||||
<string name="store_details_failed">Impossible de charger la page Flathub de cette application.</string>
|
||||
<string name="store_loading">Chargement depuis Flathub…</string>
|
||||
<string name="store_no_arm">Flathub ne propose pas de version ARM64, l\'installation est donc impossible ici.</string>
|
||||
<string name="store_screenshots">Captures d\'écran</string>
|
||||
<string name="store_about">À propos</string>
|
||||
<string name="store_app_id">ID de l\'application</string>
|
||||
<string name="store_license">Licence</string>
|
||||
<string name="store_runtime">Environnement d\'exécution</string>
|
||||
<string name="store_installed_size">Taille installée</string>
|
||||
<string name="store_installed_size_value">%1$s (plus l\'environnement d\'exécution partagé)</string>
|
||||
<string name="store_website">Site web</string>
|
||||
<string name="store_verified">Vérifié</string>
|
||||
<string name="upd_title">Mises à jour</string>
|
||||
<string name="upd_build">DroidDeck %1$s · %2$s</string>
|
||||
<string name="upd_checked">Vérifié le %1$s</string>
|
||||
<string name="upd_not_checked">Non vérifié</string>
|
||||
<string name="upd_perm_title">Autoriser DroidDeck à se mettre à jour</string>
|
||||
<string name="upd_perm_text">Activez « Autoriser depuis cette source » sur la page suivante, puis revenez. La mise à jour continuera automatiquement.</string>
|
||||
<string name="upd_open_settings">Ouvrir les paramètres</string>
|
||||
<string name="upd_not_now">Pas maintenant</string>
|
||||
<string name="upd_signed_status">Signature différente</string>
|
||||
<string name="upd_signed_headline">Impossible de mettre à jour sur place</string>
|
||||
<string name="upd_signed_detail">Cette copie n\'est pas signée avec la clé de publication de DroidDeck, elle ne peut donc pas être mise à jour par écrasement direct. Veuillez désinstaller puis basculer.</string>
|
||||
<string name="upd_available">Mise à jour disponible</string>
|
||||
<string name="upd_blocked_status">Installation impossible</string>
|
||||
<string name="upd_switch">Basculer possible</string>
|
||||
<string name="upd_ahead_status">En avance sur Stable</string>
|
||||
<string name="upd_ahead_headline">Vous êtes en avance sur Stable</string>
|
||||
<string name="upd_ahead_detail">Cette version est plus récente que la dernière Stable. Vous basculerez sur Stable après la publication de la prochaine version Stable.</string>
|
||||
<string name="upd_gone_status">Test terminé</string>
|
||||
<string name="upd_gone_headline">Ce test est terminé</string>
|
||||
<string name="upd_gone_detail">Ce correctif a été fusionné ou abandonné. Suivez Preview pour obtenir les derniers correctifs.</string>
|
||||
<string name="upd_current_status">À jour</string>
|
||||
<string name="upd_current_headline">Vous disposez de la dernière version de %1$s</string>
|
||||
<string name="upd_dot_ago">· %1$s</string>
|
||||
<string name="upd_stage_percent">%1$s… %2$d%%</string>
|
||||
<string name="upd_stage">%1$s…</string>
|
||||
<string name="upd_update_button">Mettre à jour %1$s</string>
|
||||
<string name="upd_install_button">Installer %1$s</string>
|
||||
<string name="upd_install_anyway">Installer Stable %1$s quand même</string>
|
||||
<string name="upd_follow_preview">Suivre Preview</string>
|
||||
<string name="upd_stop_session">Veuillez arrêter la session en cours avant de mettre à jour.</string>
|
||||
<string name="upd_less">Réduire</string>
|
||||
<string name="upd_more">Plus</string>
|
||||
<string name="upd_new_stable">DroidDeck %1$s</string>
|
||||
<string name="upd_new_preview">Nouvelle version Preview</string>
|
||||
<string name="upd_new_test">Version de test PR #%1$d</string>
|
||||
<string name="upd_mb">%1$d Mo</string>
|
||||
<string name="upd_channel">Canal de mise à jour</string>
|
||||
<string name="upd_stable">Stable</string>
|
||||
<string name="upd_stable_hint">Version testée, adaptée à la plupart des utilisateurs</string>
|
||||
<string name="upd_version_ago">%1$s · %2$s</string>
|
||||
<string name="upd_preview">Preview</string>
|
||||
<string name="upd_preview_hint">Derniers correctifs avant Stable</string>
|
||||
<string name="upd_preview_current">À jour</string>
|
||||
<string name="upd_tests">Versions de test</string>
|
||||
<string name="upd_tests_hint">Essayer un correctif avant sa publication officielle</string>
|
||||
<string name="upd_tests_none">Aucun pour le moment</string>
|
||||
<string name="upd_pr">PR #%1$d</string>
|
||||
<string name="upd_not_built">Non compilé pour cette copie de DroidDeck</string>
|
||||
<string name="upd_updated">%1$s mis à jour</string>
|
||||
<string name="upd_test_name">Version de test PR #%1$d</string>
|
||||
<string name="upd_while_ago">Il y a peu</string>
|
||||
<string name="upd_just_now">À l\'instant</string>
|
||||
<plurals name="upd_tests_count">
|
||||
<item quantity="one">%1$d disponible</item>
|
||||
<item quantity="other">%1$d disponibles</item>
|
||||
</plurals>
|
||||
<plurals name="upd_preview_behind">
|
||||
<item quantity="one">Modifications depuis votre version · %1$d version de retard</item>
|
||||
<item quantity="other">Modifications depuis votre version · %1$d versions de retard</item>
|
||||
</plurals>
|
||||
<plurals name="upd_preview_recent">
|
||||
<item quantity="one">Modifications récentes · %1$d version</item>
|
||||
<item quantity="other">Modifications récentes · %1$d versions</item>
|
||||
</plurals>
|
||||
<string name="gate_title">Un paramètre Android à modifier</string>
|
||||
<string name="gate_eyebrow">Steam</string>
|
||||
<string name="gate_lede">Android arrête les applications qui lancent de nombreux processus en arrière-plan, et Steam en lance des dizaines. Tant que ce paramètre n\'est pas modifié, les jeux peuvent se fermer sans message d\'erreur.</string>
|
||||
<string name="gate_fix_for_me">Corriger pour moi</string>
|
||||
<string name="gate_toggle_steps">Activez « Désactiver la limite de processus enfants », puis revenez. Gardez les options pour les développeurs activées, sinon l\'interrupteur se désactivera à nouveau.</string>
|
||||
<string name="gate_open_dev">Ouvrir les options pour les développeurs</string>
|
||||
<string name="gate_no_toggle">Cette version d\'Android ne dispose pas de cet interrupteur, DroidDeck effectuera donc la modification via le débogage sans fil. Cela prend environ une minute, sans ordinateur.</string>
|
||||
<string name="gate_needs_wifi">Le débogage sans fil nécessite un Wi-Fi. N\'importe quel réseau convient, Internet n\'est pas nécessaire.</string>
|
||||
<string name="gate_needs_notifications">Le code d\'appairage s\'affichera dans une notification. Veuillez autoriser DroidDeck à envoyer des notifications, ou utilisez « Autre méthode ».</string>
|
||||
<string name="gate_allow_notifications">Autoriser les notifications</string>
|
||||
<string name="gate_step1">Ouvrez Paramètres → Options pour les développeurs → Débogage sans fil, et activez-le</string>
|
||||
<string name="gate_step2">Appuyez sur « Associer l\'appareil avec un code d\'appairage »</string>
|
||||
<string name="gate_step3">Saisissez le code d\'appairage dans la notification DroidDeck</string>
|
||||
<string name="gate_waiting">En attente de la fenêtre d\'appairage…</string>
|
||||
<string name="gate_code_needed">Fenêtre trouvée. Veuillez saisir le code d\'appairage affiché dans la notification.</string>
|
||||
<string name="gate_status">État</string>
|
||||
<string name="gate_polling">Vérification toutes les 2 secondes. Cette page se fermera automatiquement une fois cet interrupteur désactivé.</string>
|
||||
<string name="gate_other_ways">Autres méthodes</string>
|
||||
<string name="gate_other_toggle">Débogage sans fil, saisie manuelle de l\'adresse, ADB sur PC</string>
|
||||
<string name="gate_other_no_toggle">Saisie manuelle de l\'adresse, ADB sur PC, options pour les développeurs</string>
|
||||
<string name="gate_use_wireless_instead">Utiliser plutôt le débogage sans fil</string>
|
||||
<string name="gate_some_roms">Certaines ROM proposent encore cet interrupteur. Recherchez « processus enfants » dans les options pour les développeurs.</string>
|
||||
<string name="gate_manual_hint">Si la méthode par notification ne fonctionne pas, saisissez manuellement l\'adresse et le code d\'appairage de la fenêtre.</string>
|
||||
<string name="gate_enter_manually">Saisir l\'adresse manuellement</string>
|
||||
<string name="gate_from_computer">Utiliser ADB depuis un ordinateur :</string>
|
||||
<string name="gate_copy_command">Copier la commande</string>
|
||||
<string name="gate_choose_display">Choisissez l\'écran sur lequel ouvrir les paramètres Android.</string>
|
||||
<string name="gate_main_screen">Écran principal</string>
|
||||
<string name="wadb_finding_port">Appairé. Recherche du port de débogage sans fil…</string>
|
||||
<string name="wadb_enter_port">Appairé. Veuillez saisir le port indiqué dans les paramètres.</string>
|
||||
<string name="wadb_applying">Appareil trouvé. Application des paramètres…</string>
|
||||
<string name="wadb_apply_failed">Impossible d\'appliquer automatiquement. Veuillez vérifier l\'adresse ci-dessous et réessayer. %1$s</string>
|
||||
<string name="wadb_title_wireless">Débogage sans fil</string>
|
||||
<string name="wadb_title_done">Limite des processus enfants mise à jour</string>
|
||||
<string name="wadb_title">Modifier la limite des processus enfants</string>
|
||||
<string name="wadb_lede_compact0">Si cette option n\'est pas disponible, appairez via le débogage sans fil.</string>
|
||||
<string name="wadb_lede_compact1">Appairé. Veuillez vérifier l\'adresse IP et le port dans le débogage sans fil.</string>
|
||||
<string name="wadb_lede_done">La limite des processus enfants a été mise à jour.</string>
|
||||
<string name="wadb_lede0">Si l\'option de processus enfants n\'est pas disponible dans les options pour les développeurs, utilisez l\'étape 2. Ouvrez Options pour les développeurs → Débogage sans fil → Associer l\'appareil avec un code d\'appairage.</string>
|
||||
<string name="wadb_lede1">Appairé. Utilisez l\'adresse ci-dessous, ou mettez-la à jour dans Débogage sans fil → Adresse IP et port.</string>
|
||||
<string name="wadb_lede_done_long">Limite des processus enfants mise à jour via le débogage sans fil.</string>
|
||||
<string name="wadb_confirmed_on">Android a confirmé que la limite est activée.</string>
|
||||
<string name="wadb_confirmed_off">Android a confirmé que la limite est désactivée.</string>
|
||||
<string name="wadb_pair_group">2 · Appairer le débogage sans fil</string>
|
||||
<string name="wadb_pair_address">Adresse IP et port dans la fenêtre d\'appairage</string>
|
||||
<string name="wadb_pin">Code d\'appairage (PIN)</string>
|
||||
<string name="wadb_pin_placeholder">6 chiffres</string>
|
||||
<string name="wadb_pair">Appairer</string>
|
||||
<string name="wadb_apply_group">2 · Appliquer via le débogage sans fil</string>
|
||||
<string name="wadb_connect_address">Adresse IP et port de connexion</string>
|
||||
<string name="wadb_pair_again">Réappairer</string>
|
||||
<string name="wadb_turn_off">Désactiver la limite</string>
|
||||
<string name="wadb_other_ways">Autres méthodes de modification</string>
|
||||
<string name="wadb_dev_try_first">1 · Options pour les développeurs (à essayer en premier)</string>
|
||||
<string name="wadb_restrict_on">Activez « Limiter les processus enfants ».</string>
|
||||
<string name="wadb_restrict_off">Désactivez « Limiter les processus enfants ».</string>
|
||||
<string name="wadb_computer_last">3 · ADB sur PC (en dernier recours)</string>
|
||||
<string name="wadb_only_if">À utiliser uniquement si le débogage sans fil n\'est pas disponible.</string>
|
||||
<string name="wadb_computer_group">3 · ADB sur PC (en dernier recours)</string>
|
||||
<string name="ctrl_osc_hint">Lors de l\'affichage des commandes à l\'écran en session</string>
|
||||
<string name="ctrl_color">Couleur</string>
|
||||
<string name="ctrl_color_hint">Teinte des boutons à l\'écran</string>
|
||||
<string name="ctrl_custom">Personnalisée</string>
|
||||
<string name="ctrl_opacity">Opacité</string>
|
||||
<string name="ctrl_percent">%1$d%%</string>
|
||||
<string name="ctrl_size">Taille des boutons</string>
|
||||
<string name="ctrl_size_hint" formatted="false">100 % correspond à la taille standard</string>
|
||||
<string name="ctrl_stick_click">Clic du joystick</string>
|
||||
<string name="ctrl_stick_click_hint">Double-cliquez et maintenez le joystick pour déclencher L3 ou R3</string>
|
||||
<string name="ctrl_adaptive">Joystick adaptatif</string>
|
||||
<string name="ctrl_rumble">Vibration du jeu</string>
|
||||
<string name="ctrl_steam_button">Bouton Steam</string>
|
||||
<string name="ctrl_qam_button">Bouton QAM</string>
|
||||
<string name="ctrl_keyboard_button">Bouton clavier</string>
|
||||
<string name="setup_animations">Animations</string>
|
||||
<string name="ctrl_adaptive_hint">Le joystick apparaîtra là où vous touchez et se masquera une fois relâché</string>
|
||||
<string name="ctrl_layout">Disposition</string>
|
||||
<string name="ctrl_layout_custom">Position personnalisée enregistrée</string>
|
||||
<string name="ctrl_layout_default">Adapté à cet écran</string>
|
||||
<string name="ctrl_edit">Modifier</string>
|
||||
<string name="ctrl_reset">Réinitialiser</string>
|
||||
<string name="ctrl_mapping">Mappage des boutons</string>
|
||||
<string name="ctrl_mapping_default">Chaque bouton envoie l\'entrée d\'origine correspondante</string>
|
||||
<string name="ctrl_mapping_count">%1$d/%2$d boutons remappés</string>
|
||||
<string name="ctrl_configure">Configurer</string>
|
||||
<string name="ctrl_reset_all">Réinitialiser la manette</string>
|
||||
<string name="ctrl_reset_all_hint">Restaurer tous les paramètres et dispositions de la manette</string>
|
||||
<string name="ctrl_mapping_lede">Définissez l\'entrée que chaque bouton à l\'écran envoie au jeu. « Masquer » supprimera ce bouton.</string>
|
||||
<string name="ctrl_onscreen_buttons">Boutons à l\'écran</string>
|
||||
<string name="ctrl_defaults">Valeurs par défaut</string>
|
||||
<string name="ctrl_reset_mapping">Réinitialiser le mappage</string>
|
||||
<string name="content_android_apps">Applications Android</string>
|
||||
<string name="content_apps_count">%1$d applications</string>
|
||||
<string name="content_no_android_apps">Aucune application Android lançable trouvée.</string>
|
||||
<string name="content_desktop_installed">● Bureau installé</string>
|
||||
<string name="content_desktop_first_open">Installé à la première ouverture</string>
|
||||
<string name="content_available">Installable</string>
|
||||
<string name="content_not_installed">Non installé.</string>
|
||||
<string name="content_not_installed_suffix">%1$s · Non installé</string>
|
||||
<string name="content_open_named">Ouvrir %1$s</string>
|
||||
<string name="content_games">Jeux</string>
|
||||
<string name="content_choose_roms">Sélectionnez un dossier ROM.</string>
|
||||
<string name="content_retroarch_roms">Dans RetroArch, naviguez vers /root/ROMs.</string>
|
||||
<string name="content_add_roms">Ajout de %1$s jeux à ROMs/%2$s.</string>
|
||||
<string name="content_install_named">Installer %1$s</string>
|
||||
<string name="content_stop_to_install">Arrêter la session pour installer</string>
|
||||
<string name="content_runtime_required">Environnement d\'exécution requis</string>
|
||||
<string name="content_loading_details">Chargement des détails d\'installation…</string>
|
||||
<string name="content_details_unavailable">Détails d\'installation non disponibles. Veuillez réessayer plus tard.</string>
|
||||
<string name="content_needs_runtime">Veuillez d\'abord installer l\'environnement d\'exécution Linux depuis les « Paramètres ».</string>
|
||||
<string name="content_stop_first">Veuillez arrêter la session en cours avant d\'installer une application de bureau.</string>
|
||||
<string name="content_install_into">Installer %1$s sur le bureau Linux.</string>
|
||||
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
|
||||
<string name="content_hide_note">Masquer le retirera du bureau. Les fichiers resteront dans l\'environnement d\'exécution Linux.</string>
|
||||
<string name="content_rom_gone">Ce jeu n\'est plus dans le dossier ROM.</string>
|
||||
<string name="content_desktop_system">Bureau · %1$s</string>
|
||||
<string name="content_launch_in">Lancer dans %1$s</string>
|
||||
<string name="content_also_in">Également dans %1$s</string>
|
||||
<string name="content_page_gone">Cette page n\'existe plus. Veuillez sélectionner une section à gauche.</string>
|
||||
<string name="content_open_desktop">Ouvrir le bureau</string>
|
||||
<string name="content_install_open_desktop">Installer et ouvrir le bureau</string>
|
||||
<string name="content_linux_desktop">Bureau Linux</string>
|
||||
<string name="content_linux_desktop_hint">LXQt, Firefox et vos émulateurs</string>
|
||||
<string name="games_empty">Les jeux installés sur Steam apparaîtront ici après la fin de la session.</string>
|
||||
<string name="games_play_steam">Lancer Steam</string>
|
||||
<string name="games_launch_settings">Paramètres de lancement</string>
|
||||
<string name="games_launch">Lancer</string>
|
||||
<string name="games_files">Fichiers du jeu</string>
|
||||
<string name="games_prefix">Préfixe Proton</string>
|
||||
<string name="games_saves_looking">Recherche des sauvegardes…</string>
|
||||
<string name="games_saves_none">Aucun dossier de sauvegarde trouvé par nom. L\'exportation inclura l\'intégralité du dossier utilisateur.</string>
|
||||
<string name="games_saves_folders">%1$d dossiers de sauvegarde</string>
|
||||
<string name="games_saves_summary">%1$s · %2$d fichiers · %3$s</string>
|
||||
<string name="games_kb">%1$d Ko</string>
|
||||
<string name="games_manage_saves">Gérer les sauvegardes</string>
|
||||
<string name="games_saves">Sauvegardes de jeu</string>
|
||||
<string name="games_import_saves">Importer des sauvegardes…</string>
|
||||
<string name="games_import_saves_hint">Archive GameHub ou Winlator. Les sauvegardes actuelles seront sauvegardées avant l\'importation.</string>
|
||||
<string name="games_export_gamehub">Exporter en tant qu\'archive GameHub…</string>
|
||||
<string name="games_export_gamehub_hint">Compatible avec GameHub et BannerHub</string>
|
||||
<string name="games_export_winlator">Exporter en tant qu\'archive Winlator…</string>
|
||||
<string name="games_export_winlator_hint">Compatible avec Winlator, WinNative et Bannerlator</string>
|
||||
<string name="games_open_save_folder">Ouvrir le dossier de sauvegarde</string>
|
||||
<string name="games_never_played">Jamais lancé</string>
|
||||
<string name="games_runtime_busy_percent">Environnement d\'exécution occupé · %1$d%%</string>
|
||||
<string name="games_runtime_busy">Environnement d\'exécution occupé</string>
|
||||
<string name="games_runtime_first_play">Installation de l\'environnement d\'exécution lors du premier lancement du jeu</string>
|
||||
<string name="games_runtime_update">Mise à jour de l\'environnement d\'exécution disponible</string>
|
||||
<string name="games_runtime_ready">● Environnement d\'exécution prêt</string>
|
||||
<string name="games_last_played">Dernier lancement %1$s</string>
|
||||
<string name="games_internal">Stockage interne</string>
|
||||
<string name="games_added">Jeux ajoutés</string>
|
||||
<string name="gpu_auto_hint">Sélectionne la meilleure combinaison de pilotes pour ce GPU et les maintient à jour</string>
|
||||
<string name="gpu_manual_hint">Choisir manuellement la combinaison de pilotes</string>
|
||||
<string name="gpu_checking">Vérification des pilotes…</string>
|
||||
<string name="gpu_none_listed">Aucun pilote répertorié pour le moment. Actualisez pour rechercher des pilotes.</string>
|
||||
<string name="gpu_hide_other">Masquer les pilotes des autres GPU</string>
|
||||
<string name="gpu_show_other">Afficher les pilotes des autres GPU</string>
|
||||
<string name="gpu_advanced">Avancé</string>
|
||||
<string name="gpu_set_with_display">Configuré avec le pilote d\'affichage : %1$s inclut les deux.</string>
|
||||
<string name="gpu_set_with_runtime">Configuré avec le pilote d\'exécution : %1$s inclut les deux.</string>
|
||||
<string name="gpu_runtime_hint">Utilisé par les jeux et le bureau.</string>
|
||||
<string name="gpu_runtime_default">Par défaut de l\'environnement d\'exécution</string>
|
||||
<string name="gpu_display_hint">Responsable de la sortie vidéo vers l\'écran. Prendra effet après le redémarrage de DroidDeck.</string>
|
||||
<string name="gpu_auto_by_gpu">Auto - Sélection par GPU</string>
|
||||
<string name="gpu_manage">Télécharger, importer ou supprimer…</string>
|
||||
<string name="gpu_in_use_caps">Utilisé</string>
|
||||
<string name="gpu_builtin">Pilote intégré à DroidDeck</string>
|
||||
<string name="gpu_matched_pair">Le pilote d\'exécution et le pilote d\'affichage sont configurés ensemble en tant que paire correspondante.</string>
|
||||
<string name="gpu_recommended_for">Recommandé pour ce GPU : %1$s.</string>
|
||||
<string name="gpu_refresh_hint">Actualisez pour rechercher les pilotes adaptés à ce GPU.</string>
|
||||
<string name="gpu_recommended_caps">Recommandé</string>
|
||||
<string name="gpu_unavailable">Non disponible</string>
|
||||
<string name="load_runtime">Environnement d\'exécution Linux</string>
|
||||
<string name="load_runtime_detail">Préparation de l\'environnement d\'exécution Linux</string>
|
||||
<string name="load_session">Session</string>
|
||||
<string name="load_session_detail">Démarrage de l\'affichage et de l\'audio</string>
|
||||
<string name="load_steam">Client Steam</string>
|
||||
<string name="load_steam_detail">Vérification de Steam et de ses outils de compatibilité</string>
|
||||
<string name="load_opening_steam">Ouverture de Steam</string>
|
||||
<string name="load_opening_steam_detail">En attente de la première image de Steam</string>
|
||||
<string name="load_desktop">Bureau</string>
|
||||
<string name="load_desktop_detail">Préparation du bureau</string>
|
||||
<string name="load_opening">Ouverture</string>
|
||||
<string name="load_opening_detail">En attente de la première image</string>
|
||||
<string name="load_starting_steam">Démarrage de Steam</string>
|
||||
<string name="load_ended">Session terminée</string>
|
||||
<string name="load_step">Étape %1$d sur %2$d</string>
|
||||
<string name="load_steam_paused">Steam en pause</string>
|
||||
<string name="deck_tab_grips">Poignées</string>
|
||||
<string name="deck_tab_left">Pavé tactile gauche</string>
|
||||
<string name="deck_tab_right">Pavé tactile droit</string>
|
||||
<string name="deck_tab_both">Les deux</string>
|
||||
<string name="deck_close">Fermer les commandes du deuxième écran</string>
|
||||
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
|
||||
<string name="deck_left_upper">Gauche · Haut</string>
|
||||
<string name="deck_left_lower">Gauche · Bas</string>
|
||||
<string name="deck_right_upper">Droite · Haut</string>
|
||||
<string name="deck_right_lower">Droite · Bas</string>
|
||||
<string name="deck_click">Cliquer</string>
|
||||
<string name="deck_right_trackpad">Pavé tactile droit</string>
|
||||
<string name="deck_left_trackpad">Pavé tactile gauche</string>
|
||||
<string name="second_trackpad">Pavé tactile</string>
|
||||
<string name="second_show_keyboard">Afficher le clavier</string>
|
||||
<string name="second_hide_keyboard">Masquer le clavier</string>
|
||||
<string name="second_terminal">Terminal Linux</string>
|
||||
<string name="second_terminal_not_ready">La session Linux n\'est pas prête, le terminal ne peut pas être utilisé.</string>
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Jeux</string>
|
||||
<string name="rail_desktop">Bureau</string>
|
||||
<string name="rail_store">Boutique</string>
|
||||
<string name="rail_components">Composants</string>
|
||||
<string name="rail_apps">Applications</string>
|
||||
<string name="rail_setup">Configuration</string>
|
||||
<string name="rail_updates">Mises à jour</string>
|
||||
<string name="rail_percent">%1$d%%</string>
|
||||
<string name="rail_resume_named">Reprendre %1$s</string>
|
||||
<string name="roms_text">Affiché comme /root/ROMs dans chaque session. Le stockage interne se trouve dans /root/Storage.</string>
|
||||
<string name="roms_none">Aucun dossier sélectionné</string>
|
||||
<string name="roms_applies">Prendra effet à la prochaine session. Prend en charge les cartes SD.</string>
|
||||
<string name="roms_choose">Choisir un dossier</string>
|
||||
<string name="roms_change">Modifier le dossier</string>
|
||||
<string name="proton_lede">Installez une version ARM64 de Proton, puis sélectionnez-la pour chaque jeu dans Steam → Propriétés → Compatibilité.</string>
|
||||
<string name="proton_stop_first">Veuillez arrêter la session en cours avant d\'installer ou de supprimer des outils de compatibilité.</string>
|
||||
<string name="proton_available">Versions disponibles</string>
|
||||
<string name="proton_installed">%1$s installé</string>
|
||||
<string name="proton_queued">Élément en attente d\'une demande précédente</string>
|
||||
<string name="proton_not_installed">Non installé</string>
|
||||
<string name="proton_install_now">Installer maintenant</string>
|
||||
<string name="proton_cancel_queued">Annuler la file d\'attente</string>
|
||||
<string name="pckb_android">Clavier Android</string>
|
||||
<string name="pckb_hide">Masquer ✕</string>
|
||||
<string name="main_launch_named">Lancer %1$s</string>
|
||||
<string name="main_choose_screen">Choisir l\'écran. Écran secondaire : %1$s.</string>
|
||||
<string name="main_secondary_gone">L\'écran secondaire n\'est plus disponible.</string>
|
||||
<string name="main_primary">Écran principal</string>
|
||||
<string name="main_secondary">Écran secondaire</string>
|
||||
<string name="widgets_prev_tab">Onglet précédent</string>
|
||||
<string name="widgets_next_tab">Onglet suivant</string>
|
||||
<string name="widgets_on">Activé</string>
|
||||
<string name="widgets_all_n">Tout %1$d</string>
|
||||
<string name="widgets_none">Aucun</string>
|
||||
<string name="widgets_n_of_m">%1$d/%2$d</string>
|
||||
<string name="widgets_off">Désactivé</string>
|
||||
<string name="screen_bottom">Écran inférieur</string>
|
||||
<string name="game_env_applies">Prendra effet au prochain lancement du jeu. Le profil du jeu remplacera la configuration partagée.</string>
|
||||
<string name="game_env_other">Sélectionner un autre jeu par ID d\'application…</string>
|
||||
<string name="game_env_done">Terminé</string>
|
||||
<string name="game_env_default">Par défaut</string>
|
||||
<string name="game_env_off">Désactivé</string>
|
||||
<string name="game_env_change">Modifier…</string>
|
||||
<string name="game_env_change_title">Modifier la variable</string>
|
||||
<string name="game_env_restore_default">Restaurer les valeurs par défaut</string>
|
||||
<string name="game_env_eyebrow">Jeux Proton</string>
|
||||
<string name="pip_title">Picture-in-Picture (PiP)</string>
|
||||
<string name="pip_auto">Passer automatiquement en PiP</string>
|
||||
<string name="pip_suspended">En pause</string>
|
||||
<string name="pip_not_ready">Le mode PiP sera disponible une fois la session prête.</string>
|
||||
<string name="pip_permission">Veuillez autoriser DroidDeck à utiliser le mode PiP dans les paramètres Android.</string>
|
||||
<string name="pip_unavailable">Android ne peut pas ouvrir le mode PiP.</string>
|
||||
<string name="game_shortcut">Raccourci</string>
|
||||
<string name="game_shortcut_add">Ajouter à l\'écran d\'accueil</string>
|
||||
<string name="game_shortcut_choose">Choisir un jeu</string>
|
||||
<string name="game_shortcut_scanning">Analyse des jeux installés…</string>
|
||||
<string name="game_link_copy">Copier le lien de lancement</string>
|
||||
<string name="game_link_copied">Lien de lancement copié</string>
|
||||
<string name="game_shortcut_unsupported">Ce lanceur ne peut pas ajouter de raccourci. Utilisez « Copier le lien de lancement » à la place.</string>
|
||||
<string name="game_link_invalid">Lien de lancement de jeu invalide.</string>
|
||||
<string name="game_link_missing">Ce jeu n\'est plus dans votre bibliothèque DroidDeck.</string>
|
||||
<string name="game_link_session_busy">Veuillez arrêter la session en cours avant de lancer un jeu Steam.</string>
|
||||
<string name="game_link_failed">Impossible d\'envoyer le jeu à Steam. Veuillez réessayer de le lancer.</string>
|
||||
<string name="setup_retry_removal">Réessayer la suppression</string>
|
||||
<string name="runtime_removing">Suppression…</string>
|
||||
<string name="runtime_removal_incomplete">Suppression incomplète</string>
|
||||
<string name="game_file_export">Exporter les fichiers du jeu</string>
|
||||
<string name="game_file_sync">Synchroniser tous les fichiers du jeu dans un dossier</string>
|
||||
<string name="game_file_stop_sync">Arrêter la synchronisation des fichiers du jeu</string>
|
||||
<string name="game_file_folder">Choisir un dossier pour les fichiers du jeu frontal</string>
|
||||
<string name="game_file_exported">Fichiers du jeu enregistrés dans %1$s</string>
|
||||
<string name="game_frontend_files">Fichiers frontaux</string>
|
||||
</resources>
|
||||
@@ -279,8 +279,6 @@
|
||||
<string name="mode_added_launches_in">%1$sを起動 · %2$s内</string>
|
||||
<string name="mode_added_choose">別のファイルを選択…</string>
|
||||
<string name="mode_added_exe_note">デフォルトではフォルダと同じ名前の.exeを使用し、ない場合は最大の.exeを使用します。</string>
|
||||
<string name="mode_fill">ゲームを引き伸ばして画面を埋める</string>
|
||||
<string name="mode_fill_hint">自身でサイズ変更できるゲームを全画面に保ちます。ゲームが小さく表示される場合はこれをオフにしてください。次回のセッションで有効になります。</string>
|
||||
<string name="mode_stretch169">16:9ゲームを引き伸ばす</string>
|
||||
<string name="mode_stretch169_hint">アスペクト比が16:9未満の画面で、黒帯を表示する代わりにゲームを引き伸ばして画面を埋めます。次回のセッションで有効になります。</string>
|
||||
<string name="mode_audio">オーディオ</string>
|
||||
@@ -442,7 +440,6 @@
|
||||
<string name="drawer_prev_page">前のページ</string>
|
||||
<string name="drawer_next_page">次のページ</string>
|
||||
<string name="drawer_hud">パフォーマンスHUD</string>
|
||||
<string name="drawer_fill">ゲームを引き伸ばして画面を埋める</string>
|
||||
<string name="drawer_touch_auto">自動(%1$s)</string>
|
||||
<string name="drawer_keyboard">キーボード</string>
|
||||
<string name="drawer_pc_keyboard">PCキーボード</string>
|
||||
|
||||
@@ -279,8 +279,6 @@
|
||||
<string name="mode_added_launches_in">%1$s 실행 · %2$s에 위치</string>
|
||||
<string name="mode_added_choose">다른 파일 선택…</string>
|
||||
<string name="mode_added_exe_note">기본적으로 폴더와 이름이 같은 .exe를 사용하고, 없으면 가장 큰 .exe를 사용합니다.</string>
|
||||
<string name="mode_fill">게임을 늘려 화면 채우기</string>
|
||||
<string name="mode_fill_hint">자체적으로 크기를 조절하는 게임을 전체 화면으로 유지합니다. 게임이 작게 표시되면 이 옵션을 끄세요. 다음 세션에 적용됩니다.</string>
|
||||
<string name="mode_stretch169">16:9 게임 늘리기</string>
|
||||
<string name="mode_stretch169_hint">가로세로 비율이 16:9 미만인 화면에서 검은색 여백 대신 게임을 늘려 화면을 채웁니다. 다음 세션에 적용됩니다.</string>
|
||||
<string name="mode_audio">오디오</string>
|
||||
@@ -442,7 +440,6 @@
|
||||
<string name="drawer_prev_page">이전 페이지</string>
|
||||
<string name="drawer_next_page">다음 페이지</string>
|
||||
<string name="drawer_hud">성능 HUD</string>
|
||||
<string name="drawer_fill">게임을 늘려 화면 채우기</string>
|
||||
<string name="drawer_touch_auto">자동(%1$s)</string>
|
||||
<string name="drawer_keyboard">키보드</string>
|
||||
<string name="drawer_pc_keyboard">PC 키보드</string>
|
||||
|
||||
@@ -0,0 +1,957 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">DroidDeck</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>При первом запуске будет загружен Steam. Это может занять несколько минут.</item>
|
||||
<item>По умолчанию при выходе из приложения Steam приостанавливается; при возвращении работа возобновляется с того же места.</item>
|
||||
<item>Ваш контроллер будет отображаться как геймпад Xbox 360. В противном случае используйте экранные элементы управления.</item>
|
||||
</string-array>
|
||||
<string-array name="loading_hints_desktop">
|
||||
<item>При первом запуске будет настроен рабочий стол. Это может занять несколько минут.</item>
|
||||
<item>По умолчанию при выходе из приложения сессия приостанавливается; при возвращении работа возобновляется с того же места.</item>
|
||||
<item>Сенсорный ввод работает как тачпад: перетаскивайте для перемещения курсора, нажимайте для клика.</item>
|
||||
<item>Ваш контроллер будет отображаться как геймпад Xbox 360. В противном случае используйте экранные элементы управления.</item>
|
||||
</string-array>
|
||||
<string name="session_channel">Сессия</string>
|
||||
<string name="session_channel_description">Отображается при открытии сессии Steam или рабочего стола</string>
|
||||
<string name="session_notification">Steam запущен</string>
|
||||
<string name="session_notification_paused">Сессия приостановлена - нажмите, чтобы продолжить</string>
|
||||
<string name="resume_session">Продолжить</string>
|
||||
<string name="stop_session">Завершить сессию</string>
|
||||
<string name="frame_gen_title">Генерация кадров</string>
|
||||
<string name="frame_gen_hint">Выберите режим генерации кадров</string>
|
||||
<string name="frame_gen_off">Выкл.</string>
|
||||
<string name="frame_gen_winfg">Win-FG %1$d×</string>
|
||||
<string name="frame_gen_lsfg">LSFG %1$d×</string>
|
||||
<string name="frame_gen_lsfg_adaptive">LSFG Адаптивный %1$d</string>
|
||||
<string name="lsfg_needs_steam">Требуется Lossless Scaling из Steam</string>
|
||||
<string name="lsfg_needs_install">Установите Lossless Scaling в Steam или импортируйте Lossless.dll</string>
|
||||
<string name="lsfg_import">Импорт Lossless.dll…</string>
|
||||
<string name="lsfg_using_steam">Используется версия из Steam</string>
|
||||
<string name="lsfg_using_import">Используется импортированная версия</string>
|
||||
<string name="lsfg_pick_title">Выберите файл Lossless.dll</string>
|
||||
<string name="lsfg_imported">Lossless.dll импортирован</string>
|
||||
<string name="lsfg_import_unchanged">Этот Lossless.dll уже используется</string>
|
||||
<string name="lsfg_import_not_owned">В этой учетной записи Steam нет Lossless Scaling</string>
|
||||
<string name="lsfg_import_invalid">Этот файл не является Lossless.dll от Lossless Scaling</string>
|
||||
<string name="lsfg_import_failed">Не удалось подготовить LSFG из этого Lossless.dll</string>
|
||||
<string name="lsfg_problem_missing">Для LSFG требуется Lossless Scaling из Steam или импортированный файл Lossless.dll</string>
|
||||
<string name="lsfg_problem_failed">LSFG не может подготовить свои шейдеры из этого Lossless.dll</string>
|
||||
<string name="hud_fg_unsupported">%1$s: не поддерживается</string>
|
||||
<string name="hud_fg_failed">%1$s: сбой движка</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: требуется Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · Запуск</string>
|
||||
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam.</string>
|
||||
<string name="second_library_import_hint">Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam.</string>
|
||||
<string name="added_games_forget_hint">«Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске.</string>
|
||||
<string name="game_env_title">Игровая среда</string>
|
||||
<string name="game_env_hint">Переменные среды для игр Proton.</string>
|
||||
<string name="game_env_edit">Изменить</string>
|
||||
<string name="game_env_shared">Все игры Proton</string>
|
||||
<string name="game_env_scope">Профиль</string>
|
||||
<string name="game_env_app_id">ID приложения Steam</string>
|
||||
<string name="game_env_open">Открыть</string>
|
||||
<string name="game_env_profile">Игра %1$s</string>
|
||||
<string name="game_env_add">Добавить переменную</string>
|
||||
<string name="game_env_name">Имя переменной</string>
|
||||
<string name="game_env_value">Значение</string>
|
||||
<string name="game_env_save">Сохранить</string>
|
||||
<string name="game_env_remove">Удалить</string>
|
||||
<string name="game_env_restore">Восстановить наследование</string>
|
||||
<string name="game_env_reset">Сбросить профиль</string>
|
||||
<string name="game_env_unset">Снять настройку для профиля</string>
|
||||
<string name="game_env_empty">Переменные не заданы.</string>
|
||||
<string name="game_env_inherited">Унаследовано</string>
|
||||
<string name="game_env_custom">Пользовательская переменная</string>
|
||||
<string name="game_env_error">Не удалось загрузить или сохранить. Проверьте свободное место и повторите попытку.</string>
|
||||
<string name="game_env_invalid">Введите допустимое имя и значение, до 8192 символов.</string>
|
||||
<string name="game_env_unused">Стандартная среда выполнения Proton не считывает эту переменную.</string>
|
||||
<string name="game_env_loading">Загрузка…</string>
|
||||
<string name="game_env_feature">Переопределение уровня функций D3D12. 12_2 сообщает о возможностях DirectX 12 Ultimate; неподдерживаемые функции могут вызвать сбой. Оставьте пустым для автоопределения.</string>
|
||||
<string name="game_env_shader">Переопределение модели шейдеров D3D12. 6_9 требует достаточно нового VKD3D и соответствующих функций GPU. Оставьте пустым для автоопределения.</string>
|
||||
<string name="game_env_cache">Поддерживать кэш шейдеров Mesa включенным для уменьшения повторной компиляции. Установите false, чтобы включить кэш.</string>
|
||||
<string name="game_env_cache_size">Максимальный размер кэша шейдеров Mesa, например 1G. Больший кэш занимает больше места.</string>
|
||||
<string name="game_env_vkd3d_config">Параметры VKD3D для D3D12. nodxr отключает трассировку лучей; используйте только при необходимости в игре.</string>
|
||||
<string name="game_env_proton_log">Установите 1 для создания диагностического журнала Proton. Ведение журнала может снизить производительность и занять место.</string>
|
||||
<string name="game_env_wined3d">Установите 1, чтобы использовать WineD3D на базе OpenGL вместо DXVK для тестирования совместимости с D3D9–11.</string>
|
||||
<string name="game_env_xalia">Установите 0, чтобы отключить помощник навигации геймпада Proton для этой игры.</string>
|
||||
<string name="game_env_hud">Оверлей DXVK для D3D9–11. fps показывает частоту кадров; снимите настройку, чтобы скрыть оверлей.</string>
|
||||
<string name="game_env_dxvk_config">Настройки DXVK, разделенные точкой с запятой. В последних версиях используется dxvk.maxFrameRate для ограничения частоты кадров; для повседневной игры рекомендуется использовать ограничитель частоты кадров сессии.</string>
|
||||
<string name="game_env_vkd3d_limit">Ограничение частоты кадров D3D12. 0 отключает ограничение.</string>
|
||||
<string name="game_env_glthread">Многопоточность Mesa OpenGL. Для игр OpenGL с ограничением CPU значение true может помочь; тестируйте отдельно для каждой игры.</string>
|
||||
<string name="fex_preset_title">Пресеты FEX</string>
|
||||
<string name="fex_next_launch">Вступит в силу при следующем запуске игры.</string>
|
||||
<string name="game_settings_title">Игра</string>
|
||||
<string name="fex_default_label">FEX по умолчанию</string>
|
||||
<string name="fex_default_detail">Использовать настройки FEX по умолчанию.</string>
|
||||
<string name="fex_stability_label">Стабильность</string>
|
||||
<string name="fex_stability_detail">Самые безопасные и медленные настройки.</string>
|
||||
<string name="fex_compatibility_label">Совместимость</string>
|
||||
<string name="fex_compatibility_detail">Полный TSO + multiblock.</string>
|
||||
<string name="fex_intermediate_label">Средний</string>
|
||||
<string name="fex_intermediate_detail">Сбалансированные настройки TSO со сниженной точностью x87.</string>
|
||||
<string name="fex_performance_label">Производительность</string>
|
||||
<string name="fex_performance_detail">Отключить все TSO; включить multiblock.</string>
|
||||
<string name="fex_performance_tso_label">Производительность + TSO</string>
|
||||
<string name="fex_performance_tso_detail">Настройки производительности с включенным основным TSO.</string>
|
||||
<string name="fex_extreme_label">Экстрим</string>
|
||||
<string name="fex_extreme_detail">Отключить проверку самомодифицирующегося кода. Игры JIT и DRM могут не работать.</string>
|
||||
<string name="fex_extreme_tso_label">Экстрим + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Настройки экстрим с включенным основным TSO.</string>
|
||||
<string name="fex_extreme_gn_label">Экстрим-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Настройки производительности с включенными TSC и подсказками метаданных. Рекомендуется сначала попробовать этот режим, а затем «Экстрим».</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Настройки производительности с включенной полной проверкой кода и скрытием битов гипервизора.</string>
|
||||
<string name="game_env_custom_value">Пользовательское значение</string>
|
||||
<string name="game_env_select_value">Выберите значение</string>
|
||||
<string name="user_apps_section">Эмуляторы и приложения</string>
|
||||
<string name="user_apps_add">Добавить</string>
|
||||
<string name="user_apps_add_detail">Ваше приложение или игра</string>
|
||||
<string name="user_apps_kind_script">Скрипт</string>
|
||||
<string name="user_apps_kind_appimage">AppImage</string>
|
||||
<string name="user_apps_kind_flatpak">Flatpak</string>
|
||||
<string name="user_apps_progress">%1$s · %2$s</string>
|
||||
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
|
||||
<string name="user_apps_starting">Запуск…</string>
|
||||
<string name="user_apps_added">%1$s добавлено</string>
|
||||
<string name="user_apps_removed">%1$s удалено</string>
|
||||
<string name="user_apps_back">Рабочий стол</string>
|
||||
<string name="user_apps_gone">Приложение не существует. Выберите его заново на рабочем столе.</string>
|
||||
<string name="user_apps_open">Открыть %1$s</string>
|
||||
<string name="user_apps_remove">Удалить</string>
|
||||
<string name="user_apps_remove_confirm">Нажмите еще раз для удаления</string>
|
||||
<string name="user_apps_stop_session">Сначала завершите сессию</string>
|
||||
<string name="user_apps_script_linked">Запускает %1$s из своей папки и связывает эту папку с сессией. Удаление приложения не повлияет на папку.</string>
|
||||
<string name="user_apps_script_copied">Запускает %1$s из папки, скопированной в DroidDeck. Удаление приложения удалит копию, но не оригинал.</string>
|
||||
<string name="user_apps_runtime_required">Сначала установите среду выполнения Linux</string>
|
||||
<string name="user_apps_file_gone">Файл не существует</string>
|
||||
<string name="user_apps_no_space">Недостаточно места: для копирования папки требуется %1$s</string>
|
||||
<string name="user_apps_copying">Копирование %1$s</string>
|
||||
<string name="user_apps_failed">Ошибка добавления приложения</string>
|
||||
<string name="user_apps_github_checking">Поиск выпусков %1$s</string>
|
||||
<string name="user_apps_github_unreachable">Не удалось прочитать выпуски %1$s</string>
|
||||
<string name="user_apps_github_no_appimage">%1$s не предоставляет выпуск ARM64 AppImage</string>
|
||||
<string name="user_apps_downloading">Загрузка %1$s</string>
|
||||
<string name="user_apps_download_failed">Ошибка загрузки</string>
|
||||
<string name="user_apps_update_failed">Ошибка обновления приложения</string>
|
||||
<string name="user_apps_icon_failed">Не удалось прочитать иконку</string>
|
||||
<string name="user_apps_saved">%1$s сохранено</string>
|
||||
<string name="user_apps_edit">Изменить</string>
|
||||
<string name="user_apps_check_updates">Проверить обновления</string>
|
||||
<string name="user_apps_checking_updates">Проверка обновлений…</string>
|
||||
<string name="user_apps_update_to">Обновить до %1$s</string>
|
||||
<string name="user_apps_up_to_date">Актуально · %1$s</string>
|
||||
<string name="user_apps_updated">%1$s обновлено</string>
|
||||
<string name="user_apps_github_version">%1$s · %2$s</string>
|
||||
<string name="add_app_title">Добавить приложение</string>
|
||||
<string name="add_app_tab_script">Скрипт</string>
|
||||
<string name="add_app_tab_appimage">AppImage</string>
|
||||
<string name="add_app_tab_github">GitHub</string>
|
||||
<string name="add_app_tab_flatpak">Flatpak</string>
|
||||
<string name="add_app_script_hint">Файл .sh. Запускается из своей папки и отображается в полноэкранном режиме через gamescope.</string>
|
||||
<string name="add_app_appimage_hint">ARM64 (aarch64) AppImage. Распаковывается в среду выполнения Linux при первом использовании.</string>
|
||||
<string name="add_app_github_hint">Установить ARM64 AppImage из последнего выпуска репозитория.</string>
|
||||
<string name="add_app_flatpak_hint">ID приложения Flathub или его страница Flathub. Если Flatpak не настроен, он будет настроен сначала.</string>
|
||||
<string name="add_app_file">Файл</string>
|
||||
<string name="add_app_no_file">Файл не выбран</string>
|
||||
<string name="add_app_choose_file">Выбрать файл</string>
|
||||
<string name="add_app_change_file">Изменить</string>
|
||||
<string name="add_app_pick_script">Выбрать скрипт</string>
|
||||
<string name="add_app_pick_appimage">Выбрать AppImage</string>
|
||||
<string name="add_app_pick_icon">Выбрать иконку</string>
|
||||
<string name="add_app_repository">Репозиторий</string>
|
||||
<string name="add_app_repository_placeholder">owner/repo или ссылка github.com</string>
|
||||
<string name="add_app_repository_invalid">Не является репозиторием GitHub</string>
|
||||
<string name="add_app_flatpak_id">ID приложения</string>
|
||||
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
|
||||
<string name="add_app_flatpak_invalid">Недействительный ID приложения Flathub</string>
|
||||
<string name="add_app_source">Источник</string>
|
||||
<string name="add_app_appearance">Показывать на рабочем столе</string>
|
||||
<string name="add_app_preview_name">Новое приложение</string>
|
||||
<string name="add_app_name">Название</string>
|
||||
<string name="add_app_name_placeholder">Из приложения</string>
|
||||
<string name="add_app_icon">Иконка</string>
|
||||
<string name="add_app_icon_default">Иконка по умолчанию</string>
|
||||
<string name="add_app_choose_icon">Выбрать иконку</string>
|
||||
<string name="add_app_clear_icon">Очистить</string>
|
||||
<string name="add_app_icon_suggestions">Из репозитория</string>
|
||||
<string name="add_app_icon_searching">Поиск иконок…</string>
|
||||
<string name="add_app_icon_reset">По умолчанию</string>
|
||||
<string name="edit_app_title">Изменить %1$s</string>
|
||||
<string name="edit_app_save">Сохранить</string>
|
||||
<string name="add_app_checking_folder">Проверка папки…</string>
|
||||
<string name="add_app_folder_linked">Папка связана с сессией: %1$s</string>
|
||||
<string name="add_app_folder_copied">Эта папка содержит программы Linux, которые не могут работать из общего хранилища, поэтому она будет скопирована в DroidDeck (%1$s).</string>
|
||||
<string name="add_app_confirm">Добавить</string>
|
||||
<string name="add_app_cancel">Отмена</string>
|
||||
<string name="add_app_runtime_required">Сначала установите среду выполнения Linux в «Настройках».</string>
|
||||
<string name="add_app_busy">Подождите, пока завершится %1$s.</string>
|
||||
<string name="common_cancel">Отмена</string>
|
||||
<string name="common_auto">Авто</string>
|
||||
<string name="common_never">Никогда</string>
|
||||
<string name="common_always">Всегда</string>
|
||||
<string name="common_uninstall">Удалить</string>
|
||||
<string name="common_check">Проверить</string>
|
||||
<string name="common_update">Обновить</string>
|
||||
<string name="common_checking">Проверка…</string>
|
||||
<string name="common_working">Обработка…</string>
|
||||
<string name="common_forget">Забыть</string>
|
||||
<string name="common_add_ellipsis">Добавить…</string>
|
||||
<string name="common_use">Использовать</string>
|
||||
<string name="common_applies_next_session">Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_steam_title">Сессия Steam</string>
|
||||
<string name="mode_desktop_title">Сессия рабочего стола</string>
|
||||
<string name="mode_display">Экран</string>
|
||||
<string name="mode_resolution">Разрешение</string>
|
||||
<string name="mode_res_720">До 720p</string>
|
||||
<string name="mode_res_900">До 900p</string>
|
||||
<string name="mode_res_1080">До 1080p</string>
|
||||
<string name="mode_res_native">Нативное</string>
|
||||
<string name="mode_res_default">%1$s (по умолчанию)</string>
|
||||
<string name="mode_res_custom_value">Пользовательское · %1$d×%2$d</string>
|
||||
<string name="mode_res_custom">Пользовательское…</string>
|
||||
<string name="mode_res_note">720p может ускорить отклик меню.</string>
|
||||
<string name="mode_ratio">Соотношение сторон</string>
|
||||
<string name="mode_ratio_custom">Задается пользовательским разрешением.</string>
|
||||
<string name="mode_ratio_auto">Автоматический режим использует не менее 16:9.</string>
|
||||
<string name="mode_fps">Ограничение частоты кадров</string>
|
||||
<string name="mode_fps_note">Ограничивает частоту кадров всей сессии. Монитор переключится на частоту обновления, кратную этому пределу.</string>
|
||||
<string name="mode_hdr">HDR</string>
|
||||
<string name="mode_hdr10">Вывод HDR10</string>
|
||||
<string name="mode_hdr_unavailable">Недоступно: %1$s.</string>
|
||||
<string name="mode_hdr_restart">Вступит в силу после перезапуска приложения.</string>
|
||||
<string name="mode_drivers">Драйверы</string>
|
||||
<string name="mode_gpu_drivers">Драйверы GPU</string>
|
||||
<string name="mode_gpu_drivers_hint">Общие для Steam и рабочего стола. Настройте на странице «Компоненты».</string>
|
||||
<string name="mode_touch_controls">Сенсорный ввод и управление</string>
|
||||
<string name="mode_touch">Сенсорный</string>
|
||||
<string name="mode_touch_touchpad">Тачпад</string>
|
||||
<string name="mode_touch_direct">Прямой</string>
|
||||
<string name="mode_touch_note">Авто: тачпад для рабочего стола, прямой сенсорный ввод для Steam.</string>
|
||||
<string name="mode_osc">Экранные элементы управления</string>
|
||||
<string name="mode_osc_qam">Steam + QAM</string>
|
||||
<string name="mode_osc_note">Автоматический режим показывает экранные элементы управления, если контроллер не подключен.</string>
|
||||
<string name="mode_controller">Контроллер</string>
|
||||
<string name="mode_controller_hint">Как Steam распознает ваш контроллер. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_controller_deck">Контроллер Steam Deck</string>
|
||||
<string name="mode_controller_x360">Контроллер Xbox 360</string>
|
||||
<string name="mode_controller_note">Deck: кнопка быстрого доступа и гироскоп. Xbox 360: используйте Guide+A для быстрого доступа.</string>
|
||||
<string name="mode_back">Назад</string>
|
||||
<string name="mode_session">Сессия</string>
|
||||
<string name="mode_suspend">Поведение в фоне</string>
|
||||
<string name="mode_suspend_hint">Поведение при скрытии приложения или выключении экрана.</string>
|
||||
<string name="mode_suspend_manual">Вручную</string>
|
||||
<string name="mode_suspend_note">Автоматический режим приостанавливает работу в фоне и возобновляет при отображении. Ручной режим требует нажатия «Продолжить». Никогда не остается запущенным.</string>
|
||||
<string name="mode_startup">Запуск</string>
|
||||
<string name="mode_steam_startup">Запускать Steam при открытии DroidDeck</string>
|
||||
<string name="mode_steam_startup_hint">Открывает сессию Steam при запуске DroidDeck.</string>
|
||||
<string name="mode_decky">Decky</string>
|
||||
<string name="mode_decky_checking">Проверка выпусков…</string>
|
||||
<string name="mode_decky_ready">Готово к установке %1$s.</string>
|
||||
<string name="mode_decky_none">Совместимая версия не найдена. Повторите попытку позже.</string>
|
||||
<string name="mode_decky_installed">Установлено · %1$s</string>
|
||||
<string name="mode_decky_update">Доступно обновление · %1$s</string>
|
||||
<string name="mode_decky_current">Актуально · %1$s</string>
|
||||
<string name="mode_decky_loader">Загрузчик</string>
|
||||
<string name="mode_decky_install_latest">Установить последнюю версию</string>
|
||||
<string name="mode_decky_on">Запускается вместе со Steam. После включения другие приложения на этом устройстве могут управлять Steam.</string>
|
||||
<string name="mode_decky_off">Выкл. Порт удаленной отладки Steam остается закрытым.</string>
|
||||
<string name="mode_decky_remove_title">Удалить Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">Удаляет загрузчик. Ваши плагины и настройки сохранятся.</string>
|
||||
<string name="mode_client">Клиент</string>
|
||||
<string name="mode_deck_mode">Режим Steam Deck</string>
|
||||
<string name="mode_deck_mode_hint">Интерфейс Steam Deck и управление производительностью через быстрый доступ. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_mangoapp">Оверлей производительности</string>
|
||||
<string name="mode_mangoapp_hint">Отключите, если игры падают или появляется черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_branch">Ветка клиента</string>
|
||||
<string name="mode_branch_deck">Бета-версия Steam Deck (требуется для режима Deck).</string>
|
||||
<string name="mode_branch_hint">Вступит в силу в следующей сессии. Steam может обновиться самостоятельно один раз.</string>
|
||||
<string name="mode_branch_public">Бета</string>
|
||||
<string name="mode_branch_deck_beta">Бета-версия Steam Deck</string>
|
||||
<string name="mode_branch_note">Режим Deck всегда использует бета-версию Steam Deck.</string>
|
||||
<string name="mode_added_games">Добавленные игры</string>
|
||||
<string name="mode_added_none">Папки с играми .exe не найдены</string>
|
||||
<string name="mode_games_folder">Папка с играми</string>
|
||||
<string name="mode_games_folder_another">Другая папка с играми</string>
|
||||
<string name="mode_added_art">Изображения из Steam</string>
|
||||
<string name="mode_added_art_hint">Дополняет отсутствующие изображения по имени папки. Файлы cover.jpg, hero.jpg, logo.png или icon.png в папке с игрой имеют приоритет.</string>
|
||||
<string name="mode_added_launches">Запустить %1$s</string>
|
||||
<string name="mode_added_launches_in">Запустить %1$s · в %2$s</string>
|
||||
<string name="mode_added_choose">Выбрать другой файл…</string>
|
||||
<string name="mode_added_exe_note">По умолчанию используется .exe с именем папки, иначе самый большой .exe.</string>
|
||||
<string name="mode_stretch169">Растянуть игры 16:9</string>
|
||||
<string name="mode_stretch169_hint">На экранах с соотношением сторон менее 16:9 растягивает игру на весь экран вместо черных полос. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_audio">Аудио</string>
|
||||
<string name="mode_directaudio">Использовать DirectAudio в игре</string>
|
||||
<string name="mode_directaudio_hint">Обходит PulseAudio для снижения задержки.</string>
|
||||
<string name="mode_client_audio">Аудио клиента Steam</string>
|
||||
<string name="mode_client_audio_hint">Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_client_audio_classic">Классический</string>
|
||||
<string name="mode_client_audio_direct">DirectAudio</string>
|
||||
<string name="mode_mic">Микрофон</string>
|
||||
<string name="mode_mic_hint">Используется для голосового чата.</string>
|
||||
<string name="mode_network">Сеть</string>
|
||||
<string name="mode_wifi_discovery">Обнаружение Wi-Fi</string>
|
||||
<string name="mode_wifi_discovery_hint">Показывает названия Wi-Fi и ближайшие сети в Steam.</string>
|
||||
<string name="mode_wifi_location_off">Службы геолокации отключены.</string>
|
||||
<string name="mode_wifi_permission_denied">Разрешение на определение местоположения не предоставлено.</string>
|
||||
<string name="mode_wifi_open_settings">Открыть настройки</string>
|
||||
<string name="mode_wifi_continue">Продолжить</string>
|
||||
<string name="mode_wifi_explain_title">Включить обнаружение Wi-Fi?</string>
|
||||
<string name="mode_wifi_explain_text">Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения.</string>
|
||||
<string name="mode_storage">Хранилище игр</string>
|
||||
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии</string>
|
||||
<string name="mode_storage_auto_none">Авто - Использовать SD-карту при наличии (сейчас нет SD-карты)</string>
|
||||
<string name="mode_storage_internal">Только внутреннее хранилище</string>
|
||||
<string name="mode_storage_folder">Папка: %1$s</string>
|
||||
<string name="mode_second_library">Вторая библиотека</string>
|
||||
<string name="mode_storage_note">Игры, читающие ресурсы с SD-карты или из общего хранилища, могут подтормаживать.</string>
|
||||
<string name="mode_choose_folder">Выбрать папку…</string>
|
||||
<string name="mode_renderer">Рендерер</string>
|
||||
<string name="mode_desktop_renderer">Рендерер рабочего стола</string>
|
||||
<string name="mode_renderer_hint">Отвечает за отрисовку рабочего стола.</string>
|
||||
<string name="mode_renderer_vulkan">vulkan - GPU</string>
|
||||
<string name="mode_renderer_gles2">gles2 - GPU (экспериментально)</string>
|
||||
<string name="mode_renderer_pixman">pixman - Программный</string>
|
||||
<string name="mode_renderer_note">Vulkan использует GPU для запуска программ рабочего стола, при сбое переключается на pixman. При использовании pixman игры по-прежнему запускаются в полноэкранном режиме на GPU.</string>
|
||||
<string name="mode_custom_res_title">Пользовательское разрешение</string>
|
||||
<string name="mode_custom_res_text">Заменяет предел разрешения и соотношение сторон. Размеры, не соответствующие экрану, будут отображаться с черными полосами.</string>
|
||||
<string name="mode_width">Ширина</string>
|
||||
<string name="mode_height">Высота</string>
|
||||
<string name="mode_custom_res_range">Диапазон: от 320×240 до 3840×2160.</string>
|
||||
<plurals name="mode_added_count">
|
||||
<item quantity="one">%1$d игра</item>
|
||||
<item quantity="few">%1$d игры</item>
|
||||
<item quantity="many">%1$d игр</item>
|
||||
<item quantity="other">%1$d игр</item>
|
||||
</plurals>
|
||||
<string name="common_ok">ОК</string>
|
||||
<string name="common_delete">Удалить</string>
|
||||
<string name="common_restore">Восстановить</string>
|
||||
<string name="common_download">Скачать</string>
|
||||
<string name="common_delete_named">Удалить %1$s</string>
|
||||
<string name="common_size_mb">%1$.1f МБ</string>
|
||||
<string name="comp_runtime_driver">Драйвер среды выполнения</string>
|
||||
<string name="comp_runtime_driver_hint">Используется Steam, играми и рабочим столом. Вступит в силу в следующей сессии.</string>
|
||||
<string name="comp_import_turnip">Импорт Turnip zip…</string>
|
||||
<string name="comp_display_driver">Драйвер дисплея</string>
|
||||
<string name="comp_display_driver_hint">Отвечает за вывод изображения на экран в обоих режимах. Вступит в силу после перезапуска приложения.</string>
|
||||
<string name="comp_import_adrenotools">Импорт AdrenoTools zip…</string>
|
||||
<string name="comp_never">Никогда</string>
|
||||
<string name="comp_nightlies_checked">Nightlies проверено %1$s</string>
|
||||
<string name="comp_title">Компоненты</string>
|
||||
<string name="comp_about">О компонентах</string>
|
||||
<string name="comp_check_drivers">Проверить новые драйверы</string>
|
||||
<string name="comp_check_nightlies">Проверить новые пакеты в Nightlies</string>
|
||||
<string name="comp_proton">Proton</string>
|
||||
<string name="comp_proton_note">При обновлении Steam заменяет Proton от Valve; оригинальные файлы каждой сборки сохраняются.</string>
|
||||
<string name="comp_swapped">%1$s · заменено %2$d</string>
|
||||
<string name="comp_all_original">%1$s · все оригинальные файлы</string>
|
||||
<string name="comp_game_running_suffix">%1$s · игра запущена</string>
|
||||
<string name="comp_gpu_drivers">Драйверы GPU</string>
|
||||
<string name="comp_reapplied">%1$s · повторно применено при запуске %2$s</string>
|
||||
<string name="comp_next_queued">Далее: %1$s · после закрытия игры</string>
|
||||
<string name="comp_in_use">Используется %1$s: %2$s</string>
|
||||
<string name="comp_waits">Ожидание закрытия игры</string>
|
||||
<string name="comp_applies_next_game">Вступит в силу при следующем запуске игры</string>
|
||||
<string name="comp_import_zip">Импорт .zip</string>
|
||||
<string name="comp_reading">Чтение Proton…</string>
|
||||
<string name="comp_no_proton">Proton не установлен. Запустите Steam один раз для загрузки ARM64 Proton или добавьте GE-Proton или CachyOS в «Настройках».</string>
|
||||
<string name="comp_original">Оригинал · %1$s</string>
|
||||
<string name="comp_this_build">%1$s · текущая сборка</string>
|
||||
<string name="comp_earlier_build">%1$s · более ранняя сборка</string>
|
||||
<string name="comp_installed_count">Установлено · %1$d</string>
|
||||
<string name="comp_nothing_stored">Для %1$s пока ничего не сохранено. Файлы, поставляемые с этим Proton, будут сохранены как оригинальные.</string>
|
||||
<string name="comp_waits_long">Игра использует этот Proton, поэтому нужно дождаться ее закрытия.</string>
|
||||
<string name="comp_applies_long">Вступит в силу при следующем запуске игры.</string>
|
||||
<string name="comp_swap_title">Заменить %1$s?</string>
|
||||
<string name="comp_swap_body">Поместить %1$s в %2$s? Оригинальные файлы продолжат сохраняться. %3$s</string>
|
||||
<string name="comp_restore_title">Восстановить %1$s?</string>
|
||||
<string name="comp_restore_body">Вернуть оригинальные файлы %1$s в %2$s? %3$s</string>
|
||||
<string name="comp_delete_package_body">Удалить пакет из приложения. Его можно будет скачать снова.</string>
|
||||
<string name="comp_delete_original_title">Удалить оригинальную версию?</string>
|
||||
<string name="comp_delete_original_body">Эта оригинальная версия %1$s из более ранней сборки %2$s. Оригинальная версия текущей сборки всегда сохраняется.</string>
|
||||
<string name="comp_nightlies_count">В Nightlies · %1$d</string>
|
||||
<string name="comp_refresh_to_list">Обновить, чтобы показать пакеты в Nightlies.</string>
|
||||
<string name="comp_nothing_new">С момента последней проверки нового нет.</string>
|
||||
<string name="comp_import_wcp">Импорт .wcp</string>
|
||||
<string name="comp_restore_original">Восстановить оригинальные файлы этого Proton</string>
|
||||
<string name="comp_restore_original_body">Вернуть оригинальные файлы %2$s из %1$s?</string>
|
||||
<string name="comp_swap">Переключить</string>
|
||||
<string name="comp_about_text">FEX, DXVK и VKD3D-Proton: для каждого Proton, используемого Steam для запуска игр.\n\nВыберите установленный элемент, чтобы заменить его. Это вступит в силу при следующем запуске игры или после закрытия текущей запущенной игры.\n\nВаш выбор будет перепроверяться перед каждым запуском; если он был изменен, он будет восстановлен. Оригинальные файлы каждого Proton сохраняются, поэтому обновления Steam никогда их не потеряют.</string>
|
||||
<string name="comp_tag_original">Оригинал</string>
|
||||
<string name="comp_tag_in_use">Используется</string>
|
||||
<string name="comp_tag_new">Новый</string>
|
||||
<string name="comp_tag_stored">Сохранено</string>
|
||||
<string name="perf_title">Производительность</string>
|
||||
<string name="perf_lede">Ядра CPU, исправления сессии и имя хоста. Вступит в силу в следующей сессии.</string>
|
||||
<string name="perf_client_cores_group">Ядра клиента Steam</string>
|
||||
<string name="perf_override">Переопределить выбор ядер Steam</string>
|
||||
<string name="perf_override_hint">Закрепить Steam, помощник интерфейса и gamescope за выбранными ниже ядрами.</string>
|
||||
<string name="perf_client_cores">Ядра клиента</string>
|
||||
<string name="perf_client_cores_on">Ядра, используемые Steam.</string>
|
||||
<string name="perf_client_cores_off">Включите переопределение для выбора.</string>
|
||||
<string name="perf_game_cores_group">Ядра игры</string>
|
||||
<string name="perf_game_cores">Ядра игры</string>
|
||||
<string name="perf_game_cores_hint">Ядра, используемые играми. При выборе всех ядер Android планирует свободно.</string>
|
||||
<string name="perf_client_ui">Интерфейс клиента</string>
|
||||
<string name="perf_glthread">Многопоточность GL</string>
|
||||
<string name="perf_glthread_hint">Может ускорить отклик меню Steam.</string>
|
||||
<string name="perf_zink">Zink: отложенные дескрипторы</string>
|
||||
<string name="perf_zink_hint">Рекомендуется для драйверов без буфера дескрипторов.</string>
|
||||
<string name="perf_noglerror">Пропустить проверку ошибок GL</string>
|
||||
<string name="perf_noglerror_hint">Отключить проверку GL для каждого вызова.</string>
|
||||
<string name="perf_gsrealtime">gamescope: очередь GPU в реальном времени</string>
|
||||
<string name="perf_gsrealtime_hint">Повышает приоритет работы GPU композитора. Может улучшить стабильность времени кадра, но потребляет часть производительности игры.</string>
|
||||
<string name="perf_gpu">GPU</string>
|
||||
<string name="perf_gpuclock">Поддерживать максимальную частоту GPU</string>
|
||||
<string name="perf_gpuclock_hint">Только для Adreno. Меньше подтормаживаний, но больше энергопотребления и нагрева.</string>
|
||||
<string name="perf_fixes">Исправления сессии</string>
|
||||
<string name="perf_sysmem">Turnip: рендеринг sysmem</string>
|
||||
<string name="perf_sysmem_hint">Steam и большинство игр будут быстрее. Требуется для Adreno 710/720/722.</string>
|
||||
<string name="perf_xalia">Пропустить помощник xalia в Steam</string>
|
||||
<string name="perf_xalia_hint">Отключает помощник навигации геймпада Proton, который потребляет CPU в каждой игре.</string>
|
||||
<string name="perf_seccomp">Запускать proot без seccomp</string>
|
||||
<string name="perf_seccomp_hint">Может исправить ошибки отсутствующих системных вызовов на некоторых ядрах, но может снизить производительность.</string>
|
||||
<string name="perf_fastpath">Быстрый путь proot</string>
|
||||
<string name="perf_fastpath_needs_seccomp">Требуется фильтр seccomp для proot.</string>
|
||||
<string name="perf_fastpath_hint">Быстрый поиск файлов, меньше подтормаживаний. Отключите, если программа не находит файлы.</string>
|
||||
<string name="perf_identity">Идентификация сессии</string>
|
||||
<string name="perf_hostname">Имя хоста</string>
|
||||
<string name="perf_hostname_hint">Имя, под которым Steam видит это устройство. Оставьте пустым, чтобы вернуть %1$s.</string>
|
||||
<string name="perf_hostname_invalid">Только буквы, цифры и дефисы посередине, до 63 символов.</string>
|
||||
<string name="perf_phantom_warning">Android настроен на завершение этой сессии</string>
|
||||
<string name="driver_installed">Установлен</string>
|
||||
<string name="driver_available">Доступен для скачивания</string>
|
||||
<string name="driver_nothing_new">С момента последней проверки нового нет.</string>
|
||||
<string name="driver_restore_builtin">Восстановить встроенный драйвер</string>
|
||||
<string name="driver_delete_builtin">Встроенный драйвер приложения: удалит распакованные файлы и скроет драйвер. Используйте «Восстановить встроенный драйвер» для восстановления.</string>
|
||||
<string name="driver_delete_bundle">Половина пакета Android + Linux: обе части будут удалены. Используемый драйвер вернется к драйверу по умолчанию.</string>
|
||||
<string name="driver_delete_plain">Файлы будут удалены из приложения.</string>
|
||||
<string name="driver_delete_in_use">Файлы будут удалены. Поскольку драйвер используется, будет применен драйвер по умолчанию.</string>
|
||||
<string name="drawer_page_display">Экран</string>
|
||||
<string name="drawer_page_controls">Управление</string>
|
||||
<string name="drawer_page_components">Компоненты</string>
|
||||
<string name="drawer_page_settings">Настройки</string>
|
||||
<string name="drawer_page_desc">Страница %1$s</string>
|
||||
<string name="drawer_steam">Steam</string>
|
||||
<string name="drawer_desktop">Рабочий стол</string>
|
||||
<string name="drawer_steam_menu">Меню Steam</string>
|
||||
<string name="drawer_quick_access">Быстрый доступ</string>
|
||||
<string name="drawer_open_qam">Открыть меню быстрого доступа</string>
|
||||
<string name="drawer_prev_page">Предыдущая страница</string>
|
||||
<string name="drawer_next_page">Следующая страница</string>
|
||||
<string name="drawer_hud">HUD производительности</string>
|
||||
<string name="drawer_touch_auto">Авто (%1$s)</string>
|
||||
<string name="drawer_keyboard">Клавиатура</string>
|
||||
<string name="drawer_pc_keyboard">Клавиатура ПК</string>
|
||||
<string name="drawer_android_keyboard">Android</string>
|
||||
<string name="drawer_second_screen">Второй экран</string>
|
||||
<string name="drawer_second_screen_shows">Показывать</string>
|
||||
<string name="drawer_android_apps">Приложения Android</string>
|
||||
<string name="drawer_launch_app">Запустить приложение</string>
|
||||
<string name="common_hide">Скрыть</string>
|
||||
<string name="common_show">Показать</string>
|
||||
<string name="drawer_no_apps">Нет приложений для запуска</string>
|
||||
<string name="drawer_session_behavior">Поведение сессии</string>
|
||||
<string name="drawer_suspend_hint">Применяется сразу и действует для будущих сессий этого режима.</string>
|
||||
<string name="drawer_next_session">Следующая сессия</string>
|
||||
<string name="drawer_support">Поддержка</string>
|
||||
<string name="drawer_logs">Журналы сессии</string>
|
||||
<string name="drawer_logs_hint">Прикрепить к отчету об ошибке</string>
|
||||
<string name="drawer_share_logs">Поделиться журналами</string>
|
||||
<string name="drawer_background">Фон</string>
|
||||
<string name="drawer_stop">Остановить</string>
|
||||
<string name="drawer_stop_title">Остановить сессию?</string>
|
||||
<string name="drawer_running_now">Сейчас выполняется</string>
|
||||
<string name="drawer_no_game">Сейчас нет игр, запущенных через Proton</string>
|
||||
<string name="drawer_no_proton">Proton не установлен.</string>
|
||||
<string name="drawer_game_running">Игра запущена</string>
|
||||
<string name="drawer_reapplied">Повторно применено при запуске %1$s</string>
|
||||
<string name="drawer_original">Оригинал</string>
|
||||
<string name="drawer_components_note">Скачивайте, импортируйте и удаляйте на странице «Компоненты».</string>
|
||||
<string name="setup_check_done">Готово</string>
|
||||
<string name="setup_check_warn">Требует внимания</string>
|
||||
<string name="setup_check_busy">Обработка</string>
|
||||
<string name="setup_limit_adb_failed">Проверьте IP-адрес и порт отладки по Wi-Fi; если Android удалил устройство, выполните повторное сопряжение.</string>
|
||||
<string name="setup_install">Установить</string>
|
||||
<string name="setup_manage">Управление</string>
|
||||
<string name="setup_tab_overview">Обзор</string>
|
||||
<string name="setup_tab_controller">Контроллер</string>
|
||||
<string name="setup_tab_session">Сессия</string>
|
||||
<string name="setup_tab_launcher">Лаунчер</string>
|
||||
<string name="setup_title">Настройка</string>
|
||||
<string name="setup_all_set">● Все готово</string>
|
||||
<string name="setup_n_ready">%1$d/%2$d готово</string>
|
||||
<string name="setup_system_check">Проверка системы</string>
|
||||
<string name="setup_gpu_supported">Устройство поддерживается</string>
|
||||
<string name="setup_gpu_untested">Непроверенный GPU</string>
|
||||
<string name="setup_gpu_unsupported">GPU не поддерживается</string>
|
||||
<string name="setup_gpu_detail">%1$s · %2$s</string>
|
||||
<string name="setup_gpu_untested_detail">%1$s: %2$s</string>
|
||||
<string name="setup_gpu_unsupported_detail">Steam требует драйвер Adreno; на %1$s возможен черный экран.</string>
|
||||
<string name="setup_runtime">Среда выполнения Linux</string>
|
||||
<string name="setup_runtime_progress">%1$s · %2$d%%</string>
|
||||
<string name="setup_runtime_missing">Не установлено · около 3 ГБ, устанавливается при первом запуске игры</string>
|
||||
<string name="setup_installed">Установлено</string>
|
||||
<string name="setup_runtime_update">%1$s · доступно обновление</string>
|
||||
<string name="setup_runtime_current">%1$s · актуально</string>
|
||||
<string name="setup_limit">Ограничение дочерних процессов</string>
|
||||
<string name="setup_limit_on">Включено · Android может закрыть Steam. Отключение занимает около минуты.</string>
|
||||
<string name="setup_limit_unset">Не задано · Android может закрыть Steam. Отключение занимает около минуты.</string>
|
||||
<string name="setup_limit_unknown">Невозможно проверить · отключение занимает около минуты.</string>
|
||||
<string name="setup_fix_it">Исправить</string>
|
||||
<string name="setup_details">Подробности</string>
|
||||
<string name="setup_dev_options">Для разработчиков</string>
|
||||
<string name="setup_use_wireless">Использовать отладку по Wi-Fi</string>
|
||||
<string name="setup_check_again">Проверить снова</string>
|
||||
<string name="setup_limit_turn_on">Включить ограничение</string>
|
||||
<string name="setup_account">Аккаунт Steam</string>
|
||||
<string name="setup_signed_in_offline">Вход выполнен как %1$s · автономный режим</string>
|
||||
<string name="setup_signed_in">Вход выполнен как %1$s</string>
|
||||
<string name="setup_sign_in">Нажмите Play и войдите в Steam</string>
|
||||
<string name="setup_tools">Инструменты</string>
|
||||
<string name="setup_controller">Контроллер</string>
|
||||
<string name="setup_controller_unavailable">Настройки контроллера недоступны.</string>
|
||||
<string name="setup_session">Сессия</string>
|
||||
<string name="setup_logs">Журналы сессии</string>
|
||||
<string name="setup_logs_hint">Сохраняется после каждой сессии</string>
|
||||
<string name="setup_latest_logs">Последние журналы сессии</string>
|
||||
<string name="setup_saved_logs">Сохраненные журналы сессии</string>
|
||||
<string name="setup_saved_logs_hint">Хранить последние %1$d</string>
|
||||
<string name="setup_clear_logs">Очистить журналы</string>
|
||||
<string name="setup_offline">Автономный режим</string>
|
||||
<string name="setup_sign_in_first">Сначала войдите в Steam</string>
|
||||
<string name="setup_launcher">Лаунчер</string>
|
||||
<string name="setup_theme">Тема</string>
|
||||
<string name="setup_theme_hint">Внешний вид лаунчера</string>
|
||||
<string name="setup_home">Использовать как главный экран</string>
|
||||
<string name="setup_home_on">DroidDeck может использоваться как приложение главного экрана</string>
|
||||
<string name="setup_home_off">DroidDeck не будет доступен как приложение главного экрана</string>
|
||||
<string name="setup_fullscreen">Полноэкранный режим</string>
|
||||
<string name="setup_fullscreen_on">Скрыть строку состояния и навигации Android</string>
|
||||
<string name="setup_fullscreen_off">Показать строку состояния и навигации Android</string>
|
||||
<string name="setup_default_home">Приложение главного экрана по умолчанию</string>
|
||||
<string name="setup_choose_home">Выбрать приложение главного экрана</string>
|
||||
<string name="setup_choose">Выбрать</string>
|
||||
<string name="setup_linux_apps">Приложения Linux (бета)</string>
|
||||
<string name="setup_store">Магазин Flathub</string>
|
||||
<string name="setup_store_on">Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Устанавливать приложения и игры Linux из Flathub</string>
|
||||
<string name="setup_tool_files">Файлы</string>
|
||||
<string name="setup_tool_files_hint">Просмотр и управление файлами</string>
|
||||
<string name="setup_tool_protons">Версии Proton</string>
|
||||
<string name="setup_tool_protons_hint">Установить сборки ARM64 Proton</string>
|
||||
<string name="setup_tool_performance">Производительность</string>
|
||||
<string name="setup_tool_performance_hint">Распределение ядер CPU</string>
|
||||
<string name="setup_tool_roms">Папки ROM</string>
|
||||
<string name="setup_tool_roms_hint">Место хранения игр эмулятора</string>
|
||||
<string name="setup_card_components">Компоненты</string>
|
||||
<string name="setup_card_components_hint">FEX, DXVK, VKD3D</string>
|
||||
<string name="setup_card_controls">Управление</string>
|
||||
<string name="setup_card_controls_hint">Сопоставление клавиш</string>
|
||||
<string name="store_tab_discover">Обзор</string>
|
||||
<string name="store_tab_search">Поиск</string>
|
||||
<string name="store_tab_installed">Установлено</string>
|
||||
<string name="store_tab_count">%1$s · %2$d</string>
|
||||
<string name="store_title">Магазин</string>
|
||||
<string name="store_chip_flathub">Flathub · ARM64</string>
|
||||
<string name="store_chip_ready">● Flatpak готов</string>
|
||||
<string name="store_needs_runtime">Сначала установите среду выполнения Linux в «Настройках».</string>
|
||||
<string name="store_setup_title">Настройка Flatpak</string>
|
||||
<string name="store_setup_text">Добавление Flatpak и подключение к Flathub (около 20 МБ). Первое приложение также загрузит свою среду выполнения.</string>
|
||||
<string name="store_setting_up">Настройка…</string>
|
||||
<string name="store_flatpak">Flatpak</string>
|
||||
<string name="store_updates">Обновления</string>
|
||||
<string name="store_busy">%1$s · %2$s</string>
|
||||
<string name="store_busy_percent">%1$s · %2$s · %3$d%%</string>
|
||||
<string name="store_starting">Запуск…</string>
|
||||
<string name="store_categories">Категории</string>
|
||||
<string name="store_unreachable">Не удалось подключиться к Flathub. Проверьте подключение к сети и повторите попытку.</string>
|
||||
<string name="store_try_again">Повторить</string>
|
||||
<string name="store_search_label">Поиск в Flathub</string>
|
||||
<string name="store_search_placeholder">Firefox, игры, эмуляторы…</string>
|
||||
<string name="store_search">Поиск</string>
|
||||
<string name="store_all">Все</string>
|
||||
<string name="store_searching">Поиск…</string>
|
||||
<string name="store_search_hint">Ищите по названию или выберите категорию. Здесь перечислены только приложения ARM64.</string>
|
||||
<string name="store_no_results">В Flathub нет подходящих приложений ARM64.</string>
|
||||
<string name="store_results">Результаты</string>
|
||||
<string name="store_check_updates">Проверить обновления</string>
|
||||
<string name="store_update_all">Обновить все (%1$d)</string>
|
||||
<string name="store_all_current">● Все актуально</string>
|
||||
<string name="store_no_apps">Приложений пока нет. Ищите в «Обзоре» или «Поиске».</string>
|
||||
<string name="store_setup_first_note">Настройте Flatpak, чтобы устанавливать приложения.</string>
|
||||
<string name="store_update_available_prefix">Доступно обновление · %1$s</string>
|
||||
<string name="store_open">Открыть</string>
|
||||
<string name="store_details">Подробности</string>
|
||||
<string name="store_remove">Удалить</string>
|
||||
<string name="store_press_again">Нажмите еще раз для удаления</string>
|
||||
<string name="store_installing">Установка…</string>
|
||||
<string name="store_install">Установить</string>
|
||||
<string name="store_setup_first">Сначала настройте Flatpak</string>
|
||||
<string name="store_download_size">Скачать %1$s</string>
|
||||
<string name="store_installed_chip">● Установлено</string>
|
||||
<string name="store_details_failed">Не удалось загрузить страницу Flathub этого приложения.</string>
|
||||
<string name="store_loading">Загрузка из Flathub…</string>
|
||||
<string name="store_no_arm">В Flathub нет сборки ARM64, поэтому установка здесь невозможна.</string>
|
||||
<string name="store_screenshots">Скриншоты</string>
|
||||
<string name="store_about">О приложении</string>
|
||||
<string name="store_app_id">ID приложения</string>
|
||||
<string name="store_license">Лицензия</string>
|
||||
<string name="store_runtime">Среда выполнения</string>
|
||||
<string name="store_installed_size">Установленный размер</string>
|
||||
<string name="store_installed_size_value">%1$s (плюс общая среда выполнения)</string>
|
||||
<string name="store_website">Веб-сайт</string>
|
||||
<string name="store_verified">Проверено</string>
|
||||
<string name="upd_title">Обновления</string>
|
||||
<string name="upd_build">DroidDeck %1$s · %2$s</string>
|
||||
<string name="upd_checked">Проверено %1$s</string>
|
||||
<string name="upd_not_checked">Не проверено</string>
|
||||
<string name="upd_perm_title">Разрешить DroidDeck самообновление</string>
|
||||
<string name="upd_perm_text">Включите «Разрешить из этого источника» на следующей странице и вернитесь. Обновление продолжится автоматически.</string>
|
||||
<string name="upd_open_settings">Открыть настройки</string>
|
||||
<string name="upd_not_now">Не сейчас</string>
|
||||
<string name="upd_signed_status">Другая подпись</string>
|
||||
<string name="upd_signed_headline">Невозможно обновить на месте</string>
|
||||
<string name="upd_signed_detail">Эта копия подписана не ключом выпуска DroidDeck, поэтому ее нельзя перезаписать напрямую. Удалите и переключитесь.</string>
|
||||
<string name="upd_available">Доступно обновление</string>
|
||||
<string name="upd_blocked_status">Невозможно установить</string>
|
||||
<string name="upd_switch">Можно переключить</string>
|
||||
<string name="upd_ahead_status">Опережает Stable</string>
|
||||
<string name="upd_ahead_headline">Вы опережаете Stable</string>
|
||||
<string name="upd_ahead_detail">Эта сборка новее последней Stable. Вы переключитесь на Stable после выпуска следующей версии Stable.</string>
|
||||
<string name="upd_gone_status">Тест завершен</string>
|
||||
<string name="upd_gone_headline">Этот тест завершен</string>
|
||||
<string name="upd_gone_detail">Исправление было объединено или отменено. Следите за Preview, чтобы получать последние исправления.</string>
|
||||
<string name="upd_current_status">Актуально</string>
|
||||
<string name="upd_current_headline">У вас последняя версия %1$s</string>
|
||||
<string name="upd_dot_ago">· %1$s</string>
|
||||
<string name="upd_stage_percent">%1$s… %2$d%%</string>
|
||||
<string name="upd_stage">%1$s…</string>
|
||||
<string name="upd_update_button">Обновить %1$s</string>
|
||||
<string name="upd_install_button">Установить %1$s</string>
|
||||
<string name="upd_install_anyway">Все равно установить Stable %1$s</string>
|
||||
<string name="upd_follow_preview">Следить за Preview</string>
|
||||
<string name="upd_stop_session">Остановите текущую сессию перед обновлением.</string>
|
||||
<string name="upd_less">Свернуть</string>
|
||||
<string name="upd_more">Еще</string>
|
||||
<string name="upd_new_stable">DroidDeck %1$s</string>
|
||||
<string name="upd_new_preview">Новая сборка Preview</string>
|
||||
<string name="upd_new_test">Тестовая сборка PR #%1$d</string>
|
||||
<string name="upd_mb">%1$d МБ</string>
|
||||
<string name="upd_channel">Канал обновлений</string>
|
||||
<string name="upd_stable">Stable</string>
|
||||
<string name="upd_stable_hint">Протестированная версия, подходит большинству</string>
|
||||
<string name="upd_version_ago">%1$s · %2$s</string>
|
||||
<string name="upd_preview">Preview</string>
|
||||
<string name="upd_preview_hint">Последние исправления перед Stable</string>
|
||||
<string name="upd_preview_current">Актуально</string>
|
||||
<string name="upd_tests">Тестовые сборки</string>
|
||||
<string name="upd_tests_hint">Попробовать исправление до официального выпуска</string>
|
||||
<string name="upd_tests_none">Сейчас нет</string>
|
||||
<string name="upd_pr">PR #%1$d</string>
|
||||
<string name="upd_not_built">Не собрано для этой копии DroidDeck</string>
|
||||
<string name="upd_updated">%1$s обновлено</string>
|
||||
<string name="upd_test_name">Тестовая версия PR #%1$d</string>
|
||||
<string name="upd_while_ago">Недавно</string>
|
||||
<string name="upd_just_now">Только что</string>
|
||||
<plurals name="upd_tests_count">
|
||||
<item quantity="one">%1$d доступный тест</item>
|
||||
<item quantity="few">%1$d доступных теста</item>
|
||||
<item quantity="many">%1$d доступных тестов</item>
|
||||
<item quantity="other">%1$d доступных тестов</item>
|
||||
</plurals>
|
||||
<plurals name="upd_preview_behind">
|
||||
<item quantity="one">Изменения с момента вашей сборки · Отставание на %1$d сборку</item>
|
||||
<item quantity="few">Изменения с момента вашей сборки · Отставание на %1$d сборки</item>
|
||||
<item quantity="many">Изменения с момента вашей сборки · Отставание на %1$d сборок</item>
|
||||
<item quantity="other">Изменения с момента вашей сборки · Отставание на %1$d сборки</item>
|
||||
</plurals>
|
||||
<plurals name="upd_preview_recent">
|
||||
<item quantity="one">Недавние изменения · %1$d сборка</item>
|
||||
<item quantity="few">Недавние изменения · %1$d сборки</item>
|
||||
<item quantity="many">Недавние изменения · %1$d сборок</item>
|
||||
<item quantity="other">Недавние изменения · %1$d сборки</item>
|
||||
</plurals>
|
||||
<string name="gate_title">Одна настройка Android, которую нужно изменить</string>
|
||||
<string name="gate_eyebrow">Steam</string>
|
||||
<string name="gate_lede">Android останавливает приложения, запускающие множество фоновых процессов, а Steam запускает десятки. До изменения этой настройки игры могут закрываться без сообщения об ошибке.</string>
|
||||
<string name="gate_fix_for_me">Исправить за меня</string>
|
||||
<string name="gate_toggle_steps">Включите «Отключить ограничение дочерних процессов» и вернитесь. Оставьте параметры разработчика включенными, иначе переключатель снова отключится.</string>
|
||||
<string name="gate_open_dev">Открыть параметры разработчика</string>
|
||||
<string name="gate_no_toggle">В этой версии Android нет такого переключателя, поэтому DroidDeck внесет изменения через отладку по Wi-Fi. Это займет около минуты, компьютер не нужен.</string>
|
||||
<string name="gate_needs_wifi">Для отладки по Wi-Fi требуется Wi-Fi. Подойдет любая сеть, интернет не нужен.</string>
|
||||
<string name="gate_needs_notifications">Код сопряжения отобразится в уведомлении. Разрешите DroidDeck отправлять уведомления или используйте «Другой способ».</string>
|
||||
<string name="gate_allow_notifications">Разрешить уведомления</string>
|
||||
<string name="gate_step1">Откройте Настройки → Для разработчиков → Отладка по Wi-Fi и включите ее</string>
|
||||
<string name="gate_step2">Нажмите «Сопряжение устройства с помощью кода»</string>
|
||||
<string name="gate_step3">Введите код сопряжения в уведомлении DroidDeck</string>
|
||||
<string name="gate_waiting">Ожидание окна сопряжения…</string>
|
||||
<string name="gate_code_needed">Окно найдено. Введите код сопряжения из него в уведомление.</string>
|
||||
<string name="gate_status">Статус</string>
|
||||
<string name="gate_polling">Проверка каждые 2 секунды. После отключения этого переключателя страница закроется автоматически.</string>
|
||||
<string name="gate_other_ways">Другие способы</string>
|
||||
<string name="gate_other_toggle">Отладка по Wi-Fi, ручной ввод адреса, ADB на ПК</string>
|
||||
<string name="gate_other_no_toggle">Ручной ввод адреса, ADB на ПК, параметры разработчика</string>
|
||||
<string name="gate_use_wireless_instead">Использовать отладку по Wi-Fi</string>
|
||||
<string name="gate_some_roms">Некоторые ROM все еще предоставляют этот переключатель. Найдите «дочерние процессы» в параметрах разработчика.</string>
|
||||
<string name="gate_manual_hint">Если способ с уведомлением не работает, введите адрес и код сопряжения из окна вручную.</string>
|
||||
<string name="gate_enter_manually">Ввести адрес вручную</string>
|
||||
<string name="gate_from_computer">Использовать ADB с компьютера:</string>
|
||||
<string name="gate_copy_command">Копировать команду</string>
|
||||
<string name="gate_choose_display">Выберите экран для открытия настроек Android.</string>
|
||||
<string name="gate_main_screen">Главный экран</string>
|
||||
<string name="wadb_finding_port">Сопряжено. Поиск порта отладки по Wi-Fi…</string>
|
||||
<string name="wadb_enter_port">Сопряжено. Введите порт из настроек.</string>
|
||||
<string name="wadb_applying">Устройство найдено. Применение настроек…</string>
|
||||
<string name="wadb_apply_failed">Не удалось применить автоматически. Проверьте адрес ниже и повторите попытку. %1$s</string>
|
||||
<string name="wadb_title_wireless">Отладка по Wi-Fi</string>
|
||||
<string name="wadb_title_done">Ограничение дочерних процессов обновлено</string>
|
||||
<string name="wadb_title">Изменить ограничение дочерних процессов</string>
|
||||
<string name="wadb_lede_compact0">Если опции нет, выполните сопряжение через отладку по Wi-Fi.</string>
|
||||
<string name="wadb_lede_compact1">Сопряжено. Проверьте IP-адрес и порт в отладке по Wi-Fi.</string>
|
||||
<string name="wadb_lede_done">Ограничение дочерних процессов обновлено.</string>
|
||||
<string name="wadb_lede0">Если в параметрах разработчика нет настройки дочерних процессов, используйте шаг 2. Откройте Для разработчиков → Отладка по Wi-Fi → Сопряжение устройства с помощью кода.</string>
|
||||
<string name="wadb_lede1">Сопряжено. Используйте адрес ниже или обновите его в разделе Отладка по Wi-Fi → IP-адрес и порт.</string>
|
||||
<string name="wadb_lede_done_long">Ограничение дочерних процессов обновлено через отладку по Wi-Fi.</string>
|
||||
<string name="wadb_confirmed_on">Android подтвердил, что ограничение включено.</string>
|
||||
<string name="wadb_confirmed_off">Android подтвердил, что ограничение отключено.</string>
|
||||
<string name="wadb_pair_group">2 · Сопряжение отладки по Wi-Fi</string>
|
||||
<string name="wadb_pair_address">IP-адрес и порт из окна сопряжения</string>
|
||||
<string name="wadb_pin">Код сопряжения (PIN)</string>
|
||||
<string name="wadb_pin_placeholder">6 цифр</string>
|
||||
<string name="wadb_pair">Сопряжение</string>
|
||||
<string name="wadb_apply_group">2 · Применение через отладку по Wi-Fi</string>
|
||||
<string name="wadb_connect_address">Подключить IP-адрес и порт</string>
|
||||
<string name="wadb_pair_again">Сопряжение заново</string>
|
||||
<string name="wadb_turn_off">Отключить ограничение</string>
|
||||
<string name="wadb_other_ways">Другие способы изменения</string>
|
||||
<string name="wadb_dev_try_first">1 · Для разработчиков (попробовать первым)</string>
|
||||
<string name="wadb_restrict_on">Включите «Ограничение дочерних процессов».</string>
|
||||
<string name="wadb_restrict_off">Отключите «Ограничение дочерних процессов».</string>
|
||||
<string name="wadb_computer_last">3 · ADB на ПК (крайняя мера)</string>
|
||||
<string name="wadb_only_if">Используйте только если отладка по Wi-Fi недоступна.</string>
|
||||
<string name="wadb_computer_group">3 · ADB на ПК (крайняя мера)</string>
|
||||
<string name="ctrl_osc_hint">При показе экранных элементов управления в сессии</string>
|
||||
<string name="ctrl_color">Цвет</string>
|
||||
<string name="ctrl_color_hint">Оттенок экранных кнопок</string>
|
||||
<string name="ctrl_custom">Пользовательский</string>
|
||||
<string name="ctrl_opacity">Непрозрачность</string>
|
||||
<string name="ctrl_percent">%1$d%%</string>
|
||||
<string name="ctrl_size">Размер кнопок</string>
|
||||
<string name="ctrl_size_hint" formatted="false">100% — стандартный размер</string>
|
||||
<string name="ctrl_stick_click">Нажатие стика</string>
|
||||
<string name="ctrl_stick_click_hint">Дважды нажмите и удерживайте стик для L3 или R3</string>
|
||||
<string name="ctrl_adaptive">Адаптивный стик</string>
|
||||
<string name="ctrl_rumble">Вибрация игры</string>
|
||||
<string name="ctrl_steam_button">Кнопка Steam</string>
|
||||
<string name="ctrl_qam_button">Кнопка QAM</string>
|
||||
<string name="ctrl_keyboard_button">Кнопка клавиатуры</string>
|
||||
<string name="setup_animations">Анимации</string>
|
||||
<string name="ctrl_adaptive_hint">Стик появится в месте касания и скроется после отпускания</string>
|
||||
<string name="ctrl_layout">Раскладка</string>
|
||||
<string name="ctrl_layout_custom">Сохранена пользовательская позиция</string>
|
||||
<string name="ctrl_layout_default">Адаптировано под этот экран</string>
|
||||
<string name="ctrl_edit">Изменить</string>
|
||||
<string name="ctrl_reset">Сбросить</string>
|
||||
<string name="ctrl_mapping">Сопоставление кнопок</string>
|
||||
<string name="ctrl_mapping_default">Каждая кнопка отправляет соответствующий исходный ввод</string>
|
||||
<string name="ctrl_mapping_count">%1$d/%2$d кнопок переназначено</string>
|
||||
<string name="ctrl_configure">Настроить</string>
|
||||
<string name="ctrl_reset_all">Сбросить контроллер</string>
|
||||
<string name="ctrl_reset_all_hint">Восстановить все настройки и раскладки контроллера</string>
|
||||
<string name="ctrl_mapping_lede">Настройте ввод, отправляемый каждой экранной кнопкой в игру. «Скрыть» удалит кнопку.</string>
|
||||
<string name="ctrl_onscreen_buttons">Экранные кнопки</string>
|
||||
<string name="ctrl_defaults">По умолчанию</string>
|
||||
<string name="ctrl_reset_mapping">Сбросить сопоставление</string>
|
||||
<string name="content_android_apps">Приложения Android</string>
|
||||
<string name="content_apps_count">%1$d приложений</string>
|
||||
<string name="content_no_android_apps">Не найдено приложений Android для запуска.</string>
|
||||
<string name="content_desktop_installed">● Рабочий стол установлен</string>
|
||||
<string name="content_desktop_first_open">Устанавливается при первом открытии</string>
|
||||
<string name="content_available">Доступно для установки</string>
|
||||
<string name="content_not_installed">Не установлено.</string>
|
||||
<string name="content_not_installed_suffix">%1$s · не установлено</string>
|
||||
<string name="content_open_named">Открыть %1$s</string>
|
||||
<string name="content_games">Игры</string>
|
||||
<string name="content_choose_roms">Выберите папку ROM.</string>
|
||||
<string name="content_retroarch_roms">В RetroArch перейдите в /root/ROMs.</string>
|
||||
<string name="content_add_roms">Добавлено %1$s игр в ROMs/%2$s.</string>
|
||||
<string name="content_install_named">Установить %1$s</string>
|
||||
<string name="content_stop_to_install">Остановите сессию для установки</string>
|
||||
<string name="content_runtime_required">Требуется среда выполнения</string>
|
||||
<string name="content_loading_details">Загрузка подробностей установки…</string>
|
||||
<string name="content_details_unavailable">Подробности установки недоступны. Повторите попытку позже.</string>
|
||||
<string name="content_needs_runtime">Сначала установите среду выполнения Linux в «Настройках».</string>
|
||||
<string name="content_stop_first">Остановите текущую сессию перед установкой приложения рабочего стола.</string>
|
||||
<string name="content_install_into">Установить %1$s на рабочий стол Linux.</string>
|
||||
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
|
||||
<string name="content_hide_note">Скрытие удалит его с рабочего стола. Файлы останутся в среде выполнения Linux.</string>
|
||||
<string name="content_rom_gone">Этой игры больше нет в папке ROM.</string>
|
||||
<string name="content_desktop_system">Рабочий стол · %1$s</string>
|
||||
<string name="content_launch_in">Запустить в %1$s</string>
|
||||
<string name="content_also_in">Также в %1$s</string>
|
||||
<string name="content_page_gone">Эта страница больше не существует. Выберите раздел слева.</string>
|
||||
<string name="content_open_desktop">Открыть рабочий стол</string>
|
||||
<string name="content_install_open_desktop">Установить и открыть рабочий стол</string>
|
||||
<string name="content_linux_desktop">Рабочий стол Linux</string>
|
||||
<string name="content_linux_desktop_hint">LXQt, Firefox и ваши эмуляторы</string>
|
||||
<string name="games_empty">Игры, установленные в Steam, появятся здесь после завершения сессии.</string>
|
||||
<string name="games_play_steam">Запустить Steam</string>
|
||||
<string name="games_launch_settings">Настройки запуска</string>
|
||||
<string name="games_launch">Запустить</string>
|
||||
<string name="games_files">Файлы игры</string>
|
||||
<string name="games_prefix">Префикс Proton</string>
|
||||
<string name="games_saves_looking">Поиск сохранений…</string>
|
||||
<string name="games_saves_none">Папка сохранений не найдена по имени. При экспорте будет включена вся папка пользователя.</string>
|
||||
<string name="games_saves_folders">%1$d папок сохранений</string>
|
||||
<string name="games_saves_summary">%1$s · %2$d файлов · %3$s</string>
|
||||
<string name="games_kb">%1$d КБ</string>
|
||||
<string name="games_manage_saves">Управление сохранениями</string>
|
||||
<string name="games_saves">Сохранения игры</string>
|
||||
<string name="games_import_saves">Импорт сохранений…</string>
|
||||
<string name="games_import_saves_hint">Архив GameHub или Winlator. Перед импортом текущие сохранения будут сохранены в резервной копии.</string>
|
||||
<string name="games_export_gamehub">Экспорт как архив GameHub…</string>
|
||||
<string name="games_export_gamehub_hint">Подходит для GameHub и BannerHub</string>
|
||||
<string name="games_export_winlator">Экспорт как архив Winlator…</string>
|
||||
<string name="games_export_winlator_hint">Подходит для Winlator, WinNative и Bannerlator</string>
|
||||
<string name="games_open_save_folder">Открыть папку сохранений</string>
|
||||
<string name="games_never_played">Никогда не запускалась</string>
|
||||
<string name="games_runtime_busy_percent">Среда выполнения занята · %1$d%%</string>
|
||||
<string name="games_runtime_busy">Среда выполнения занята</string>
|
||||
<string name="games_runtime_first_play">Установка среды выполнения при первом запуске игры</string>
|
||||
<string name="games_runtime_update">Доступно обновление среды выполнения</string>
|
||||
<string name="games_runtime_ready">● Среда выполнения готова</string>
|
||||
<string name="games_last_played">Последний запуск %1$s</string>
|
||||
<string name="games_internal">Внутреннее хранилище</string>
|
||||
<string name="games_added">Добавленные игры</string>
|
||||
<string name="gpu_auto_hint">Выбирает лучшую комбинацию драйверов для этого GPU и поддерживает ее в актуальном состоянии</string>
|
||||
<string name="gpu_manual_hint">Выбрать комбинацию драйверов вручную</string>
|
||||
<string name="gpu_checking">Проверка драйверов…</string>
|
||||
<string name="gpu_none_listed">Драйверы пока не перечислены. Обновите, чтобы найти драйверы.</string>
|
||||
<string name="gpu_hide_other">Скрыть драйверы других GPU</string>
|
||||
<string name="gpu_show_other">Показать драйверы других GPU</string>
|
||||
<string name="gpu_advanced">Дополнительно</string>
|
||||
<string name="gpu_set_with_display">Настраивается вместе с драйвером дисплея: %1$s включает оба.</string>
|
||||
<string name="gpu_set_with_runtime">Настраивается вместе с драйвером среды выполнения: %1$s включает оба.</string>
|
||||
<string name="gpu_runtime_hint">Используется играми и рабочим столом.</string>
|
||||
<string name="gpu_runtime_default">По умолчанию среды выполнения</string>
|
||||
<string name="gpu_display_hint">Отвечает за вывод изображения на экран. Вступит в силу после перезапуска DroidDeck.</string>
|
||||
<string name="gpu_auto_by_gpu">Авто - Выбор по GPU</string>
|
||||
<string name="gpu_manage">Скачать, импортировать или удалить…</string>
|
||||
<string name="gpu_in_use_caps">Используется</string>
|
||||
<string name="gpu_builtin">Встроенный драйвер DroidDeck</string>
|
||||
<string name="gpu_matched_pair">Драйвер среды выполнения и драйвер дисплея настраиваются вместе как совместимая пара.</string>
|
||||
<string name="gpu_recommended_for">Рекомендуется для этого GPU: %1$s.</string>
|
||||
<string name="gpu_refresh_hint">Обновите, чтобы найти драйверы, подходящие для этого GPU.</string>
|
||||
<string name="gpu_recommended_caps">Рекомендуется</string>
|
||||
<string name="gpu_unavailable">Недоступно</string>
|
||||
<string name="load_runtime">Среда выполнения Linux</string>
|
||||
<string name="load_runtime_detail">Подготовка среды выполнения Linux</string>
|
||||
<string name="load_session">Сессия</string>
|
||||
<string name="load_session_detail">Запуск дисплея и аудио</string>
|
||||
<string name="load_steam">Клиент Steam</string>
|
||||
<string name="load_steam_detail">Проверка Steam и его инструментов совместимости</string>
|
||||
<string name="load_opening_steam">Открытие Steam</string>
|
||||
<string name="load_opening_steam_detail">Ожидание первого кадра Steam</string>
|
||||
<string name="load_desktop">Рабочий стол</string>
|
||||
<string name="load_desktop_detail">Подготовка рабочего стола</string>
|
||||
<string name="load_opening">Открытие</string>
|
||||
<string name="load_opening_detail">Ожидание первого кадра</string>
|
||||
<string name="load_starting_steam">Запуск Steam</string>
|
||||
<string name="load_ended">Сессия завершена</string>
|
||||
<string name="load_step">Шаг %1$d из %2$d</string>
|
||||
<string name="load_steam_paused">Steam приостановлен</string>
|
||||
<string name="deck_tab_grips">Грипсы</string>
|
||||
<string name="deck_tab_left">Левый тачпад</string>
|
||||
<string name="deck_tab_right">Правый тачпад</string>
|
||||
<string name="deck_tab_both">Оба</string>
|
||||
<string name="deck_close">Закрыть управление вторым экраном</string>
|
||||
<string name="deck_open_steam_menu">Открыть меню Steam</string>
|
||||
<string name="deck_left_upper">Слева · Сверху</string>
|
||||
<string name="deck_left_lower">Слева · Снизу</string>
|
||||
<string name="deck_right_upper">Справа · Сверху</string>
|
||||
<string name="deck_right_lower">Справа · Снизу</string>
|
||||
<string name="deck_click">Нажать</string>
|
||||
<string name="deck_right_trackpad">Правый тачпад</string>
|
||||
<string name="deck_left_trackpad">Левый тачпад</string>
|
||||
<string name="second_trackpad">Тачпад</string>
|
||||
<string name="second_show_keyboard">Показать клавиатуру</string>
|
||||
<string name="second_hide_keyboard">Скрыть клавиатуру</string>
|
||||
<string name="second_terminal">Терминал Linux</string>
|
||||
<string name="second_terminal_not_ready">Сессия Linux не готова, терминал недоступен.</string>
|
||||
<string name="rail_steam">Steam</string>
|
||||
<string name="rail_games">Игры</string>
|
||||
<string name="rail_desktop">Рабочий стол</string>
|
||||
<string name="rail_store">Магазин</string>
|
||||
<string name="rail_components">Компоненты</string>
|
||||
<string name="rail_apps">Приложения</string>
|
||||
<string name="rail_setup">Настройка</string>
|
||||
<string name="rail_updates">Обновления</string>
|
||||
<string name="rail_percent">%1$d%%</string>
|
||||
<string name="rail_resume_named">Продолжить %1$s</string>
|
||||
<string name="roms_text">Отображается как /root/ROMs в каждой сессии. Внутреннее хранилище находится в /root/Storage.</string>
|
||||
<string name="roms_none">Папка не выбрана</string>
|
||||
<string name="roms_applies">Вступит в силу в следующей сессии. Поддерживаются SD-карты.</string>
|
||||
<string name="roms_choose">Выбрать папку</string>
|
||||
<string name="roms_change">Изменить папку</string>
|
||||
<string name="proton_lede">Установите сборку ARM64 Proton, затем выберите ее для каждой игры в Steam → Свойства → Совместимость.</string>
|
||||
<string name="proton_stop_first">Остановите текущую сессию перед установкой или удалением инструментов совместимости.</string>
|
||||
<string name="proton_available">Доступные сборки</string>
|
||||
<string name="proton_installed">%1$s установлено</string>
|
||||
<string name="proton_queued">Ожидающий элемент от предыдущего запроса</string>
|
||||
<string name="proton_not_installed">Не установлено</string>
|
||||
<string name="proton_install_now">Установить сейчас</string>
|
||||
<string name="proton_cancel_queued">Отменить очередь</string>
|
||||
<string name="pckb_android">Клавиатура Android</string>
|
||||
<string name="pckb_hide">Скрыть ✕</string>
|
||||
<string name="main_launch_named">Запустить %1$s</string>
|
||||
<string name="main_choose_screen">Выберите экран. Дополнительный экран: %1$s.</string>
|
||||
<string name="main_secondary_gone">Дополнительный экран недоступен.</string>
|
||||
<string name="main_primary">Главный экран</string>
|
||||
<string name="main_secondary">Дополнительный экран</string>
|
||||
<string name="widgets_prev_tab">Предыдущая вкладка</string>
|
||||
<string name="widgets_next_tab">Следующая вкладка</string>
|
||||
<string name="widgets_on">Вкл.</string>
|
||||
<string name="widgets_all_n">Все %1$d</string>
|
||||
<string name="widgets_none">Нет</string>
|
||||
<string name="widgets_n_of_m">%1$d/%2$d</string>
|
||||
<string name="widgets_off">Выкл.</string>
|
||||
<string name="screen_bottom">Нижний экран</string>
|
||||
<string name="game_env_applies">Вступит в силу при следующем запуске игры. Профиль игры переопределит общие настройки.</string>
|
||||
<string name="game_env_other">Выбрать другую игру по ID приложения…</string>
|
||||
<string name="game_env_done">Готово</string>
|
||||
<string name="game_env_default">По умолчанию</string>
|
||||
<string name="game_env_off">Выкл.</string>
|
||||
<string name="game_env_change">Изменить…</string>
|
||||
<string name="game_env_change_title">Изменить переменную</string>
|
||||
<string name="game_env_restore_default">Восстановить по умолчанию</string>
|
||||
<string name="game_env_eyebrow">Игры Proton</string>
|
||||
<string name="pip_title">Картинка в картинке</string>
|
||||
<string name="pip_auto">Автоматически переходить в режим PiP</string>
|
||||
<string name="pip_suspended">Приостановлено</string>
|
||||
<string name="pip_not_ready">PiP станет доступен после готовности сессии.</string>
|
||||
<string name="pip_permission">Разрешите DroidDeck использовать PiP в настройках Android.</string>
|
||||
<string name="pip_unavailable">Android не может открыть PiP.</string>
|
||||
<string name="game_shortcut">Ярлык</string>
|
||||
<string name="game_shortcut_add">Добавить на главный экран</string>
|
||||
<string name="game_shortcut_choose">Выбрать игру</string>
|
||||
<string name="game_shortcut_scanning">Сканирование установленных игр…</string>
|
||||
<string name="game_link_copy">Копировать ссылку запуска</string>
|
||||
<string name="game_link_copied">Ссылка запуска скопирована</string>
|
||||
<string name="game_shortcut_unsupported">Этот лаунчер не может добавлять ярлыки. Используйте «Копировать ссылку запуска».</string>
|
||||
<string name="game_link_invalid">Недействительная ссылка запуска игры.</string>
|
||||
<string name="game_link_missing">Этой игры больше нет в вашей библиотеке DroidDeck.</string>
|
||||
<string name="game_link_session_busy">Остановите текущую сессию перед запуском игры Steam.</string>
|
||||
<string name="game_link_failed">Не удалось отправить игру в Steam. Попробуйте запустить снова.</string>
|
||||
<string name="setup_retry_removal">Повторить удаление</string>
|
||||
<string name="runtime_removing">Удаление</string>
|
||||
<string name="runtime_removal_incomplete">Удаление не завершено</string>
|
||||
<string name="game_file_export">Экспорт файлов игры</string>
|
||||
<string name="game_file_sync">Синхронизировать все файлы игры в папку</string>
|
||||
<string name="game_file_stop_sync">Остановить синхронизацию файлов игры</string>
|
||||
<string name="game_file_folder">Выбрать папку для файлов игры фронтенда</string>
|
||||
<string name="game_file_exported">Файлы игры сохранены в %1$s</string>
|
||||
<string name="game_frontend_files">Файлы фронтенда</string>
|
||||
</resources>
|
||||
@@ -74,11 +74,21 @@
|
||||
<string name="game_env_vkd3d_config">D3D12 的 VKD3D 选项。nodxr 禁用光线追踪;仅供需要它的游戏使用。</string>
|
||||
<string name="game_env_proton_log">设为 1 可生成 Proton 诊断日志。记录日志会降低性能并占用存储。</string>
|
||||
<string name="game_env_wined3d">设为 1 可对 D3D9–11 使用基于 OpenGL 的 WineD3D 代替 DXVK 进行兼容性测试。</string>
|
||||
<string name="game_env_no_ntsync">设为 1 可让此游戏禁用 ntsync(droiddeck-ntsync 或内核驱动);存在对应包时使用 droiddeck-esync,否则使用 droiddeck-fsync。</string>
|
||||
<string name="game_env_no_esync">设为 1 可为此游戏禁用 droiddeck-esync;改用 droiddeck-fsync,若 fsync 也已禁用则使用 droiddeck-ntsync。</string>
|
||||
<string name="game_env_no_fsync">设为 1 可让此游戏禁用 droiddeck-fsync;存在对应包时使用 droiddeck-esync,否则使用 droiddeck-ntsync。</string>
|
||||
<string name="game_env_fsync_first">设为 1 可让此游戏使用 droiddeck-fsync;设为 0 则在有匹配其 Proton 的包时优先使用 droiddeck-esync。</string>
|
||||
<string name="game_env_xalia">设为 0 可为此游戏禁用 Proton 的手柄导航助手。</string>
|
||||
<string name="game_env_hud">针对 D3D9–11 的 DXVK 覆盖层。fps 显示帧率;不设置则隐藏。</string>
|
||||
<string name="game_env_dxvk_config">以英文分号(;)分隔的 DXVK 设置。新版本使用 dxvk.maxFrameRate 限制帧率;日常游戏建议优先使用会话帧率限制。</string>
|
||||
<string name="game_env_vkd3d_limit">D3D12 帧率限制。0 表示不限制。</string>
|
||||
<string name="game_env_glthread">Mesa OpenGL 多线程。true 可能有助于受 CPU 限制的 OpenGL 游戏;请逐个游戏测试。</string>
|
||||
<string name="sync_backend_title">Wine 同步</string>
|
||||
<string name="sync_backend_hint">游戏保持线程同步的方式。将在下次会话生效。</string>
|
||||
<string name="sync_backend_esync">esync</string>
|
||||
<string name="sync_backend_ntsync">ntsync</string>
|
||||
<string name="sync_backend_fsync">fsync</string>
|
||||
<string name="sync_backend_wineserver">wineserver</string>
|
||||
<string name="fex_preset_title">FEX 预设</string>
|
||||
<string name="fex_next_launch">将在下次启动游戏时生效。</string>
|
||||
<string name="game_settings_title">游戏</string>
|
||||
@@ -151,6 +161,15 @@
|
||||
<string name="add_app_script_hint">一个 .sh 文件。它在自己的文件夹中运行,全屏显示在 gamescope 下。</string>
|
||||
<string name="add_app_appimage_hint">ARM64(aarch64)AppImage。只会解包一次到 Linux 运行时中。</string>
|
||||
<string name="add_app_github_hint">从仓库的最新发布版本安装 ARM64 AppImage。</string>
|
||||
<string name="app_fex_title">使用 FEX</string>
|
||||
<string name="app_fex_auto">自动</string>
|
||||
<string name="app_fex_on">开启</string>
|
||||
<string name="app_fex_off">关闭</string>
|
||||
<string name="app_fex_hint">FEX 在此 ARM64 设备上运行 x86 Linux 程序。自动模式会在程序为 x86 构建时启用。</string>
|
||||
<string name="app_fex_x86">%1$s 经由 FEX</string>
|
||||
<string name="app_fex_not_ready">此程序面向 %1$s PC 构建,需经由 FEX 运行,而 FEX 尚未配置。请在登录状态下打开一次 Steam:DroidDeck 会让其下载 FEX 及所需的 Steam Linux Runtime,之后即可打开。</string>
|
||||
<string name="session_end_program">此程序</string>
|
||||
<string name="session_end_fex_missing">%1$s 面向 x86 PC 构建,需经由 FEX 运行,而 FEX 尚未安装。请在登录状态下启动一次 Steam,让其下载 FEX 与 Steam Linux Runtime,然后重新启动。</string>
|
||||
<string name="add_app_flatpak_hint">一个 Flathub 应用 ID 或其 Flathub 页面。若尚未设置 Flatpak 会先进行设置。</string>
|
||||
<string name="add_app_file">文件</string>
|
||||
<string name="add_app_no_file">未选择文件</string>
|
||||
@@ -186,6 +205,9 @@
|
||||
<string name="add_app_cancel">取消</string>
|
||||
<string name="add_app_runtime_required">请先在“配置”中安装 Linux 运行时。</string>
|
||||
<string name="add_app_busy">请等待 %1$s 完成。</string>
|
||||
<string name="steam_compat_label">兼容</string>
|
||||
<string name="proton_compatible_note">在 Steam 兼容性菜单中标记为“%1$s”的构建带有 DroidDeck 的前缀修复,可在此设备启动游戏。默认为 Proton Experimental ARM64(%1$s),安装更新构建后则为 Proton 11.0 ARM64(%1$s)。未带标记的 Valve 官方 Proton 条目无法在此设备启动游戏。</string>
|
||||
<string name="proton_installed_compatible">已安装 %1$s · %2$s</string>
|
||||
<string name="common_cancel">取消</string>
|
||||
<string name="common_auto">自动</string>
|
||||
<string name="common_never">从不</string>
|
||||
@@ -279,8 +301,6 @@
|
||||
<string name="mode_added_launches_in">启动 %1$s · 位于 %2$s</string>
|
||||
<string name="mode_added_choose">选择其他文件…</string>
|
||||
<string name="mode_added_exe_note">默认为与文件夹同名的 .exe,否则为最大的那个。</string>
|
||||
<string name="mode_fill">拉伸游戏以填满屏幕</string>
|
||||
<string name="mode_fill_hint">使会自行缩放的游戏保持全屏。若游戏显示过小,请关闭此项。将在下次会话生效。</string>
|
||||
<string name="mode_stretch169">拉伸 16:9 比例的游戏</string>
|
||||
<string name="mode_stretch169_hint">在比 16:9 更方的屏幕上,拉伸游戏填满屏幕而不是显示黑边。将在下次会话生效。</string>
|
||||
<string name="mode_audio">音频</string>
|
||||
@@ -308,6 +328,8 @@
|
||||
<string name="mode_storage_folder">文件夹:%1$s</string>
|
||||
<string name="mode_second_library">第二库</string>
|
||||
<string name="mode_storage_note">从 SD 卡或共享存储流式读取资源的游戏可能出现卡顿。</string>
|
||||
<string name="mode_storage_diagnostics">存储诊断</string>
|
||||
<string name="mode_storage_diagnostics_hint">在分享日志中记录分配结果和存储采样耗时。启用期间可能减慢安装。将在下次会话生效。</string>
|
||||
<string name="mode_choose_folder">选择文件夹…</string>
|
||||
<string name="mode_renderer">渲染器</string>
|
||||
<string name="mode_desktop_renderer">桌面渲染器</string>
|
||||
@@ -344,9 +366,16 @@
|
||||
<string name="comp_check_nightlies">检查 Nightlies 上的新软件包</string>
|
||||
<string name="comp_proton">Proton</string>
|
||||
<string name="comp_proton_note">Steam 更新时会替换 Valve 的 Proton;每个构建的原件都会保留。</string>
|
||||
<string name="comp_default_live">%1$s 现在将运行所有未在 Steam 中单独选择 Proton 的游戏。</string>
|
||||
<string name="comp_default_next">从下次 Steam 启动起,%1$s 将运行所有未单独选择 Proton 的游戏。</string>
|
||||
<string name="comp_default_not_runnable">%1$s 无法在此设备启动游戏,因此不能设为默认。</string>
|
||||
<string name="comp_default_failed">无法保存默认 Proton:%1$s</string>
|
||||
<string name="comp_swapped">%1$s · 已替换 %2$d 项</string>
|
||||
<string name="comp_all_original">%1$s · 全部为原件</string>
|
||||
<string name="comp_game_running_suffix">%1$s · 游戏运行中</string>
|
||||
<string name="comp_esync_pack">%1$s · droiddeck-esync 包</string>
|
||||
<string name="comp_esync_wanted">%1$s · 尚无 droiddeck-esync 包</string>
|
||||
<string name="comp_esync_builtin">%1$s · 内置 esync,无需包</string>
|
||||
<string name="comp_gpu_drivers">GPU 驱动</string>
|
||||
<string name="comp_reapplied">%1$s · 于启动 %2$s 时重新应用</string>
|
||||
<string name="comp_next_queued">下一个:%1$s · 在游戏关闭后</string>
|
||||
@@ -409,6 +438,12 @@
|
||||
<string name="perf_sysmem">Turnip:sysmem 渲染</string>
|
||||
<string name="perf_sysmem_hint">对 Steam 和大多数游戏更快。Adreno 710/720/722 必需。</string>
|
||||
<string name="perf_xalia">跳过 Steam 的 xalia 助手</string>
|
||||
<string name="perf_fastsync">droiddeck-ntsync(实验性)</string>
|
||||
<string name="perf_fastsync_hint">Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。</string>
|
||||
<string name="perf_esync">droiddeck-esync</string>
|
||||
<string name="perf_fsync">droiddeck-fsync 优先</string>
|
||||
<string name="perf_fsync_hint">在原始 Proton 文件上运行 Proton 自带的 fsync,由用户态提供 futex_waitv,因此无需任何包,且 Steam 更新 Proton 后立即继续可用。开启时,除 Proton 自身禁用 fsync 的少数游戏外,各游戏优先使用它而非 droiddeck-esync。将在下次会话生效。</string>
|
||||
<string name="perf_esync_hint">游戏的 Windows 线程通过 eventfd 和共享内存相互等待,而非经由 wineserver 往返。使用为该 Proton 构建精确制作的 droiddeck-esync 包;包会自动获取。在新 Proton 构建尚无对应包之前,由 droiddeck-fsync 顶替。将在下次会话生效。</string>
|
||||
<string name="perf_xalia_hint">禁用 Proton 的手柄导航助手,它会在每个游戏中消耗 CPU。</string>
|
||||
<string name="perf_seccomp">不启用 seccomp 运行 proot</string>
|
||||
<string name="perf_seccomp_hint">可能修复某些内核上的缺失系统调用错误。可能降低性能。</string>
|
||||
@@ -429,6 +464,7 @@
|
||||
<string name="driver_delete_plain">它的文件会从应用中移除。</string>
|
||||
<string name="driver_delete_in_use">文件将被移除。该驱动正在使用,将由默认驱动接管。</string>
|
||||
<string name="drawer_page_display">显示</string>
|
||||
<string name="drawer_page_effects">画面效果</string>
|
||||
<string name="drawer_page_controls">控制</string>
|
||||
<string name="drawer_page_components">组件</string>
|
||||
<string name="drawer_page_settings">设置</string>
|
||||
@@ -441,7 +477,30 @@
|
||||
<string name="drawer_prev_page">上一页</string>
|
||||
<string name="drawer_next_page">下一页</string>
|
||||
<string name="drawer_hud">性能 HUD</string>
|
||||
<string name="drawer_fill">拉伸游戏填满屏幕</string>
|
||||
<string name="drawer_scaling">缩放模式</string>
|
||||
<string name="drawer_scaling_sharpness">缩放锐度</string>
|
||||
<string name="drawer_effects">屏幕效果</string>
|
||||
<string name="drawer_effects_hint">实时生效。任何效果开启期间会占用少量 GPU 时间。</string>
|
||||
<string name="drawer_look">观感</string>
|
||||
<string name="drawer_look_custom">自定义</string>
|
||||
<string name="drawer_cas">锐化(CAS)</string>
|
||||
<string name="drawer_cas_level">锐化级别</string>
|
||||
<string name="drawer_fake_hdr">伪 HDR</string>
|
||||
<string name="drawer_deband">去色带</string>
|
||||
<string name="drawer_deband_strength">去色带强度</string>
|
||||
<string name="drawer_brightness">亮度</string>
|
||||
<string name="drawer_contrast">对比度</string>
|
||||
<string name="drawer_gamma">伽马</string>
|
||||
<string name="drawer_saturation">饱和度</string>
|
||||
<string name="drawer_fxaa">FXAA(边缘平滑)</string>
|
||||
<string name="drawer_toon">卡通描边</string>
|
||||
<string name="drawer_crt">CRT</string>
|
||||
<string name="drawer_ntsc">NTSC</string>
|
||||
<string name="drawer_texture">纹理过滤</string>
|
||||
<string name="drawer_texture_hint">DirectX 9–11 游戏,下次启动时生效。</string>
|
||||
<string name="drawer_anisotropy">各向异性过滤</string>
|
||||
<string name="drawer_texture_sharpness">纹理锐度</string>
|
||||
<string name="drawer_texture_sharpness_note">负 mipmap 偏移:纹理更锐利,可能有轻微闪烁。自动模式会匹配会话放大到屏幕的程度。</string>
|
||||
<string name="drawer_touch_auto">自动(%1$s)</string>
|
||||
<string name="drawer_keyboard">键盘</string>
|
||||
<string name="drawer_pc_keyboard">PC 键盘</string>
|
||||
|
||||
@@ -279,8 +279,6 @@
|
||||
<string name="mode_added_launches_in">啟動 %1$s · 位於 %2$s</string>
|
||||
<string name="mode_added_choose">選擇其他檔案…</string>
|
||||
<string name="mode_added_exe_note">默認使用與文件夾同名的 .exe,否則使用最大的 .exe。</string>
|
||||
<string name="mode_fill">拉伸遊戲以填滿屏幕</string>
|
||||
<string name="mode_fill_hint">讓支持自行調整大小的遊戲保持全屏幕。如果遊戲顯示得很小,請關閉此項。下次會話生效。</string>
|
||||
<string name="mode_stretch169">拉伸 16:9 遊戲</string>
|
||||
<string name="mode_stretch169_hint">在寬高比小於 16:9 的屏幕上,將遊戲拉伸以填滿屏幕,而不是顯示黑邊。下次會話生效。</string>
|
||||
<string name="mode_audio">音頻</string>
|
||||
@@ -442,7 +440,6 @@
|
||||
<string name="drawer_prev_page">上一頁</string>
|
||||
<string name="drawer_next_page">下一頁</string>
|
||||
<string name="drawer_hud">性能 HUD</string>
|
||||
<string name="drawer_fill">拉伸遊戲以填滿屏幕</string>
|
||||
<string name="drawer_touch_auto">自動(%1$s)</string>
|
||||
<string name="drawer_keyboard">鍵盤</string>
|
||||
<string name="drawer_pc_keyboard">電腦鍵盤</string>
|
||||
|
||||
@@ -279,8 +279,6 @@
|
||||
<string name="mode_added_launches_in">啟動 %1$s · 位於 %2$s</string>
|
||||
<string name="mode_added_choose">選擇其他檔案…</string>
|
||||
<string name="mode_added_exe_note">預設使用與資料夾同名的 .exe,否則使用最大的 .exe。</string>
|
||||
<string name="mode_fill">拉伸遊戲以填滿螢幕</string>
|
||||
<string name="mode_fill_hint">讓支援自行調整大小的遊戲保持全螢幕。如果遊戲顯示得很小,請關閉此項。下次工作階段生效。</string>
|
||||
<string name="mode_stretch169">拉伸 16:9 遊戲</string>
|
||||
<string name="mode_stretch169_hint">在長寬比小於 16:9 的螢幕上,將遊戲拉伸以填滿螢幕,而不是顯示黑邊。下次工作階段生效。</string>
|
||||
<string name="mode_audio">音訊</string>
|
||||
@@ -442,7 +440,6 @@
|
||||
<string name="drawer_prev_page">上一頁</string>
|
||||
<string name="drawer_next_page">下一頁</string>
|
||||
<string name="drawer_hud">效能 HUD</string>
|
||||
<string name="drawer_fill">拉伸遊戲以填滿螢幕</string>
|
||||
<string name="drawer_touch_auto">自動(%1$s)</string>
|
||||
<string name="drawer_keyboard">鍵盤</string>
|
||||
<string name="drawer_pc_keyboard">電腦鍵盤</string>
|
||||
|
||||
@@ -263,6 +263,8 @@
|
||||
<string name="mode_suspend">Background behavior</string>
|
||||
<string name="mode_suspend_hint">When the app is hidden or the screen turns off.</string>
|
||||
<string name="mode_suspend_manual">Manual</string>
|
||||
<string name="mode_suspend_native">Native</string>
|
||||
<string name="mode_suspend_steam_note">Auto pauses and resumes when shown. Native prepares Steam for sleep first. Manual waits for Resume. Never keeps running.</string>
|
||||
<string name="mode_suspend_note">Auto pauses in the background and resumes when shown. Manual waits for Resume. Never keeps running.</string>
|
||||
<string name="mode_startup">Startup</string>
|
||||
<string name="mode_steam_startup">Start Steam with DroidDeck</string>
|
||||
@@ -291,6 +293,10 @@
|
||||
<string name="mode_branch_public">Public beta</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
|
||||
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
|
||||
<string name="mode_steam_repair">Repair Steam client</string>
|
||||
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
|
||||
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
|
||||
<string name="mode_steam_repair_button">Repair</string>
|
||||
<string name="mode_added_games">Added games</string>
|
||||
<string name="mode_added_none">no game folders with a .exe found</string>
|
||||
<string name="mode_games_folder">Games folder</string>
|
||||
@@ -301,8 +307,29 @@
|
||||
<string name="mode_added_launches_in">Launches %1$s · in %2$s</string>
|
||||
<string name="mode_added_choose">Choose another file…</string>
|
||||
<string name="mode_added_exe_note">Defaults to the .exe named after the folder, or else the largest.</string>
|
||||
<string name="mode_fill">Stretch games to fill the screen</string>
|
||||
<string name="mode_fill_hint">Keeps self-resizing games full screen. Turn off if a game shows up small. Applies next session.</string>
|
||||
<string name="wincomp_title">Windows components</string>
|
||||
<string name="wincomp_pad_hint">LB / RB jump between the sections.</string>
|
||||
<string name="wincomp_card_none">None</string>
|
||||
<string name="wincomp_applies">Windows DLLs Proton does not include, from Bannerlator\'s component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
|
||||
<string name="wincomp_offline">Could not reach the component list. Components already downloaded can still be switched.</string>
|
||||
<string name="wincomp_recommended">Recommended for this game</string>
|
||||
<string name="wincomp_found_bundled">The game ships %1$s</string>
|
||||
<string name="wincomp_found_shipped">The game has %1$s (often works as is)</string>
|
||||
<string name="wincomp_found_steam">Steam installs %1$s with this game</string>
|
||||
<string name="wincomp_already_here">Already in this game</string>
|
||||
<string name="wincomp_no_recommendation">Nothing in the game\'s folder points at a component.</string>
|
||||
<string name="wincomp_all">All components (%1$d)</string>
|
||||
<string name="wincomp_installers">Need a Windows installer (%1$d)</string>
|
||||
<string name="wincomp_installers_note">These run a real installer inside the prefix (.NET, PhysX, XNA, the full Visual C++ installers…). DroidDeck cannot do that yet; it is coming next.</string>
|
||||
<string name="wincomp_show">Show</string>
|
||||
<string name="wincomp_hide">Hide</string>
|
||||
<string name="wincomp_needs_installer">Needs a Windows installer (coming)</string>
|
||||
<string name="wincomp_unsupported">Not supported here yet</string>
|
||||
<string name="wincomp_loading">Loading the components…</string>
|
||||
<string name="wincomp_failed">Could not install: %1$s</string>
|
||||
<string name="wincomp_downloaded">Downloaded</string>
|
||||
<string name="wincomp_download_size">%1$s download</string>
|
||||
<string name="wincomp_next_launch">Takes effect the next time the game starts.</string>
|
||||
<string name="mode_stretch169">Stretch 16:9 games</string>
|
||||
<string name="mode_stretch169_hint">On screens squarer than 16:9, stretches games to fill instead of showing bars. Applies next session.</string>
|
||||
<string name="mode_audio">Audio</string>
|
||||
@@ -331,6 +358,8 @@
|
||||
<string name="mode_second_library">Second library</string>
|
||||
<string name="mode_storage_note">Games that stream assets from SD or shared storage may stutter.</string>
|
||||
<string name="mode_storage_diagnostics">Storage diagnostics</string>
|
||||
<string name="mode_skip_preallocation">Skip space reservation (experimental)</string>
|
||||
<string name="mode_skip_preallocation_hint">Steam starts downloading to the game storage folder straight away instead of reserving every file\'s space first, which can take many minutes on an SD card. If the card fills up, the download stops with a disk write error. Applies next session.</string>
|
||||
<string name="mode_storage_diagnostics_hint">Record allocation results and sampled storage timings in Share logs. May slow installs while enabled. Applies next session.</string>
|
||||
<string name="mode_choose_folder">Choose a folder…</string>
|
||||
<string name="mode_renderer">Renderer</string>
|
||||
@@ -470,6 +499,7 @@
|
||||
<string name="drawer_page_effects">Effects</string>
|
||||
<string name="drawer_page_controls">Controls</string>
|
||||
<string name="drawer_page_components">Components</string>
|
||||
<string name="drawer_page_controller">Controller</string>
|
||||
<string name="drawer_page_settings">Settings</string>
|
||||
<string name="drawer_page_desc">%1$s page</string>
|
||||
<string name="drawer_steam">Steam</string>
|
||||
@@ -480,13 +510,15 @@
|
||||
<string name="drawer_prev_page">Previous page</string>
|
||||
<string name="drawer_next_page">Next page</string>
|
||||
<string name="drawer_hud">Performance HUD</string>
|
||||
<string name="drawer_fill">Stretch games to fill</string>
|
||||
<string name="drawer_scaling">Scaling mode</string>
|
||||
<string name="drawer_scaling_sharpness">Scaling sharpness</string>
|
||||
<string name="drawer_effects">Screen effects</string>
|
||||
<string name="drawer_effects_hint">Live. Costs a little GPU time while any effect is on.</string>
|
||||
<string name="drawer_look">Look</string>
|
||||
<string name="drawer_look">Presets</string>
|
||||
<string name="drawer_look_custom">Custom</string>
|
||||
<string name="drawer_effects_advanced">Advanced</string>
|
||||
<string name="widgets_expanded">Expanded</string>
|
||||
<string name="widgets_collapsed">Collapsed</string>
|
||||
<string name="drawer_cas">Sharpening (CAS)</string>
|
||||
<string name="drawer_cas_level">Sharpening level</string>
|
||||
<string name="drawer_fake_hdr">Fake HDR</string>
|
||||
@@ -510,6 +542,7 @@
|
||||
<string name="drawer_pc_keyboard">PC keyboard</string>
|
||||
<string name="drawer_android_keyboard">Android</string>
|
||||
<string name="drawer_second_screen">Second screen</string>
|
||||
<string name="drawer_second_screen_mode">Mode</string>
|
||||
<string name="drawer_second_screen_shows">Shows</string>
|
||||
<string name="drawer_android_apps">Android apps</string>
|
||||
<string name="drawer_launch_app">Launch an app</string>
|
||||
@@ -586,6 +619,9 @@
|
||||
<string name="setup_offline">Offline mode</string>
|
||||
<string name="setup_sign_in_first">Sign in to Steam first</string>
|
||||
<string name="setup_launcher">Launcher</string>
|
||||
<string name="setup_app_scale">App scale</string>
|
||||
<string name="setup_app_scale_hint">DroidDeck text and controls</string>
|
||||
<string name="setup_app_scale_default">%1$d%% (Default)</string>
|
||||
<string name="setup_theme">Theme</string>
|
||||
<string name="setup_theme_hint">Launcher appearance</string>
|
||||
<string name="setup_home">Use as Home screen</string>
|
||||
@@ -1011,4 +1047,29 @@
|
||||
<string name="game_file_folder">Choose a folder for frontend game files</string>
|
||||
<string name="game_file_exported">Game files saved to %1$s</string>
|
||||
<string name="game_frontend_files">Frontend files</string>
|
||||
<string name="display_session">Session display</string>
|
||||
<string name="display_resolution">Resolution</string>
|
||||
<string name="display_game_fit">16:9 game fit</string>
|
||||
<string name="display_game_fit_hint">How 16:9 games fit this display. Next session.</string>
|
||||
<string name="display_game_fit_unavailable">Choose Match screen or a custom size narrower than 16:9.</string>
|
||||
<string name="display_preserve">Keep proportions</string>
|
||||
<string name="display_stretch">Stretch to display</string>
|
||||
<string name="display_image_scaling">Image scaling</string>
|
||||
<string name="display_filter">Scaling filter</string>
|
||||
<string name="display_filter_hint">Shared by Steam and Desktop.</string>
|
||||
<string name="display_filter_live">Applies live. Shared by Steam and Desktop.</string>
|
||||
<string name="display_filter_note">FSR, GSR and NIS apply when enlarging the session image. Sharpen only also works without enlargement.</string>
|
||||
<string name="display_sharpness">Filter sharpness</string>
|
||||
<string name="display_frame_rate">Frame rate</string>
|
||||
<string name="display_look_filter">%1$s Sets scaling to %2$s.</string>
|
||||
<string name="display_match_screen">Match screen (%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">Show device details</string>
|
||||
<string name="device_info_hide">Hide device details</string>
|
||||
<string name="device_info_loading">Reading the device…</string>
|
||||
<string name="device_info_copy">Copy</string>
|
||||
<string name="device_info_copy_hint">Copy these details into a bug report.</string>
|
||||
<string name="device_info_copied">Copied.</string>
|
||||
<string name="force_ssbs_title">Force SSBS</string>
|
||||
<string name="force_ssbs_hint">Keeps a CPU speed setting on that Wine keeps turning off. Can make Windows games faster on Snapdragon 8 Elite; little change seen on older chips. Applies on the next game launch.</string>
|
||||
</resources>
|
||||
@@ -4,4 +4,8 @@
|
||||
<locale android:name="en" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="ru" />
|
||||
</locale-config>
|
||||
@@ -11,6 +11,14 @@ class GameEnvironmentTest {
|
||||
assertEquals("6_9", values["VKD3D_SHADER_MODEL"])
|
||||
}
|
||||
|
||||
@Test fun forceSsbsIsADefaultAGameCanTurnOff() {
|
||||
assertFalse(GameEnvironment.defaults("").containsKey(GameEnvironment.FORCE_SSBS))
|
||||
assertEquals("1", GameEnvironment.defaults("", forceSsbs = true)[GameEnvironment.FORCE_SSBS])
|
||||
val config = GameEnvironment.Config().withEntries("42", mapOf(GameEnvironment.FORCE_SSBS to "0"))
|
||||
assertEquals("0", GameEnvironment.effective(config, "", "42", forceSsbs = true)[GameEnvironment.FORCE_SSBS])
|
||||
assertEquals("1", GameEnvironment.effective(config, "", "43", forceSsbs = true)[GameEnvironment.FORCE_SSBS])
|
||||
}
|
||||
|
||||
@Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() {
|
||||
val config = GameEnvironment.Config(
|
||||
shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"),
|
||||
|
||||
@@ -27,6 +27,7 @@ class GameFileSyncTest {
|
||||
}
|
||||
val folder = File(context.filesDir, "exports").apply { mkdirs() }
|
||||
GameFileSync.enable(context, folder)
|
||||
assertEquals("8400", File(folder, "Geometry Wars.steam").readText())
|
||||
assertEquals(folder.path, GameFileSync.folder(context))
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
// An unavailable source must leave the existing export intact.
|
||||
@@ -35,6 +36,8 @@ class GameFileSyncTest {
|
||||
assertThrows(IllegalStateException::class.java) { GameFileSync.syncConfigured(context) }
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
assertTrue(unavailable.renameTo(steamapps))
|
||||
GameFileSync.syncConfigured(context)
|
||||
assertTrue(File(folder, "Geometry Wars.steam").exists())
|
||||
// A genuine uninstall removes the owned export at the next tick.
|
||||
assertTrue(manifest.delete())
|
||||
GameFileSync.syncConfigured(context)
|
||||
@@ -44,6 +47,7 @@ class GameFileSyncTest {
|
||||
manifest.writeText("\"name\" \"Geometry Wars\"\n\"StateFlags\" \"4\"")
|
||||
GameFileSync.syncConfigured(context)
|
||||
assertNull(GameFileSync.folder(context))
|
||||
assertFalse(File(folder, "Geometry Wars.steam").exists())
|
||||
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class GameFilesTest {
|
||||
@Test fun exportsPortableFilesAndAcceptsFileUriRawPathAndContentUri() {
|
||||
val folder = tmp.newFolder()
|
||||
val file = GameFiles.export(folder, game("18446744073709551615", "A/B: C"))
|
||||
assertEquals("A_B_ C (18446744073709551615).droiddeck", file.name)
|
||||
assertEquals("droiddeck://game/18446744073709551615\n", file.readText())
|
||||
assertEquals("A_B_ C.steam", file.name)
|
||||
assertEquals("18446744073709551615", file.readText())
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
listOf(Uri.fromFile(file), Uri.parse(file.path)).forEach { uri ->
|
||||
assertEquals("18446744073709551615", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, uri)))
|
||||
@@ -44,15 +44,15 @@ class GameFilesTest {
|
||||
|
||||
@Test fun rejectsCommandsMalformedIdsLargeFilesAndUnsupportedIntents() {
|
||||
listOf("", "0", "01", "-1", "18446744073709551616", "steam://rungameid/8400",
|
||||
"8400\n8401", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
|
||||
"8400\n8401", "droiddeck://game/8400", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
|
||||
assertNull(value, GameFiles.read(ByteArrayInputStream(value.toByteArray())))
|
||||
}
|
||||
assertEquals("8400", GameFiles.read(ByteArrayInputStream("8400\n".toByteArray())))
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_SEND, Uri.parse("droiddeck://game/8400"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.droiddeck"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.droiddeck"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.droiddeck")))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.steam"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.steam"))))
|
||||
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.steam")))))
|
||||
assertEquals("8400", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("droiddeck://game/8400"))))
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ class GameFilesTest {
|
||||
assertEquals("custom artwork", image.readText())
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertEquals("custom artwork", image.readText())
|
||||
assertEquals(0, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
|
||||
assertEquals("8400", File(folder, GameFiles.filename(game)).readText())
|
||||
assertEquals(1, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
|
||||
icon.delete()
|
||||
val fallback = tmp.newFolder()
|
||||
GameFiles.export(fallback, game)
|
||||
@@ -80,7 +81,7 @@ class GameFilesTest {
|
||||
|
||||
@Test fun syncAddsRenamesAndRemovesOwnedFilesAndPreservesUnrelatedAndEditedFiles() {
|
||||
val folder = tmp.newFolder()
|
||||
val unrelated = File(folder, "manual.droiddeck").apply { writeText("droiddeck://game/1\n") }
|
||||
val unrelated = File(folder, "manual.steam").apply { writeText("1") }
|
||||
val note = File(folder, "notes.txt").apply { writeText("Keep me") }
|
||||
val first = game()
|
||||
val second = game("620", "Portal 2")
|
||||
@@ -110,10 +111,12 @@ class GameFilesTest {
|
||||
GameFiles.sync(folder, listOf(first))
|
||||
val second = game("620", "Portal 2")
|
||||
val collision = File(folder, GameFiles.filename(second)).apply { writeText("personal file") }
|
||||
val fallback = File(folder, GameFiles.filename(second, disambiguate = true)).apply { writeText("another personal file") }
|
||||
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(folder, listOf(second)) }
|
||||
assertEquals("personal file", collision.readText())
|
||||
assertTrue(File(folder, GameFiles.filename(first)).isFile)
|
||||
collision.delete()
|
||||
fallback.delete()
|
||||
GameFiles.sync(folder, listOf(second))
|
||||
assertFalse(File(folder, GameFiles.filename(first)).exists())
|
||||
assertTrue(collision.isFile)
|
||||
@@ -132,10 +135,10 @@ class GameFilesTest {
|
||||
@Test fun refusesSymlinksLeavingTheExportFolder() {
|
||||
val folder = tmp.newFolder()
|
||||
val outside = tmp.newFile()
|
||||
outside.writeText("droiddeck://game/8400\n")
|
||||
outside.writeText("8400")
|
||||
java.nio.file.Files.createSymbolicLink(File(folder, GameFiles.filename(game())).toPath(), outside.toPath())
|
||||
assertThrows(IllegalArgumentException::class.java) { GameFiles.export(folder, game()) }
|
||||
assertEquals("droiddeck://game/8400\n", outside.readText())
|
||||
assertEquals("8400", outside.readText())
|
||||
}
|
||||
|
||||
@Test fun caseOnlyRenameKeepsTheLiveExportOnCaseInsensitiveStorage() {
|
||||
@@ -148,6 +151,8 @@ class GameFilesTest {
|
||||
assertTrue(file.isFile)
|
||||
assertEquals("8400", file.inputStream().use(GameFiles::read))
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertFalse(file.exists())
|
||||
}
|
||||
|
||||
@Test fun unrelatedInventoryIsPreserved() {
|
||||
@@ -158,6 +163,52 @@ class GameFilesTest {
|
||||
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
}
|
||||
|
||||
@Test fun steamExportContainsOnlyIdForSteamAndAddedGames() {
|
||||
val folder = tmp.newFolder()
|
||||
val file = GameFiles.export(folder, game("620", "Portal 2"))
|
||||
assertEquals("Portal 2.steam", file.name)
|
||||
assertEquals("620", file.readText())
|
||||
assertEquals("620", GameFiles.readIntent(RuntimeEnvironment.getApplication(), Intent(Intent.ACTION_VIEW, Uri.fromFile(file))))
|
||||
val added = Library.SteamGame(1, "Added Game", null, Library.ADDED, -1L)
|
||||
val addedFile = GameFiles.export(folder, added)
|
||||
assertEquals("Added Game.steam", addedFile.name)
|
||||
assertEquals("18446744073709551615", addedFile.readText())
|
||||
}
|
||||
|
||||
@Test fun steamSyncDisambiguatesTitlesAndPreservesEditedAndForeignFiles() {
|
||||
val folder = tmp.newFolder()
|
||||
val first = game("620", "A/B")
|
||||
val second = game("8400", "A:B")
|
||||
val foreign = File(folder, "Existing.steam").apply { writeText("1") }
|
||||
val existing = game("1", "Existing")
|
||||
GameFiles.sync(folder, listOf(first, second, existing))
|
||||
val owned = File(folder, "A_B (620).steam")
|
||||
assertEquals("620", owned.readText())
|
||||
assertEquals("8400", File(folder, "A_B (8400).steam").readText())
|
||||
owned.writeText("user edit")
|
||||
GameFiles.sync(folder, listOf(first, second, existing))
|
||||
assertEquals("user edit", owned.readText())
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertEquals("user edit", owned.readText())
|
||||
assertEquals("1", foreign.readText())
|
||||
assertFalse(File(folder, "A_B (8400).steam").exists())
|
||||
}
|
||||
|
||||
@Test fun steamCollisionFallbackDoesNotClaimAnExistingFile() {
|
||||
val folder = tmp.newFolder()
|
||||
val collision = File(folder, "Geometry Wars.steam").apply { writeText("other file") }
|
||||
val existing = File(folder, "Geometry Wars (8400).steam").apply { writeText("8400") }
|
||||
GameFiles.sync(folder, listOf(game()))
|
||||
GameFiles.sync(folder, emptyList())
|
||||
assertEquals("other file", collision.readText())
|
||||
assertEquals("8400", existing.readText())
|
||||
existing.writeText("another file")
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GameFiles.sync(folder, listOf(game()))
|
||||
}
|
||||
assertEquals("another file", existing.readText())
|
||||
}
|
||||
|
||||
private class LaunchFileProvider(private val file: File) : ContentProvider() {
|
||||
override fun onCreate() = true
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class GameLaunchIntentTest {
|
||||
@Test fun acceptsNativeAndExplicitlyRetargetedGameNativeProfiles() {
|
||||
listOf(GameLaunchIntent.ACTION, "app.gamenative.LAUNCH_GAME").forEach { action ->
|
||||
val request = Intent(action).putExtra("app_id", 620).putExtra("game_source", "STEAM")
|
||||
assertEquals("620", GameLaunchIntent.read(RuntimeEnvironment.getApplication(), request))
|
||||
assertEquals("8400", GameLaunchIntent.readId(Intent(action).putExtra("app_id", "8400")))
|
||||
assertEquals("620", GameLaunchIntent.readId(request.putExtra("game_source", "steam")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun stringIdsPreserveUnsignedShortcutIds() {
|
||||
listOf("2147483648", "18446744073709551615").forEach { id ->
|
||||
assertEquals(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun rejectsUnsupportedSourcesTypesAndIdsWithoutFallingBackToData() {
|
||||
listOf("", "0", "01", "+1", "-1", "18446744073709551616", "620\n", "steam://rungameid/620").forEach { id ->
|
||||
assertNull(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
|
||||
}
|
||||
listOf("EPIC", "GOG", "CUSTOM_GAME", "", "STEAM ").forEach { source ->
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", source)))
|
||||
}
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620L)))
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", -1)))
|
||||
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", 1)))
|
||||
assertNull(GameLaunchIntent.readId(Intent(Intent.ACTION_MAIN).putExtra("app_id", 620)))
|
||||
assertNull(GameLaunchIntent.read(RuntimeEnvironment.getApplication(), Intent(GameLaunchIntent.ACTION,
|
||||
android.net.Uri.parse("droiddeck://game/620"))))
|
||||
}
|
||||
|
||||
@Test fun deepLinksStillAcceptUnsignedShortcutIds() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertEquals("18446744073709551615", GameLaunchIntent.read(context,
|
||||
Intent(Intent.ACTION_VIEW, android.net.Uri.parse("droiddeck://game/18446744073709551615"))))
|
||||
assertFalse(GameLaunchIntent.accepts(Intent.ACTION_SEND))
|
||||
assertTrue(GameLaunchIntent.accepts(GameLaunchIntent.ACTION))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class WinComponentDetectorsTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun steamsSharedDepotsBecomeRecommendations() {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
val game = File(steamapps, "common/BioShock Remastered").apply { mkdirs() }
|
||||
File(steamapps, "appmanifest_409710.acf").writeText(
|
||||
"""
|
||||
"AppState"
|
||||
{
|
||||
"appid" "409710"
|
||||
"SharedDepots"
|
||||
{
|
||||
"228983" "228980"
|
||||
"228984" "228980"
|
||||
"228988" "228980"
|
||||
"228987" "228980"
|
||||
"228990" "228980"
|
||||
"999999" "228980"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val found = SteamRedists.detect(game, 409710)
|
||||
assertEquals(listOf("vcredist2010_dll", "vcredist2012_dll", "vcredist2019_dll", "d3dx9"), found.map { it.componentName })
|
||||
assertTrue(found.all { it.kind == DependencyDetector.Kind.STEAM })
|
||||
assertEquals("VC++ 2010", found.first().reason)
|
||||
assertEquals(emptyList<DependencyDetector.Recommendation>(), SteamRedists.detect(game, 1))
|
||||
}
|
||||
|
||||
@Test fun thePrefixCountsOnlyWhatDroidDeckDidNotPutThere() {
|
||||
val compat = tmp.newFolder("compatdata", "409710")
|
||||
val pfx = File(compat, "pfx")
|
||||
File(pfx, "drive_c/windows/syswow64").mkdirs()
|
||||
File(pfx, "drive_c/windows/syswow64/OpenAL32.dll").writeText("x")
|
||||
File(pfx, "system.reg").writeText("[Software\\\\Microsoft\\\\Games for Windows Live]\n")
|
||||
assertEquals(setOf("oalinst", "XLiveRedist"), PrefixInstalledDetector.detect(compat))
|
||||
File(compat, ".droiddeck-wincomponents.json").writeText("""{"files": {"drive_c/windows/syswow64/openal32.dll": "oalinst_dll"}}""")
|
||||
assertEquals(setOf("XLiveRedist"), PrefixInstalledDetector.detect(compat))
|
||||
assertEquals(emptySet<String>(), PrefixInstalledDetector.detect(File(tmp.root, "missing")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GpuModelFallbackTest {
|
||||
@Test fun aPlatformCodeNameGivesItsAdreno() {
|
||||
// AYANEO's Pocket FIT: KGSL says "Adreno33v2", the platform says pineapple.
|
||||
assertEquals(750, GpuInfo.platformModel("pineapple"))
|
||||
assertEquals(740, GpuInfo.platformModel("KALAMA"))
|
||||
assertEquals(830, GpuInfo.platformModel("sun"))
|
||||
assertEquals(0, GpuInfo.platformModel("somethingelse"))
|
||||
assertEquals(GpuInfo.Family.A7XX, GpuInfo.familyOf(true, GpuInfo.platformModel("pineapple")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SocNamesTest {
|
||||
private fun props(vararg pairs: Pair<String, String>): (String) -> String = { pairs.toMap()[it].orEmpty() }
|
||||
|
||||
@Test fun theModelIsFoundWhereVendorsPutIt() {
|
||||
// AYANEO Pocket FIT: ro.soc.model blank, the vendor property set, ro.fota.platform a branch name.
|
||||
assertEquals("SG8350P", SocNames.model(props("ro.vendor.qti.soc_model" to "SG8350P", "ro.fota.platform" to "MSM_13.0")))
|
||||
assertEquals("QCS8550", SocNames.model(props("ro.soc.model" to "QCS8550", "ro.vendor.qti.soc_model" to "other")))
|
||||
assertEquals("", SocNames.model(props("ro.fota.platform" to "MSM_13.0")))
|
||||
}
|
||||
|
||||
@Test fun knownModelsAndPlatformsGetTheirNames() {
|
||||
assertEquals("Snapdragon 8 Gen 2", SocNames.name("QCS8550", ""))
|
||||
assertEquals("Snapdragon 8 Gen 3 family", SocNames.name("SG8350P", "pineapple"))
|
||||
assertNull(SocNames.name("XYZ123", "nowhere"))
|
||||
assertEquals("Snapdragon 8 Gen 2 (QCS8550)", SocNames.label("QCS8550", ""))
|
||||
assertEquals("Snapdragon 8 Gen 3 family (SG8350P)", SocNames.label("SG8350P", "pineapple"))
|
||||
assertEquals("XYZ123", SocNames.label("XYZ123", ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session
|
||||
* tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the
|
||||
* first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in
|
||||
* for that here.
|
||||
*/
|
||||
class FakeInputRingPathTest {
|
||||
private lateinit var base: File
|
||||
private lateinit var real: File
|
||||
private lateinit var link: File
|
||||
|
||||
@Before fun setUp() {
|
||||
base = Files.createTempDirectory("ring-path").toFile()
|
||||
real = File(base, "data-data").apply { mkdirs() }
|
||||
link = File(base, "data-user-0")
|
||||
Files.createSymbolicLink(link.toPath(), real.toPath())
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
base.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun ringsSitBesideTheFakeInputNodes() {
|
||||
val fakeInputDir = File(link, "files/session/dev/input")
|
||||
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2))
|
||||
}
|
||||
|
||||
@Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() {
|
||||
val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() }
|
||||
val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") }
|
||||
// The link really does resolve elsewhere, as /data/user/0 does on the affected devices.
|
||||
assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/"))
|
||||
val exported = FakeInputRingPaths.exportPath(ring)
|
||||
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported)
|
||||
assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/"))
|
||||
assertTrue("$exported does not open", File(exported).isFile)
|
||||
}
|
||||
|
||||
@Test fun aRelativeInputDirIsExportedAbsolute() {
|
||||
val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!)
|
||||
assertTrue(exported, File(exported).isAbsolute)
|
||||
assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class ProotFastPathTest {
|
||||
private val root = File("/data/data/com.droiddeck.launcher/files/linuxfs")
|
||||
|
||||
@Test fun aSessionsUsualBindsGetAKey() {
|
||||
// 87 on a device with two Games folders: past the old limit of 64, which dropped them.
|
||||
val binds = (1..87).map { "/host/$it:/guest/$it" }
|
||||
assertNotNull(ProotFastPath.key(root, binds))
|
||||
}
|
||||
|
||||
@Test fun moreBindsThanTheLibraryHoldsLeaveItOff() {
|
||||
val binds = (1..ProotFastPath.MAX_BINDS + 1).map { "/host/$it:/guest/$it" }
|
||||
assertNull(ProotFastPath.key(root, binds))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.BatteryManager
|
||||
import java.io.File
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class BatteryComponentTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private var battery: BatteryComponent? = null
|
||||
|
||||
@After fun stop() { battery?.stop() }
|
||||
|
||||
@Test fun oneAggregateBatteryKeepsOverlayPowerAndLifetimeConsistentWithQam() {
|
||||
val supplies = tmp.newFolder("power_supply")
|
||||
val vpower = tmp.newFolder("vpower")
|
||||
start(supplies, vpower, -2_000_000L)
|
||||
assertTelemetry(supplies, vpower)
|
||||
}
|
||||
|
||||
@Test fun upgradingRemovesTheDuplicateRatherThanLeavingItForOverlayDiscovery() {
|
||||
val supplies = tmp.newFolder("power_supply")
|
||||
val vpower = tmp.newFolder("vpower")
|
||||
val legacy = File(supplies, "BAT0").apply { mkdirs() }
|
||||
File(legacy, "power_now").writeText("8000000\n")
|
||||
File(legacy, "charge_now").writeText("4000000\n")
|
||||
start(supplies, vpower, 2_000_000L)
|
||||
assertFalse(legacy.exists())
|
||||
assertTelemetry(supplies, vpower)
|
||||
}
|
||||
|
||||
private fun start(supplies: File, vpower: File, currentUa: Long) {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
|
||||
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
|
||||
putExtra(BatteryManager.EXTRA_LEVEL, 50)
|
||||
putExtra(BatteryManager.EXTRA_SCALE, 100)
|
||||
putExtra(BatteryManager.EXTRA_VOLTAGE, 4000)
|
||||
})
|
||||
shadowOf(context.getSystemService(BatteryManager::class.java)).apply {
|
||||
setLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER, 4_000_000L)
|
||||
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW, currentUa)
|
||||
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE, currentUa)
|
||||
}
|
||||
battery = BatteryComponent(supplies, vpower).also { it.attach(context); it.start() }
|
||||
}
|
||||
|
||||
private fun assertTelemetry(supplies: File, vpower: File) {
|
||||
val discovered = supplies.listFiles()!!.filter { it.name.contains("BAT") }
|
||||
assertEquals(listOf("BAT1"), discovered.map { it.name })
|
||||
fun read(name: String) = File(discovered.single(), name).readText().trim().toLong()
|
||||
// One 4 Ah battery at 4 V drawing 2 A: 8 W and two hours, regardless of vendor current sign.
|
||||
assertEquals(8_000_000L, read("power_now"))
|
||||
assertEquals(16_000_000L, read("energy_now"))
|
||||
assertEquals(7200L, read("time_to_empty_now"))
|
||||
assertEquals("7200", File(vpower, "secs_until_shutdown_request").readText().trim())
|
||||
assertEquals("50.00", File(vpower, "battery_percent").readText().trim())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class ForcedPackageTest {
|
||||
private val dxvk2 = "dxvk-2.7.1-linux.wcp"
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
File(context.filesDir, "components").deleteRecursively()
|
||||
LinuxRuntime.rootDir(context).deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun adreno6xxForcesDxvk2AndNothingElseDoes() {
|
||||
val rule = ComponentsManager.FORCED_PACKAGES.single { GpuInfo.Family.A6XX in it.families }
|
||||
assertEquals("dxvk", rule.comp)
|
||||
assertEquals(dxvk2, rule.file)
|
||||
assertEquals("Dxvk-Linux", rule.release)
|
||||
assertTrue(rule.models.isEmpty())
|
||||
assertTrue(rule.matches(gpu(650, GpuInfo.Family.A6XX)))
|
||||
assertTrue(rule.matches(gpu(610, GpuInfo.Family.A6XX)))
|
||||
assertTrue(rule.matches(gpu(690, GpuInfo.Family.A6XX)))
|
||||
assertFalse(rule.matches(gpu(740, GpuInfo.Family.A7XX)))
|
||||
assertFalse(rule.matches(gpu(830, GpuInfo.Family.A8XX)))
|
||||
assertTrue(ComponentsManager.FORCED_PACKAGES.none { GpuInfo.Family.A7XX in it.families })
|
||||
}
|
||||
|
||||
@Test fun aModelListOnlyMatchesThoseChips() {
|
||||
val rule = ComponentsManager.ForcedPackage(
|
||||
setOf(GpuInfo.Family.A7XX_LOW), "dxvk", dxvk2, "Dxvk-Linux", setOf(710),
|
||||
)
|
||||
assertTrue(rule.matches(gpu(710, GpuInfo.Family.A7XX_LOW)))
|
||||
assertFalse(rule.matches(gpu(720, GpuInfo.Family.A7XX_LOW)))
|
||||
assertFalse(rule.matches(gpu(710, GpuInfo.Family.A6XX)))
|
||||
assertFalse(ComponentsManager.ForcedPackage(emptySet(), "dxvk", dxvk2, "Dxvk-Linux").matches(gpu(650, GpuInfo.Family.A6XX)))
|
||||
}
|
||||
|
||||
@Test fun aShippedComponentIsReplacedUntilTheUserChoosesOne() {
|
||||
assertTrue(ComponentsManager.forcedPackageWanted(null, false))
|
||||
assertTrue(ComponentsManager.forcedPackageWanted(null, true))
|
||||
assertTrue(ComponentsManager.forcedPackageWanted("", false))
|
||||
assertFalse(ComponentsManager.forcedPackageWanted(dxvk2, true))
|
||||
assertTrue(ComponentsManager.forcedPackageWanted(dxvk2, false))
|
||||
assertFalse(ComponentsManager.forcedPackageWanted("dxvk-3.1-linux.wcp", true))
|
||||
assertTrue(ComponentsManager.forcedPackageWanted("dxvk-3.1-linux.wcp", false))
|
||||
assertTrue(ComponentsManager.forcedPackageWanted("dxvk-gplasync-2.7.1-1-linux.wcp", false))
|
||||
}
|
||||
|
||||
@Test fun catalogMatchPicksTheReleaseAndIgnoresAnother() {
|
||||
val wanted = item(dxvk2, "Dxvk-Linux")
|
||||
val otherRelease = item(dxvk2, "Dxvk-gplasync-Linux")
|
||||
val otherFile = item("dxvk-3.1-linux.wcp", "Dxvk-Linux")
|
||||
assertEquals(wanted, ComponentsManager.catalogMatch(listOf(otherFile, otherRelease, wanted), dxvk2, "Dxvk-Linux"))
|
||||
assertNull(ComponentsManager.catalogMatch(listOf(otherFile, otherRelease), dxvk2, "Dxvk-Linux"))
|
||||
assertNull(ComponentsManager.catalogMatch(emptyList(), dxvk2, "Dxvk-Linux"))
|
||||
}
|
||||
|
||||
@Test fun thisMachineIsNotForced() {
|
||||
assertNull(ComponentsManager.ensureForcedPackages(context))
|
||||
}
|
||||
|
||||
@Test fun anotherGpuIsLeftAlone() {
|
||||
proton("proton-exp")
|
||||
assertNull(install(gpu(740, GpuInfo.Family.A7XX)))
|
||||
}
|
||||
|
||||
@Test fun noInstalledProtonNeedsNothing() {
|
||||
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
|
||||
}
|
||||
|
||||
@Test fun aChosenPackageIsNotReplaced() {
|
||||
proton("proton-exp")
|
||||
writeState("proton-exp", "dxvk-3.1-linux.wcp", "experimental-11")
|
||||
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
|
||||
}
|
||||
|
||||
@Test fun theSamePackageOnThisProtonBuildIsNotFetchedAgain() {
|
||||
proton("proton-exp")
|
||||
writeState("proton-exp", dxvk2, "experimental-11")
|
||||
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
|
||||
}
|
||||
|
||||
@Test fun aMissingListingLeavesTheShippedCopy() {
|
||||
proton("proton-exp")
|
||||
val result = install(gpu(650, GpuInfo.Family.A6XX), lookup = { _, _ -> null })
|
||||
assertEquals("$dxvk2 is not in the Nightlies listing; DXVK stays on the Proton's copy", result)
|
||||
}
|
||||
|
||||
@Test fun aShippedCopyAndAProtonUpdateAreBothSwapped() {
|
||||
proton("kept")
|
||||
proton("proton-exp", "experimental-12")
|
||||
proton("other", "experimental-12")
|
||||
writeState("kept", "dxvk-3.1-linux.wcp", "experimental-11")
|
||||
writeState("proton-exp", dxvk2, "experimental-11")
|
||||
writeState("other", "dxvk-3.1-linux.wcp", "experimental-11")
|
||||
val fetched = mutableListOf<String>()
|
||||
val applied = mutableListOf<String>()
|
||||
val result = install(
|
||||
gpu(650, GpuInfo.Family.A6XX),
|
||||
lookup = { file, release -> item(file, release) },
|
||||
fetch = { fetched += it.file },
|
||||
apply = { id, file -> applied += "$id $file"; "DXVK 2.7.1 is now in $id. It applies the next time a game starts." },
|
||||
)
|
||||
assertEquals(listOf(dxvk2), fetched)
|
||||
assertEquals(listOf("other $dxvk2", "proton-exp $dxvk2"), applied)
|
||||
assertEquals(
|
||||
"DXVK 2.7.1 is now in other. It applies the next time a game starts.; " +
|
||||
"DXVK 2.7.1 is now in proton-exp. It applies the next time a game starts.",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun everyProtonStillOnItsShippedCopyIsSwapped() {
|
||||
proton("one")
|
||||
proton("two")
|
||||
val applied = mutableListOf<String>()
|
||||
val result = install(
|
||||
gpu(650, GpuInfo.Family.A6XX),
|
||||
lookup = { file, release -> item(file, release) },
|
||||
fetch = {},
|
||||
apply = { id, _ -> applied += id; "DXVK 2.7.1 is now in $id. It applies the next time a game starts." },
|
||||
)
|
||||
assertEquals(listOf("one", "two"), applied)
|
||||
assertEquals(
|
||||
"DXVK 2.7.1 is now in one. It applies the next time a game starts.; " +
|
||||
"DXVK 2.7.1 is now in two. It applies the next time a game starts.",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aBrokenStoredPackageIsFetchedAgain() {
|
||||
proton("proton-exp")
|
||||
File(context.filesDir, "components/packages/$dxvk2").apply { parentFile!!.mkdirs(); writeText("not a package") }
|
||||
val fetched = mutableListOf<String>()
|
||||
install(
|
||||
gpu(650, GpuInfo.Family.A6XX),
|
||||
lookup = { file, release -> item(file, release) },
|
||||
fetch = { fetched += it.file },
|
||||
apply = { _, _ -> "swapped" },
|
||||
)
|
||||
assertEquals(listOf(dxvk2), fetched)
|
||||
assertFalse(File(context.filesDir, "components/packages/$dxvk2").isFile)
|
||||
}
|
||||
|
||||
@Test fun aFailedFetchIsLeftForTheSessionToLog() {
|
||||
proton("proton-exp")
|
||||
val applied = mutableListOf<String>()
|
||||
val error = assertThrows(IllegalStateException::class.java) {
|
||||
install(
|
||||
gpu(650, GpuInfo.Family.A6XX),
|
||||
lookup = { file, release -> item(file, release) },
|
||||
fetch = { throw IllegalStateException("Download failed") },
|
||||
apply = { id, _ -> applied += id; "" },
|
||||
)
|
||||
}
|
||||
assertEquals("Download failed", error.message)
|
||||
assertTrue(applied.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun theCachedListingIsUsedBeforeRefreshing() {
|
||||
writeCatalog()
|
||||
assertEquals(cachedUrl, ComponentsManager.catalogItem(context, dxvk2, "Dxvk-Linux")?.url)
|
||||
}
|
||||
|
||||
@Test fun aCacheMissReadsTheRefreshedListingOnce() {
|
||||
var refreshes = 0
|
||||
val refreshed = item(dxvk2, "Dxvk-Linux")
|
||||
val found = ComponentsManager.resolveCatalogItem(dxvk2, "Dxvk-Linux", { emptyList() }, { refreshes += 1; listOf(refreshed) })
|
||||
assertEquals(refreshed, found)
|
||||
assertEquals(1, refreshes)
|
||||
assertNull(ComponentsManager.resolveCatalogItem("missing.wcp", "Dxvk-Linux", { emptyList() }, { refreshes += 1; emptyList() }))
|
||||
assertEquals(2, refreshes)
|
||||
val cached = item(dxvk2, "Dxvk-Linux")
|
||||
assertEquals(cached, ComponentsManager.resolveCatalogItem(dxvk2, "Dxvk-Linux", { listOf(cached) }, { error("refresh") }))
|
||||
}
|
||||
|
||||
private fun gpu(model: Int, family: GpuInfo.Family) = GpuInfo("Adreno $model", model, family, "", false)
|
||||
|
||||
private fun install(
|
||||
gpu: GpuInfo,
|
||||
lookup: (String, String) -> ComponentsManager.CatalogItem? = ::refuseLookup,
|
||||
fetch: (ComponentsManager.CatalogItem) -> Unit = ::refuseFetch,
|
||||
apply: (String, String) -> String = ::refuseApply,
|
||||
) = ComponentsManager.ensureForcedPackages(context, gpu, lookup, fetch, apply)
|
||||
|
||||
private fun refuseLookup(file: String, release: String): ComponentsManager.CatalogItem? = error("catalog $file $release")
|
||||
|
||||
private fun refuseFetch(item: ComponentsManager.CatalogItem) { error("fetch ${item.file}") }
|
||||
|
||||
private fun refuseApply(protonId: String, file: String): String = error("apply $protonId $file")
|
||||
|
||||
private fun proton(name: String, version: String = "experimental-11") {
|
||||
val dir = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/common/$name")
|
||||
File(dir, "files/lib/wine").mkdirs()
|
||||
File(dir, "proton").writeText("#!/bin/sh\n")
|
||||
File(dir, "version").writeText("1 $version\n")
|
||||
}
|
||||
|
||||
private fun writeState(protonId: String, file: String, protonVersion: String) {
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
state.parentFile!!.mkdirs()
|
||||
val active = if (state.isFile) JSONObject(state.readText()).getJSONObject("active") else JSONObject()
|
||||
active.put(protonId, JSONObject().put("dxvk", JSONObject().put("file", file).put("protonVersion", protonVersion)))
|
||||
state.writeText(JSONObject().put("active", active).put("queued", JSONObject()).toString())
|
||||
}
|
||||
|
||||
private val cachedUrl = "https://github.com/The412Banner/Nightlies/releases/download/Dxvk-Linux/$dxvk2?cached=1"
|
||||
|
||||
private fun writeCatalog() {
|
||||
val cache = File(context.filesDir, "components/catalog.json")
|
||||
cache.parentFile!!.mkdirs()
|
||||
cache.writeText(JSONObject()
|
||||
.put("fetchedAt", 1)
|
||||
.put("items", JSONArray().put(JSONObject()
|
||||
.put("file", dxvk2).put("comp", "dxvk").put("release", "Dxvk-Linux")
|
||||
.put("url", cachedUrl).put("size", 4).put("digest", "sha256:" + "ab".repeat(32))))
|
||||
.toString())
|
||||
}
|
||||
|
||||
private fun item(file: String, release: String) = ComponentsManager.CatalogItem(
|
||||
file, "dxvk", release, "https://github.com/The412Banner/Nightlies/releases/download/$release/$file", 4, "sha256:" + "ab".repeat(32),
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.os.Looper
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import java.io.IOException
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -68,12 +69,74 @@ class RumbleComponentTest {
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
private fun effect() {
|
||||
// Inject a decoded force-feedback packet; transport is outside this preference test.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, 65535, 5000)
|
||||
@Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null }
|
||||
activeController(PAD_ID)
|
||||
effect(strong = 40000, weak = 1000)
|
||||
assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played)
|
||||
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
|
||||
rumble.controllerMotors = { null }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
assertTrue(pad.vibrating)
|
||||
activeController(PadBridge.NO_CONTROLLER)
|
||||
effect()
|
||||
assertFalse("the controller kept rumbling after the switch", pad.vibrating)
|
||||
assertTrue(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
effect()
|
||||
effect(strong = 0, weak = 0)
|
||||
assertFalse(pad.vibrating)
|
||||
effect()
|
||||
ControllerPrefs.setRumble(context, false)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertFalse(pad.vibrating)
|
||||
ControllerPrefs.setRumble(context, true)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
effect()
|
||||
rumble.stop()
|
||||
assertFalse(pad.vibrating)
|
||||
}
|
||||
|
||||
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
|
||||
|
||||
private fun activeController(id: Int) {
|
||||
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
|
||||
}
|
||||
|
||||
private fun effect(strong: Int = 65535, weak: Int = 65535) {
|
||||
// Inject a decoded force-feedback packet; transport is outside these tests.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
|
||||
}
|
||||
|
||||
private class FakeMotors : RumbleComponent.Motors {
|
||||
override val name = "fake pad"
|
||||
val played = mutableListOf<Triple<Int, Int, Long>>()
|
||||
var vibrating = false
|
||||
override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true }
|
||||
override fun cancel() { vibrating = false }
|
||||
}
|
||||
|
||||
private companion object { const val PAD_ID = 7 }
|
||||
|
||||
@Implements(LocalServerSocket::class)
|
||||
class NoPacketsSocket {
|
||||
@Implementation fun __constructor__(name: String) = Unit
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class SessionBackgroundPrefsTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun clearSettings() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun nativeIsOptInAndDoesNotChangeDesktopOrExistingPolicies() {
|
||||
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "steam"))
|
||||
for (policy in listOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_MANUAL, SessionPrefs.SUSPEND_NEVER,
|
||||
SessionPrefs.SUSPEND_NATIVE)) {
|
||||
SessionPrefs.setSuspendPolicy(context, "steam", policy)
|
||||
assertEquals(policy, SessionPrefs.suspendPolicy(context, "steam"))
|
||||
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, "desktop"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun directGamesUseAutoWithoutOverwritingSteamsNativeChoice() {
|
||||
SessionPrefs.setSuspendPolicy(context, SessionService.MODE_STEAM, SessionPrefs.SUSPEND_NATIVE)
|
||||
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_RUN))
|
||||
assertEquals(SessionPrefs.SUSPEND_NATIVE, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDisplayTest {
|
||||
@Test fun fixedSizesAndMatchScreenResolveWithoutAnyAspectSetting() {
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720"))
|
||||
assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900"))
|
||||
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN))
|
||||
assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN))
|
||||
}
|
||||
|
||||
@Test fun heightPresetsKeepThePanelShapeWithASixteenByNineFloor() {
|
||||
// 20:9 phone: edge to edge, no bars for the client's interface.
|
||||
assertEquals(1600 to 720, SessionDisplay.resolveChoice(2400 to 1080, SessionDisplay.DEFAULT_RESOLUTION))
|
||||
assertEquals(1608 to 720, SessionDisplay.resolveChoice(2412 to 1080, "720p"))
|
||||
// 1440p phone: bounded by the preset height, not the panel.
|
||||
assertEquals(1560 to 720, SessionDisplay.resolveChoice(3120 to 1440, "720p"))
|
||||
assertEquals(2340 to 1080, SessionDisplay.resolveChoice(3120 to 1440, "1080p"))
|
||||
// Foldable inner panel and 4:3 handhelds: never narrower than 16:9.
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(2208 to 1840, "720p"))
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "720p"))
|
||||
// Never taller than the panel.
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, "1080p"))
|
||||
}
|
||||
|
||||
@Test fun optionsListTheHeightsThePanelCanShowThenThePanel() {
|
||||
val thor = SessionDisplay.resolutionOptions(1920 to 1080)
|
||||
assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN), thor)
|
||||
assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN), SessionDisplay.resolutionOptions(2400 to 1080))
|
||||
assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN), SessionDisplay.resolutionOptions(3120 to 1440))
|
||||
val small = SessionDisplay.resolutionOptions(1280 to 720)
|
||||
assertEquals(listOf(SessionDisplay.MATCH_SCREEN), small)
|
||||
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN))
|
||||
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
|
||||
}
|
||||
|
||||
@Test fun resolutionCapsPreserveTheSelectedShapeWithoutExceedingPanelHeight() {
|
||||
assertEquals(1280 to 720, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_AUTO))
|
||||
assertEquals(960 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_EXACT))
|
||||
assertEquals(1280 to 720, SessionDisplay.resolve(1440 to 1080, 720, SessionPrefs.SHAPE_AUTO))
|
||||
assertEquals(1280 to 720, SessionDisplay.resolve(1280 to 720, 1080, SessionPrefs.SHAPE_AUTO))
|
||||
}
|
||||
|
||||
@Test fun noCapUsesPanelHeightWhileCustomOverridesBothCapAndAspect() {
|
||||
assertEquals(1440 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_EXACT))
|
||||
assertEquals(1920 to 1080, SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE))
|
||||
assertEquals(1024 to 768, SessionDisplay.resolve(1920 to 1080, 720, SessionPrefs.SHAPE_WIDE, 1024 to 768))
|
||||
}
|
||||
|
||||
@Test fun orientationAndOddDimensionsKeepTheExistingEvenLandscapeDisplay() {
|
||||
assertEquals(1920 to 1080, SessionDisplay.resolve(1081 to 1921, 0, SessionPrefs.SHAPE_EXACT))
|
||||
assertEquals(1600 to 720, SessionDisplay.resolve(2400 to 1080, 720, SessionPrefs.SHAPE_AUTO))
|
||||
}
|
||||
|
||||
@Test fun gameStretchAvailabilityMatchesTheRuntimeIncludingRounding() {
|
||||
assertTrue(SessionDisplay.canStretch16x9(960 to 720))
|
||||
assertFalse(SessionDisplay.canStretch16x9(1280 to 720))
|
||||
assertFalse(SessionDisplay.canStretch16x9(1600 to 720))
|
||||
val wide = SessionDisplay.resolve(1440 to 1080, 0, SessionPrefs.SHAPE_WIDE)
|
||||
assertFalse(SessionDisplay.canStretch16x9(wide))
|
||||
}
|
||||
|
||||
@Test fun removedFilterAliasesRetainTheirEquivalentBehavior() {
|
||||
assertEquals(0, SessionPrefs.canonicalUpscaler(1))
|
||||
assertEquals(4, SessionPrefs.canonicalUpscaler(5))
|
||||
assertEquals(0, SessionPrefs.canonicalUpscaler(99))
|
||||
assertFalse(SessionPrefs.upscalerHasSharpness(0))
|
||||
assertFalse(SessionPrefs.upscalerHasSharpness(1))
|
||||
assertFalse(SessionPrefs.upscalerHasSharpness(2))
|
||||
for (mode in 3..8) assertTrue(SessionPrefs.upscalerHasSharpness(mode))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class SessionLogShareTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private val secret = "GET /x?sessionid=abcdef123456 200"
|
||||
private val leaked = "abcdef123456"
|
||||
|
||||
private fun zip(folder: File): Map<String, String> =
|
||||
ZipFile(SessionLogShare.zipFolder(RuntimeEnvironment.getApplication(), folder)!!).use { z ->
|
||||
z.entries().toList().associate { it.name.substringAfter('/') to z.getInputStream(it).bufferedReader().readText() }
|
||||
}
|
||||
|
||||
/** A record as the session's ending writes it, listing [files] as they stand now. */
|
||||
private fun record(folder: File, vararg files: String, rules: Int = LogRedactor.RULES_VERSION) {
|
||||
File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).writeText(
|
||||
"rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" })
|
||||
}
|
||||
|
||||
@Test fun aRecordedUnchangedFileGoesInAsItIs() {
|
||||
val folder = tmp.newFolder("2026-10-06-01-steam")
|
||||
// Stands in for a file the ending scrubbed; the share must not pass it through again.
|
||||
File(folder, "session.log").writeText("$secret\n")
|
||||
record(folder, "session.log")
|
||||
assertTrue(zip(folder).getValue("session.log").contains(leaked))
|
||||
}
|
||||
|
||||
@Test fun anythingTheRecordDoesNotVouchForIsScrubbed() {
|
||||
val folder = tmp.newFolder("2026-10-06-02-steam")
|
||||
File(folder, "app.log").writeText("ok\n")
|
||||
File(folder, "session.log").writeText("ok\n")
|
||||
File(folder, "steam/deep").mkdirs()
|
||||
File(folder, "steam/deep/nested.txt").writeText("$secret\n")
|
||||
File(folder, ".hidden.txt").writeText("$secret\n")
|
||||
record(folder, "app.log", "session.log")
|
||||
// Appended to after the ending (the app's log capture), and touched with an older time.
|
||||
File(folder, "app.log").appendText("$secret\n")
|
||||
File(folder, "session.log").apply { writeText("$secret\n".padEnd(3, ' ')); setLastModified(1_000L) }
|
||||
File(folder, "events.jsonl").writeText("$secret\n")
|
||||
|
||||
val out = zip(folder)
|
||||
for (name in listOf("app.log", "session.log", "steam/deep/nested.txt", ".hidden.txt", "events.jsonl"))
|
||||
assertFalse(name, out.getValue(name).contains(leaked))
|
||||
}
|
||||
|
||||
@Test fun aRecordUnderOtherRedactorRulesVouchesForNothing() {
|
||||
val folder = tmp.newFolder("2026-10-06-03-steam")
|
||||
File(folder, "session.log").writeText("$secret\n")
|
||||
record(folder, "session.log", rules = LogRedactor.RULES_VERSION - 1)
|
||||
assertFalse(zip(folder).getValue("session.log").contains(leaked))
|
||||
}
|
||||
|
||||
@Test fun withoutARecordEveryFileIsScrubbed() {
|
||||
val folder = tmp.newFolder("2026-10-06-04-steam")
|
||||
File(folder, "session.log").writeText("$secret\n")
|
||||
File(folder, "steam").mkdirs()
|
||||
File(folder, "steam/console_log.txt").writeText("$secret\n")
|
||||
val out = zip(folder)
|
||||
assertFalse(out.getValue("session.log").contains(leaked))
|
||||
assertFalse(out.getValue("steam/console_log.txt").contains(leaked))
|
||||
}
|
||||
|
||||
@Test fun theEndingScrubsEveryFolderAndRecordsWhatItScrubbed() {
|
||||
val folder = tmp.newFolder("2026-10-06-05-steam")
|
||||
File(folder, "session.log").writeText("$secret\n")
|
||||
File(folder, "droiddeck-esync").mkdirs()
|
||||
File(folder, "droiddeck-esync/launches.log").writeText("$secret\n")
|
||||
File(folder, "steam/deep").mkdirs()
|
||||
File(folder, "steam/deep/nested.txt").writeText("$secret\n")
|
||||
|
||||
SessionArtifacts.scrubAndMark(folder)
|
||||
|
||||
for (name in listOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"))
|
||||
assertFalse(name, File(folder, name).readText().contains(leaked))
|
||||
assertEquals(setOf("session.log", "droiddeck-esync/launches.log", "steam/deep/nested.txt"),
|
||||
SessionArtifacts.scrubbedFiles(folder))
|
||||
assertTrue(zip(folder).values.none { it.contains(leaked) })
|
||||
}
|
||||
|
||||
@Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() {
|
||||
val folder = tmp.newFolder("2026-10-06-06-steam")
|
||||
File(folder, "session.log").writeText("$secret\n")
|
||||
// A folder the scrubbed copy cannot be written into: the scrub fails, the original stays.
|
||||
folder.setWritable(false)
|
||||
try {
|
||||
SessionArtifacts.scrubAndMark(folder)
|
||||
} finally {
|
||||
folder.setWritable(true)
|
||||
}
|
||||
assertTrue(File(folder, "session.log").readText().contains(leaked))
|
||||
assertFalse(File(folder, SessionArtifacts.SCRUBBED_TREE_MARKER).exists())
|
||||
assertTrue(SessionArtifacts.scrubbedFiles(folder).isEmpty())
|
||||
assertTrue(zip(folder).values.none { it.contains(leaked) })
|
||||
}
|
||||
|
||||
@Test fun steamLogsPastTheSizeLimitStayOut() {
|
||||
val folder = tmp.newFolder("2026-10-06-07-steam")
|
||||
File(folder, "session.log").writeText("ok\n")
|
||||
File(folder, "steam").mkdirs()
|
||||
File(folder, "steam/console_log.txt").writeText("ok\n")
|
||||
File(folder, "steam/cef_log.previous.txt").outputStream().use { out ->
|
||||
val line = ByteArray(1024) { 'x'.code.toByte() }.also { it[it.size - 1] = '\n'.code.toByte() }
|
||||
repeat(9 * 1024) { out.write(line) }
|
||||
}
|
||||
val names = zip(folder).keys
|
||||
assertTrue("steam/console_log.txt" in names)
|
||||
assertFalse("steam/cef_log.previous.txt" in names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class SessionScalingPrefsTest {
|
||||
@Before fun clearSettings() {
|
||||
RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun explicitLegacyAspectAndCapKeepTheirSizeUntilAResolutionIsPicked() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("shape", SessionPrefs.SHAPE_EXACT).putInt("resolutionCap.steam", 900).commit()
|
||||
assertEquals("1200x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", "1600x900")
|
||||
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).commit()
|
||||
assertEquals("1600x900", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
|
||||
assertTrue(SessionPrefs.resolutionChosen(context, "steam"))
|
||||
}
|
||||
|
||||
@Test fun matchScreenOverridesLegacyCustomAndAspectWithoutAffectingTheOtherMode() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("shape", SessionPrefs.SHAPE_WIDE).putString("customRes.steam", "1024x768")
|
||||
.putString("customRes.lxqt", "800x600").commit()
|
||||
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", SessionDisplay.MATCH_SCREEN)
|
||||
val choice = SessionPrefs.resolutionChoice(context, "steam", 1440 to 1080)
|
||||
assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1440 to 1080, choice))
|
||||
assertEquals("800x600", SessionPrefs.resolutionChoice(context, "lxqt", 1440 to 1080))
|
||||
}
|
||||
|
||||
@Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertEquals(SessionDisplay.DEFAULT_RESOLUTION, SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", "900p")
|
||||
assertEquals("900p", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
|
||||
SessionPrefs.setResolutionChoice(context, "steam", "1025×769")
|
||||
assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
|
||||
}
|
||||
|
||||
@Test fun legacySavedFiltersStillResolveToAVisibleEquivalentChoice() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putInt("upscaler", 1).putInt("upscaleSharpness", 65).commit()
|
||||
assertEquals(0, SessionPrefs.upscaler(context))
|
||||
prefs.edit().putInt("upscaler", 5).commit()
|
||||
assertEquals(4, SessionPrefs.upscaler(context))
|
||||
assertEquals(65, SessionPrefs.upscaleSharpness(context))
|
||||
SessionPrefs.setUpscaler(context, 5)
|
||||
assertEquals(4, prefs.getInt("upscaler", -1))
|
||||
SessionPrefs.setUpscaler(context, 1)
|
||||
assertEquals(0, prefs.getInt("upscaler", -1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class WinComponentNamesTest {
|
||||
@Test fun catalogKeysReadAsPeopleKnowThem() {
|
||||
val names = mapOf(
|
||||
"oalinst_dll" to "OpenAL", "vcredist2010_dll" to "Visual C++ 2010", "vcredist2022" to "Visual C++ 2015-2022",
|
||||
"dotnet48" to ".NET Framework 4.8", "dotnet452" to ".NET Framework 4.5.2", "dotnet20sp1" to ".NET Framework 2.0 SP1",
|
||||
"d3dcompiler_47" to "D3DCompiler 47", "xaudio2.7" to "XAudio 2.7", "mono-10.4.1" to "Wine Mono 10.4.1",
|
||||
"msxml6" to "MSXML 6", "dmsynth" to "DirectMusic (dmsynth)", "XLiveRedist" to "Games for Windows Live",
|
||||
"something-new" to "something-new",
|
||||
)
|
||||
for ((id, name) in names) assertEquals(id, name, WinComponentNames.of(id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.session.WinComponents.Support
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class WinComponentsSupportTest {
|
||||
private val base = "https://github.com/The412Banner/winlator-contents/releases/download/system-libraries-v1"
|
||||
|
||||
private fun step(action: String, vararg pairs: Pair<String, String>) =
|
||||
WinComponents.Step(action, JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } })
|
||||
|
||||
private fun component(name: String, vararg steps: WinComponents.Step) =
|
||||
WinComponents.Component(name, "", "", "ready", emptyList(), steps.toList())
|
||||
|
||||
private fun support(c: WinComponents.Component) = WinComponents.support(c, mapOf(c.name to c))
|
||||
|
||||
@Test fun windowsInstallerPackagesInstallHere() {
|
||||
assertEquals(Support.READY, support(component("xna40", step("install_msi", "url" to "$base/xna40.msi"))))
|
||||
// The catalog names some .msi packages install_exe.
|
||||
assertEquals(Support.READY, support(component("XLiveRedist",
|
||||
step("install_exe", "url" to "$base/XLiveRedist.msi", "file_name" to "XLiveRedist1.2.0238.msi"))))
|
||||
assertEquals(Support.READY, support(component("msxml3", step("delete_dlls", "dest" to "win32"),
|
||||
step("override_dll", "dll" to "msxml3", "type" to "native"), step("install_msi", "url" to "$base/msxml3.msi"))))
|
||||
assertEquals(Support.READY, support(component("powershell_core",
|
||||
step("install_msi", "url" to "$base/ps-x86.msi"), step("install_msi", "url" to "$base/ps-x64.msi"))))
|
||||
}
|
||||
|
||||
@Test fun otherInstallersStillWait() {
|
||||
assertEquals(Support.NEEDS_INSTALLER, support(component("dotnet48", step("install_exe", "url" to "$base/ndp48.exe"))))
|
||||
// An uninstall step first (wine-mono) is not something done here.
|
||||
assertEquals(Support.NEEDS_INSTALLER, support(component("mono", step("uninstall"),
|
||||
step("install_msi", "url" to "$base/mono.msi"))))
|
||||
assertEquals(Support.NEEDS_INSTALLER, support(component("plain", step("install_msi", "url" to "http://example.com/a.msi"))))
|
||||
}
|
||||
|
||||
@Test fun whatProtonShipsIsNotOffered() {
|
||||
assertEquals(Support.UNSUPPORTED, support(component("gecko", step("install_msi", "url" to "$base/gecko.msi"))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.core.AppUiPrefs
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class AppScaleTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun clearScale() {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun defaultsToSystemDensityAndCanRestoreItAfterChoosingAnotherScale() {
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
AppUiPrefs.setScale(context, 75)
|
||||
assertEquals(75, AppUiPrefs.scale(context))
|
||||
AppUiPrefs.setScale(context, 100)
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
val density = Density(2f, 1.3f)
|
||||
val configuration = Configuration(context.resources.configuration)
|
||||
assertSame(density, scaledAppDensity(density, 100))
|
||||
assertSame(configuration, scaledAppConfiguration(configuration, 100))
|
||||
}
|
||||
|
||||
@Test fun invalidStoredOrRequestedScaleFallsBackToTheDefault() {
|
||||
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().putInt("scale", 0).commit()
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
AppUiPrefs.setScale(context, 1000)
|
||||
assertEquals(100, AppUiPrefs.scale(context))
|
||||
}
|
||||
|
||||
@Test fun scalingDoesNotModifySessionPreferencesOrAndroidDisplayMetrics() {
|
||||
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
session.edit().putString("resolution.steam", "1280x720").putInt("upscaler", 2).commit()
|
||||
val before = session.all.toMap()
|
||||
val density = context.resources.displayMetrics.density
|
||||
val configuration = Configuration(context.resources.configuration)
|
||||
AppUiPrefs.setScale(context, 150)
|
||||
scaledAppConfiguration(context.resources.configuration, AppUiPrefs.scale(context))
|
||||
assertEquals(before, session.all)
|
||||
assertEquals(density, context.resources.displayMetrics.density, 0f)
|
||||
assertEquals(configuration, context.resources.configuration)
|
||||
}
|
||||
|
||||
@Test fun textAndControlsScaleTogetherWhilePreservingAccessibilityFontScale() {
|
||||
val base = Density(2f, 1.3f)
|
||||
for (percent in AppUiPrefs.scales) {
|
||||
val scaled = scaledAppDensity(base, percent)
|
||||
assertEquals(1.3f, scaled.fontScale, 0f)
|
||||
assertEquals(base.run { 44.dp.toPx() } * percent / 100f, scaled.run { 44.dp.toPx() }, 0.001f)
|
||||
assertEquals(base.run { 16.sp.toPx() } * percent / 100f, scaled.run { 16.sp.toPx() }, 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun responsiveWidthAndPopupHeightFitTheSamePhysicalWindowAtEveryScale() {
|
||||
val base = Configuration().apply {
|
||||
densityDpi = 320
|
||||
screenWidthDp = 960
|
||||
screenHeightDp = 540
|
||||
smallestScreenWidthDp = 540
|
||||
fontScale = 1.3f
|
||||
orientation = Configuration.ORIENTATION_LANDSCAPE
|
||||
}
|
||||
for (percent in AppUiPrefs.scales) {
|
||||
val scaled = scaledAppConfiguration(base, percent)
|
||||
val density = scaledAppDensity(Density(2f, 1.3f), percent).density
|
||||
assertEquals(1920f, scaled.screenWidthDp * density, 2f)
|
||||
assertEquals(1080f, scaled.screenHeightDp * density, 2f)
|
||||
assertEquals(1080f, scaled.smallestScreenWidthDp * density, 2f)
|
||||
assertEquals(1.3f, scaled.fontScale, 0f)
|
||||
assertEquals(base.orientation, scaled.orientation)
|
||||
}
|
||||
assertEquals(960, base.screenWidthDp)
|
||||
assertEquals(540, base.screenHeightDp)
|
||||
assertEquals(320, base.densityDpi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPageTest {
|
||||
@Test fun singleScreenNavigationStartsWithControllerAndSkipsSecondScreen() {
|
||||
val pages = sessionDrawerPages(false)
|
||||
assertEquals(SessionDrawerPage.CONTROLLER, pages.first())
|
||||
assertFalse(SessionDrawerPage.SECOND_SCREEN in pages)
|
||||
assertEquals(SessionDrawerPage.DISPLAY, pages.step(SessionDrawerPage.CONTROLLER, 1))
|
||||
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.CONTROLLER, -1))
|
||||
assertEquals(SessionDrawerPage.CONTROLLER, pages.step(SessionDrawerPage.SESSION, 1))
|
||||
}
|
||||
|
||||
@Test fun connectedSecondaryDisplayAddsItsPageAfterControllerAndPreservesOtherPages() {
|
||||
val connected = sessionDrawerPages(true)
|
||||
assertEquals(SessionDrawerPage.SECOND_SCREEN, connected.step(SessionDrawerPage.CONTROLLER, 1))
|
||||
assertEquals(SessionDrawerPage.DISPLAY, connected.step(SessionDrawerPage.SECOND_SCREEN, 1))
|
||||
assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN })
|
||||
for (pages in listOf(connected, sessionDrawerPages(false))) {
|
||||
assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1))
|
||||
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1))
|
||||
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
Thor SD storage investigation: selected saved evidence
|
||||
Recorded 2026-10-04; event timestamps are EDT.
|
||||
These are excerpts, not complete logs or a continuous attribution profile.
|
||||
See ../thor-sd-storage-investigation.md for context and live-only observations.
|
||||
|
||||
=== Reporter unsupported allocation ===
|
||||
Source: Reporter diagnostic storage.log
|
||||
volume_removable=true
|
||||
filesystem=fuse
|
||||
allocation pid=29062 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=374272 elapsed_us=212
|
||||
|
||||
=== Thor unsupported allocation ===
|
||||
Source: candidate-storage.log
|
||||
volume_removable=true
|
||||
filesystem=fuse
|
||||
allocation pid=9810 comm=steam api=fallocate result=-1 api_error=95 errno_after=95 flags=0x0 offset=0 length=650752 elapsed_us=310
|
||||
|
||||
=== Native-path allocation diagnostics ===
|
||||
Source: direct-storage-completed.log / session 64
|
||||
DroidDeck storage diagnostics v1
|
||||
selected_library=secondary
|
||||
volume_removable=true
|
||||
filesystem=exfat
|
||||
truncate_allocation_probe=passed
|
||||
block_size_bytes=131072
|
||||
total_bytes=393855500288
|
||||
available_bytes=101076697088
|
||||
native_observation=enabled
|
||||
public_symbol_interposition=enabled
|
||||
direct_syscalls_and_libc_internal_calls=not_observed
|
||||
pwrite_timing=sampled_1_in_32
|
||||
per_process_allocation_detail_limit=64
|
||||
storage_log_limit_bytes=524288
|
||||
summary pid=26725 comm=python3 allocation_calls=0 allocation_failures=0 allocation_total_us=0 pwrite_sample_every=32 pwrite_samples=0 pwrite_bytes=0 pwrite_failures=0 pwrite_sample_total_us=0 pwrite_sample_max_us=0 ftruncate_calls=0 ftruncate_failures=0 ftruncate_total_us=0 ftruncate_max_us=0 fstat_calls=1 fstat_total_us=0 fstat_max_us=0 fsync_calls=0 fsync_failures=0 fsync_total_us=0 fsync_max_us=0
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=667648 elapsed_us=387
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=39 elapsed_us=32
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=156 elapsed_us=25
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=457428 elapsed_us=351
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=901928 elapsed_us=864
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=15041920 elapsed_us=15292
|
||||
allocation pid=26825 comm=steam api=fallocate_ftruncate result=0 api_error=0 errno_after=0 flags=0x0 offset=0 length=337563 elapsed_us=2783
|
||||
|
||||
=== Fresh real Steam install ===
|
||||
Source: direct-content-completed.log / session 64
|
||||
[2026-10-04 17:31:20] AppID 1675830 App update changed : Running Update,Preallocating,
|
||||
[2026-10-04 17:38:04] AppID 1675830 preallocated 2216 files (7626 MB)
|
||||
[2026-10-04 17:38:06] AppID 1675830 App update changed : Running Update,Downloading,Staging,
|
||||
[2026-10-04 17:45:25] AppID 1675830 App update changed : Running Update,Committing,
|
||||
[2026-10-04 17:45:25] AppID 1675830 starting commit from "/mnt/droiddeck-sd/steamapps/downloading/1675830" to "/mnt/droiddeck-sd/steamapps/common/1000xRESIST" : 2230 updated, 0 moved, 0 deleted files
|
||||
[2026-10-04 17:47:55] AppID 1675830 state changed : Fully Installed,Update Queued,Update Running,
|
||||
[2026-10-04 17:47:55] AppID 1675830 finished update, 1 mounted depots (BuildID 21498291) : 1675831 (3663189783241284849),
|
||||
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,Update Queued,
|
||||
[2026-10-04 17:47:56] AppID 1675830 state changed : Fully Installed,
|
||||
[2026-10-04 17:47:56] AppID 1675830 scheduler finished : removed from schedule (result No Error, state 0xc)
|
||||
|
||||
=== Normal-app native/FUSE directory read ===
|
||||
Source: app-raw-probe-session.log / session 62
|
||||
APP_RAW_PROBE uid=10195 path=/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.002288 calls=2 bytes=154920 read_error=0
|
||||
APP_RAW_PROBE uid=10195 path=/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam/steamapps/downloading/1675830-fuse-control/1000xRESIST_Data/StreamingAssets/aa/StandaloneWindows64 seconds=0.501306 calls=112 bytes=154872 read_error=0
|
||||
|
||||
=== Binding regression: bind-probe-before.log ===
|
||||
Source: bind-probe-before.log
|
||||
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=2 pass=0 errno=2 bytes=-1 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
|
||||
BIND_PROBE index=3 pass=0 errno=2 bytes=-1 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
|
||||
|
||||
=== Binding regression: bind-probe-after.log ===
|
||||
Source: bind-probe-after.log
|
||||
BIND_PROBE index=0 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=1 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/steamlinuxruntime_4-arm64/.droiddeck-bind-probe
|
||||
BIND_PROBE index=2 pass=1 errno=0 bytes=17 path=/mnt/droiddeck-sd/steamapps/common/proton experimental (arm64)/.droiddeck-bind-probe
|
||||
BIND_PROBE index=3 pass=1 errno=0 bytes=17 path=/mnt/bannerlator-sd/steamapps/shadercache/.droiddeck-bind-probe
|
||||
|
||||
=== Root metadata/mount experiment: metadata-mount-comparison.log ===
|
||||
Source: metadata-mount-comparison.log
|
||||
original_mount
|
||||
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
|
||||
files=64 bytes_per_file=262144 open_seconds=6.707435 truncate_seconds=0.084806 wall_seconds=6.794807 error=0
|
||||
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
|
||||
mount: '/dev/block/vold/public:179,1'->'/mnt/media_rw/FF7F-F56A': Invalid argument
|
||||
|
||||
=== Root metadata/mount experiment: metadata-syscall-comparison.log ===
|
||||
Source: metadata-syscall-comparison.log
|
||||
original_mount
|
||||
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
|
||||
files=64 bytes_per_file=262144 open_seconds=25.181255 truncate_seconds=0.309577 wall_seconds=25.493210 error=0
|
||||
temporary_mount
|
||||
/dev/block/vold/public:179,1 /mnt/media_rw/FF7F-F56A exfat rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro 0 0
|
||||
files=64 bytes_per_file=262144 open_seconds=2.013194 truncate_seconds=0.095924 wall_seconds=2.112288 error=0
|
||||
|
||||
=== Post-install Android low-memory exit ===
|
||||
Source: game-launch-exit-info.txt
|
||||
ApplicationExitInfo #0:
|
||||
timestamp=2026-10-04 18:13:48.454 pid=14709 realUid=10195 packageUid=10195 definingUid=10195 user=0
|
||||
process=com.droiddeck.launcher reason=3 (LOW_MEMORY) subreason=0 (UNKNOWN) status=0
|
||||
importance=100 pss=227MB rss=344MB description=null state=empty trace=null
|
||||
|
||||
=== Combined release unit-test completion ===
|
||||
Source: combined-233-tests.log
|
||||
> Task :app:testReleaseUnitTest
|
||||
BUILD SUCCESSFUL in 10s
|
||||
|
||||
=== Combined release build completion ===
|
||||
Source: combined-233-build.log
|
||||
BUILD SUCCESSFUL in 25s
|
||||
APK: /Users/danhimebauch/.codex/worktrees/steam-sd-preallocation/DroidDeck/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -23,6 +23,11 @@ and explicit removals take precedence over these defaults. FEX's own default
|
||||
remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray
|
||||
tracing and diagnostic logging otherwise retain the runtime's defaults.
|
||||
|
||||
On an Adreno 6xx, Turnip does not expose `storageBuffer8BitAccess`, which DXVK 3
|
||||
requires. A Steam or desktop session installs DXVK 2.7.1 into any Proton whose
|
||||
DXVK is still the copy that Proton shipped, including after a Proton update put
|
||||
that copy back. A DXVK package chosen in Components stays for that Proton build.
|
||||
|
||||
The variable-name picker offers 24 predefined variables and a Custom entry. Like
|
||||
WinNative, known variables use toggles, value dropdowns, multi-select lists, or
|
||||
numeric/text fields. Feature-level and shader-model dropdowns also accept custom
|
||||
|
||||
@@ -5,6 +5,11 @@ games should keep running under proot, run directly on glibc, or a mix of the tw
|
||||
fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel
|
||||
6.12.38-android16, Android 16) over adb, unless it says otherwise.
|
||||
|
||||
For Steam SD allocation measurements on AYN Thor (Android 13), see the
|
||||
[Thor storage investigation](thor-sd-storage-investigation.md). It separates
|
||||
observed allocation-wrapper time, FUSE directory access and native exFAT metadata
|
||||
waits; it does not establish a full-install PRoot overhead percentage.
|
||||
|
||||
## Short answer
|
||||
|
||||
- **Don't drop proot.** On Android it does two jobs. It translates paths. It also answers every
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# Steam session display sizing: research and device tests
|
||||
|
||||
Started 2026-10-06. This is a working log. Findings are added as they are measured on a device, so
|
||||
it reads in order.
|
||||
|
||||
## Question
|
||||
|
||||
Should the Steam session run gamescope at the panel's own size and let gamescope scale games, the
|
||||
way SteamOS does, instead of one session resolution shared by the Steam client and every game
|
||||
(1280x720 16:9 by default) that our compositor stretches onto the panel? What does that mean on
|
||||
very high-resolution panels and on foldables?
|
||||
|
||||
## How the session is sized today (main @ c636eea3)
|
||||
|
||||
- `droiddeck-session` starts gamescope with `-W/-H` (output) = `-w/-h` (nested, what games and the
|
||||
client see) = `BL_WIDTH x BL_HEIGHT`, the per-mode "Resolution" setting. The default is
|
||||
`1280x720` (`SessionDisplay.DEFAULT_RESOLUTION`). One Xwayland server holds both the Steam
|
||||
client and the games.
|
||||
- The Android compositor (`waylandcomp`) draws gamescope's output into a panel-sized swapchain
|
||||
(2414x1080 on a 20:9 phone) and fits it with bars where the shapes differ.
|
||||
- "Force game windows fullscreen" (`--force-windows-fullscreen`, off by default since #276) makes
|
||||
every window the nested size. A game whose own resolution differs fights it, and the resize
|
||||
storm that follows piles up GPU buffers until the low-memory killer ends the session (seen
|
||||
2026-10-06 on the AYN Thor with Alan Wake's American Nightmare).
|
||||
|
||||
Why the current choices were made, from the history:
|
||||
|
||||
| Choice | Reason given | Source |
|
||||
|---|---|---|
|
||||
| 720p default | The Steam client's CEF is the heaviest thing in a session, and 720p keeps Big Picture responsive on a regular flagship | `SessionPrefs.resolutionCap` comment |
|
||||
| Never narrower than 16:9 | Games handed a near-square display (Fold inner panel) crop their own menus | `d2ea9b89` |
|
||||
| 16:9 stretch option | Games on a narrow panel letterbox themselves; stretch fills it | #63, #133 |
|
||||
| Custom resolution | Parity with Bannerlator, 4:3 and 16:10 presets | `ad2f9f8a` |
|
||||
| Force fullscreen | A game that shrinks its window on focus loss (FlatOut) comes back small in a corner | #276, `droiddeck-session` |
|
||||
|
||||
## How SteamOS-style sessions do it
|
||||
|
||||
- gamescope output at the panel's size, `--xwayland-count 2`, and `STEAM_MULTIPLE_XWAYLANDS=1`
|
||||
(ChimeraOS `gamescope-session-plus` defaults to two Xwayland servers). The client lives on
|
||||
one server and games on the other.
|
||||
- Per-game "Game Resolution" (Deck mode game properties): Steam writes
|
||||
`GAMESCOPE_XWAYLAND_MODE_CONTROL = {server, width, height, allow_super_res}` on the root window,
|
||||
and gamescope resizes that Xwayland server (`steamcompmgr.cpp`, the `gamescopeXWaylandModeControl`
|
||||
handler). gamescope then scales the game to the output with the scaler and filter chosen in the QAM.
|
||||
- `GAMESCOPE_STEAM_MAX_HEIGHT` on the root window caps only the Steam client's window height, and
|
||||
gamescope scales it up (`steamcompmgr.cpp`, the `g_nSteamMaxHeight` branch). So a native output
|
||||
need not make the client render natively.
|
||||
- Our gamescope (3.16.29-p5) contains both atoms (checked in the installed binary on the Thor). We
|
||||
use neither, and we don't pass `--xwayland-count`.
|
||||
|
||||
## Android side
|
||||
|
||||
- Hardware scaler: a surface whose buffer is smaller than the view is scaled by the display
|
||||
hardware at no GPU cost (`SurfaceHolder.setFixedSize`; Android Developers blog 2013, NVIDIA).
|
||||
Our compositor always renders a panel-sized swapchain and scales on the GPU.
|
||||
- Android 16 ignores orientation and aspect-ratio locks on large screens, except for games. The
|
||||
manifest's game category covers us.
|
||||
- `game_mode_config.xml` already refuses OEM downscaling and FPS overrides.
|
||||
|
||||
## Device tests
|
||||
|
||||
All runs on the AYN Thor (SD 8 Gen 2, Adreno 740, 1920x1080 top panel), Deck mode on, Force
|
||||
fullscreen off, with a spike build of `feat/steam-native-output`. Each run sets
|
||||
`Download/droiddeck-env` and starts the session with
|
||||
`am start -n com.droiddeck.launcher/.SessionActivity --es mode steam [--es steamUrl ...]`.
|
||||
|
||||
### Run 1: baseline (Resolution = Match screen, 1920x1080)
|
||||
|
||||
Steam UI idle: GPU 0%, gamescope 27 dma-bufs, Xwayland 39. Everything renders at 1920x1080.
|
||||
|
||||
### Run 2: client height cap (`BL_STEAM_MAX_HEIGHT=720`)
|
||||
|
||||
- Log: `== steam: the client's height capped at 720 (was unset)`. The client never wrote its own
|
||||
value during the run, so a one-time set would hold.
|
||||
- On screen: the Steam UI lays out at 1280x720 and gamescope scales it to fill 1920x1080. The
|
||||
mangoapp overlay stays at native size and sharpness, so the cap affects only the client's window.
|
||||
|
||||
### Run 3: two Xwayland servers (`--xwayland-count 2`, `STEAM_MULTIPLE_XWAYLANDS=1`), Alan Wake's American Nightmare, Force fullscreen off
|
||||
|
||||
- gamescope starts Xwayland on `:0` and `:1`. The client stays on `:0`, and the game runs on `:1`:
|
||||
the second Xwayland's dma-buf count rises when the game starts.
|
||||
- The game runs full screen at about 60 fps with one swapchain, no resize churn, and stable buffer
|
||||
counts. Nothing visibly broke in the client.
|
||||
|
||||
### Run 4: as run 3, Force fullscreen on
|
||||
|
||||
The storm still happens. The games' Xwayland went from 16 to 840 dma-bufs in about 13 s, and the
|
||||
memory guard (kills the session under 2.5 GB free) stopped it. gamescope logged no mode change for
|
||||
server `#1`, so it stayed at the output size (1920x1080) while the game draws at 1280x720.
|
||||
|
||||
### Run 5: as run 4, writing `GAMESCOPE_XWAYLAND_MODE_CONTROL = 1,1280,720,0` at session start
|
||||
|
||||
gamescope applied it (`Updating mode for xwayland server #1: 1280x720`), then the client set it
|
||||
back to `1920x1080` when it launched the game, and the storm followed. **The client manages the games'
|
||||
Xwayland size itself:** at each launch it writes the game's "Game Resolution", which defaults to
|
||||
native.
|
||||
|
||||
### Run 6: as run 5, writing the mode 5 s after the game takes focus (what a chosen Game Resolution does)
|
||||
|
||||
**No storm.** Force fullscreen on, games' Xwayland at 1280x720: 10 dma-bufs on `:1` and 41 in
|
||||
gamescope for the whole run, 9 frame-size changes in total (runs 4/5: 835 and 1591), memory
|
||||
steady, 60 fps. gamescope scales the 1280x720 game to fill 1920x1080.
|
||||
|
||||
**Conclusion so far:** the resize storm comes from Force fullscreen resizing a game window to a
|
||||
display size the game won't accept. With the games on their own Xwayland server at the game's own
|
||||
resolution, Force fullscreen is harmless. Two Xwayland servers alone don't help, because the client
|
||||
sizes the games' server to native unless the game's resolution is set.
|
||||
|
||||
### Run 7: two Xwayland servers, output 1920x1080, `-w 1280 -h 720` (games 720p), Alan Wake, Force fullscreen off
|
||||
|
||||
The client ignores gamescope's nested size. It set both servers to the output size
|
||||
(`#0: 1920x1080`, `#1: 1920x1080`). **The client's "native" game resolution is gamescope's output
|
||||
size.** A panel-sized output on a 3120x1440 phone would therefore make every game default to
|
||||
3120x1440 until a per-game resolution is chosen.
|
||||
|
||||
Side finding: gamescope passed the game's 1280x720 buffers straight through to our compositor (its
|
||||
frame size changed to 1280x720) instead of compositing at the output size. Our compositor does the
|
||||
final scale either way.
|
||||
|
||||
### Default resolution history
|
||||
|
||||
Before #247 the default was `resolutionCap = 720` with shape `auto`: the panel's own shape at 720
|
||||
lines, never narrower than 16:9 (`SessionDisplay.resolve`). On a 2412x1080 phone that is 1608x720,
|
||||
which is exactly the "custom" size a tester had been using. #247 replaced it with fixed 16:9
|
||||
presets (`1280x720` default, `1600x900`, `1920x1080`). Those draw the client with bars on any panel
|
||||
wider than 16:9, which is the black border the tester reported after #247.
|
||||
|
||||
## Conclusions
|
||||
|
||||
1. **The output size sets the default game resolution** (run 7), so it must stay bounded on
|
||||
very high-resolution panels. A native output is only safe on panels about 1080 lines or shorter.
|
||||
2. **The output should keep the panel's shape**, so the client's interface is edge to edge, with
|
||||
the 16:9 floor from `d2ea9b89` so a foldable's inner panel doesn't make games crop themselves.
|
||||
3. **The resize storm is Force fullscreen against a display size the game won't accept** (runs
|
||||
4 to 6). The SteamOS model fixes it: two Xwayland servers, with the games' server at the game's
|
||||
resolution. That needs a per-game Game Resolution, so it is a follow-up, not a default.
|
||||
4. `GAMESCOPE_STEAM_MAX_HEIGHT` caps only the client and works on our gamescope (run 2). It
|
||||
becomes useful when the output is larger than the client should render, so it belongs with
|
||||
any future native-output option.
|
||||
|
||||
## Change made (this branch)
|
||||
|
||||
The resolution presets are heights: **720p (default), 900p, 1080p**, each in the panel's shape,
|
||||
never narrower than 16:9 and never taller than the panel. Then Match screen (exact panel), then
|
||||
Custom. Saved fixed sizes, custom sizes and Match screen are unchanged. Only the default and the
|
||||
preset list change. Resolved sizes:
|
||||
|
||||
| Panel | 720p | 900p | 1080p |
|
||||
|---|---|---|---|
|
||||
| 1920x1080 (Thor, 16:9) | 1280x720 | 1600x900 | Match screen |
|
||||
| 2412x1080 (20:9 phone) | 1608x720 | 2010x900 | Match screen |
|
||||
| 3120x1440 (1440p phone) | 1560x720 | 1950x900 | 2340x1080 |
|
||||
| 2208x1840 (foldable inner) | 1280x720 | 1600x900 | 1920x1080 |
|
||||
|
||||
### Run 9: default on the Thor
|
||||
|
||||
With no saved resolution (the saved choice, legacy shape and cap removed from `session.xml`, then
|
||||
restored): the session starts at `size=1280x720` and the device report shows `720p (1280x720)`.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Two Xwayland servers and per-game Game Resolution** (done, see below). Was to verify: whether the client shows
|
||||
Game Resolution under game Properties in our session, the Android clipboard bridge for games on
|
||||
`:1`, and the drawer's live Force fullscreen toggle (it writes `GAMESCOPE_FORCE_WINDOWS_FULLSCREEN`
|
||||
only on `:0`'s root, and gamescope reads it per server). The client's devtools (port 8080 when
|
||||
`.cef-enable-remote-debugging` exists) accept guest processes only, so test it from inside the
|
||||
session.
|
||||
- **Hardware scaler.** The compositor draws a panel-sized swapchain and scales on the GPU.
|
||||
`setFixedSize` to the session size, when no compositor effect is on, would hand that scale to
|
||||
the display hardware.
|
||||
- **Force fullscreen per game**, the way Quake-engine titles are handled (`cbcd3f80`), instead of a
|
||||
global switch.
|
||||
|
||||
### How runs 2 to 6 were driven
|
||||
|
||||
Two temporary session-script hooks, not kept on this branch: `BL_STEAM_MAX_HEIGHT=N` wrote
|
||||
`GAMESCOPE_STEAM_MAX_HEIGHT` on the root window, and `BL_GAME_XWAYLAND_MODE=WxH` wrote
|
||||
`GAMESCOPE_XWAYLAND_MODE_CONTROL = 1,W,H,0` five seconds after `GAMESCOPE_FOCUSED_APP` showed a
|
||||
game. `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1` went through `Download/droiddeck-env`.
|
||||
|
||||
## Two Xwayland servers by default (`feat/steam-dual-xwayland`)
|
||||
|
||||
Steam sessions now start gamescope with `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1`.
|
||||
`BL_XWAYLAND_COUNT=1` in `Download/droiddeck-env` goes back to one. The session script's
|
||||
root-window requests that are meant for the game (the drawer's live Force fullscreen,
|
||||
`GAMESCOPE_FORCE_WINDOWS_FULLSCREEN`, and the resume watcher's `GAMESCOPE_RESTORE_FOCUS_WINDOW`)
|
||||
go to both displays, `$DISPLAY` and `STEAM_GAME_DISPLAY_0`. gamescope reads both per server, so
|
||||
writing only to `:0` would have reached the Steam client and not the game.
|
||||
|
||||
Clipboard: gamescope copies a selection made on any of its Xwayland servers to all of them
|
||||
(`gamescope_set_selection`), so the Android text `droiddeck-clipboard` puts on `:0` reaches games
|
||||
on `:1` without changes here.
|
||||
|
||||
Device runs (Thor, no `droiddeck-env`):
|
||||
|
||||
| Run | Game | Game Resolution | Force fullscreen | Result |
|
||||
|---|---|---|---|---|
|
||||
| d1 | Alan Wake's American Nightmare | 1280x720 (set in Steam) | on | The client set `#1` to 1280x720 itself. 2 frame-size changes, `:1` steady at 10 dma-bufs, memory steady. No storm |
|
||||
| d2 | Alan Wake's American Nightmare | 1280x720 | on → off → on, live | `stretch games to fill the screen: 0 (live)` then `1`, written to both displays. Game stable |
|
||||
| d5 | 198X | default | off | The client set `#1` to native 1920x1080. Full screen, 60 fps |
|
||||
|
||||
Steam saves the per-game choice as `"ResolutionOverride2" "1280x720"` under the app in
|
||||
`userdata/<id>/config/localconfig.vdf`.
|
||||
|
||||
Checked by hand (2026-10-06, Thor, Force fullscreen off):
|
||||
- **FlatOut 2**, the game Force fullscreen was added for, does not shrink when the QAM opens over
|
||||
it and closes again. With the client's overlay on another X server, the game keeps its own X
|
||||
keyboard focus and never sees the deactivation that made it shrink.
|
||||
- Resume after the Steam menu: the resume watcher's restore request reaches both servers
|
||||
(`restore requested for focus window` logged once for the client's window and once for the
|
||||
game's).
|
||||
|
||||
Still to check by hand: pasting Android text into a game.
|
||||
|
||||
Force fullscreen is still unsafe without a matching Game Resolution: with the default (native),
|
||||
a game drawing at another size fights it as in run 4. It stays off by default.
|
||||
|
||||
## Force fullscreen removed (`feat/remove-force-fullscreen`)
|
||||
|
||||
With games on their own Xwayland server, FlatOut 2 no longer shrinks under the QAM, which was the
|
||||
reason "Force game windows fullscreen" existed. What it still did was make any game whose Game
|
||||
Resolution differs from what it draws fight it (runs 4 and 5). The switch is gone from Steam
|
||||
settings (Games) and the session drawer (Display), along with its preference, the
|
||||
`BL_GAMESCOPE_FORCE_FULLSCREEN` flag, the `~/.droiddeck-fill` live watcher and the device report
|
||||
line. `BL_GAMESCOPE_ARGS=--force-windows-fullscreen` in `Download/droiddeck-env` still passes the
|
||||
flag for an experiment.
|
||||
|
||||
Device check (Thor): Alan Wake's American Nightmare with its 1280x720 Game Resolution runs on `:1`
|
||||
at 1280x720 with no `--force-windows-fullscreen` in the session, 2 frame-size changes, no crash
|
||||
in logcat. The Games tab and the drawer render without the switch.
|
||||
@@ -0,0 +1,379 @@
|
||||
# Steam SD allocation on Thor
|
||||
|
||||
Investigation recorded on 2026-10-04. Device measurements and PR states below are a
|
||||
snapshot of that day's work. All event times are EDT (UTC−04:00).
|
||||
|
||||
A fresh 1000xRESIST install on the Thor SD card completed with the native-path and
|
||||
allocation-fallback patches: 2,216 files preallocated in **6m44s**, followed by a
|
||||
successful download and commit, **16m36s total**. The observed allocation wrappers
|
||||
reported zero failures and roughly 7.1 seconds of aggregate execution time.
|
||||
That wrapper measurement does **not** account for the entire preallocation phase.
|
||||
|
||||
Native directory access was substantially faster than Android's FUSE view in a
|
||||
small controlled probe. Kernel samples during the remaining delay showed exFAT
|
||||
file-creation and size-update waits. This supports further investigation of SD
|
||||
metadata writes. It does not establish that PRoot is free, that `dirsync` alone
|
||||
causes the delay, or that SD now matches internal-storage install speed.
|
||||
|
||||
Selected source-log excerpts are preserved in
|
||||
[the evidence file](evidence/thor-sd-storage-2026-10-04.txt). The original full logs
|
||||
contain unrelated sessions and are not needed to interpret those excerpts.
|
||||
|
||||
## Device and storage
|
||||
|
||||
| Item | Observed configuration |
|
||||
|---|---|
|
||||
| Device | AYN Thor, Android 13, kernel 5.15.123, 12 GB RAM and 8 GB swap |
|
||||
| App | Production-signed `com.droiddeck.launcher`, normal app UID 10195 |
|
||||
| Configured SD library | `/storage/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
|
||||
| Native view of the same library | `/mnt/media_rw/FF7F-F56A/Android/data/com.droiddeck.launcher/files/steam` |
|
||||
| Guest content aliases | `/mnt/droiddeck-sd` and legacy `/mnt/bannerlator-sd` |
|
||||
| SD filesystem | Native exFAT; its Android `/storage` view reports FUSE |
|
||||
| Native filesystem magic | `0x2011BAB0`, matching the NDK Linux header |
|
||||
| Internal comparison location | App-private cache under `/data` |
|
||||
|
||||
The native SD mount was observed with these options:
|
||||
|
||||
```text
|
||||
rw,dirsync,nosuid,nodev,noexec,noatime,uid=1023,gid=1023,fmask=0007,dmask=0007,allow_utime=0020,iocharset=utf8,errors=remount-ro
|
||||
```
|
||||
|
||||
The device already had root and SELinux Permissive. Neither state was introduced
|
||||
by this investigation. Root was used for inspection and temporary experiments.
|
||||
The validated Steam session ran with the app's normal UID and existing groups,
|
||||
without a root launcher or an app-specific Magisk root grant.
|
||||
|
||||
This distinction does not prove the native path works on an unrooted device with
|
||||
enforcing SELinux. The production change tests access in the app and retains the
|
||||
configured path when verification fails.
|
||||
|
||||
## Initial report and allocation failure
|
||||
|
||||
The [linked Discord report](https://discord.com/channels/1552707793247539220/1555632840388116491/1556271955076710560)
|
||||
included a newer diagnostic ZIP with `storage.log`. Unlike the earlier capture
|
||||
that lacked allocation instrumentation, this capture established:
|
||||
|
||||
- The selected secondary library was removable and its exposed filesystem was
|
||||
FUSE.
|
||||
- Steam called mode-zero `fallocate` and received `EOPNOTSUPP` (errno 95).
|
||||
- That observed failure returned in 212 microseconds. It was not itself a
|
||||
multi-minute blocked call.
|
||||
|
||||
A Thor baseline capture likewise showed `fallocate` failing with errno 95 in
|
||||
310 microseconds. These establish an unsupported allocation API on the tested
|
||||
paths. They do not attribute the later Steam delay to any particular fallback.
|
||||
|
||||
The suggestion that glibc's `posix_fallocate` emulation accounts for the whole
|
||||
hang was not established by those logs. The tested Steam calls included
|
||||
`fallocate`; directory operations and other calls occur outside that wrapper.
|
||||
|
||||
## Published patches and the tested build
|
||||
|
||||
The main base was `26cb9e2f038cdbab4cdc8670d2f64c18d1d90875`. These heads were
|
||||
checked again while preparing this report; all three storage PRs were open with
|
||||
successful Build APK checks and clean merge states. This is a dated snapshot,
|
||||
not a statement about their future merge status.
|
||||
|
||||
| PR | Tested head | Behavior |
|
||||
|---|---|---|
|
||||
| [#221](https://github.com/Droid-Deck/DroidDeck/pull/221) | `7962c19283bfb0f18a5c7672e08a879814a3d5a1` | Verify a native SD content alias and handle safe case aliases in the path fast path |
|
||||
| [#229](https://github.com/Droid-Deck/DroidDeck/pull/229) | `4628ae4c92cc5813013537165a0e95886e6de904` | Use a verified truncate fallback for unsupported selected-library allocation |
|
||||
| [#233](https://github.com/Droid-Deck/DroidDeck/pull/233) | `5c6765233ae5405d7bd4c1b29f20c9e8343a1fed` | Preserve the complete fast-path binding table, including later private overrides |
|
||||
| [#225](https://github.com/Droid-Deck/DroidDeck/pull/225) | `0b5908d6d63080f625b51bab3439e2e68466631e` | Repair repeated failed Steam client starts; reviewed separately, excluded from this storage build |
|
||||
|
||||
### Native content path and case handling: #221
|
||||
|
||||
For a configured `/storage/...` library, the launcher checks the corresponding
|
||||
`/mnt/media_rw/...` candidate. It requires usable directory permissions and
|
||||
performs a write/read/delete identity probe through both views. Failure keeps
|
||||
the original configured path.
|
||||
|
||||
Only the shared SD content bindings change. The configured path remains the
|
||||
library's identity for private prefixes, migrations and other private storage.
|
||||
This avoids moving existing saves or runtime state when selecting a faster view.
|
||||
Removable-volume detection also uses the original configured path because
|
||||
Android's volume API need not recognize the native alias.
|
||||
|
||||
The C fast path permits case-only canonical-parent differences on known
|
||||
FUSE/FAT/exFAT filesystems only after checking directory components without
|
||||
following symlinks. It also handles the supported `F_OK` plus
|
||||
`AT_SYMLINK_NOFOLLOW` case. Ambiguous paths still fall back to PRoot.
|
||||
|
||||
The final #221 diff was four files, 98 insertions and 20 deletions, including
|
||||
28 new test lines. The experimental directory cache was removed from that diff.
|
||||
|
||||
### Allocation fallback: #229
|
||||
|
||||
The launcher first creates a temporary 1 MiB file on the selected content path,
|
||||
extends it, and checks that `st_blocks` reports at least 2,048 512-byte blocks.
|
||||
The file must also be cleaned up successfully. The fallback is enabled only for
|
||||
a removable library that passes this reservation probe.
|
||||
|
||||
For file descriptors on the selected library device, the wrapper tries the real
|
||||
allocation API. On mode-zero `fallocate` returning `EOPNOTSUPP`, it extends the
|
||||
file with `ftruncate`. It validates the range, detects overflow and avoids
|
||||
shrinking an existing file. Other modes and other errors retain their normal
|
||||
behavior. The POSIX wrapper tries kernel allocation first to avoid unnecessary
|
||||
libc emulation and preserves POSIX error-return and `errno` semantics.
|
||||
|
||||
This is a capability-tested fallback, not unconditional successful allocation
|
||||
reporting. A failed extension still returns an error. The optimization operates
|
||||
independently of the optional diagnostic toggle.
|
||||
|
||||
The reservation probe and one successful full install do not replace separate
|
||||
future tests for ENOSPC, interrupted installs or other devices/filesystems.
|
||||
|
||||
### Complete binding table: #233
|
||||
|
||||
The real Steam session had **96 bindings**. The old C limit of 64 silently
|
||||
dropped later mappings. Several of those were private overrides inside the
|
||||
shared SD root, so truncation could send an operation to the wrong location or
|
||||
return `ENOENT`.
|
||||
|
||||
#233 raises capacity to 256 and makes both the launcher and C implementation
|
||||
disable the fast path if a complete table cannot be represented. Overlong paths
|
||||
also cause fallback rather than partial mapping. This is a correctness fix as
|
||||
well as a requirement for trusting the storage fast path.
|
||||
|
||||
Marker files existed only in private directories. A normal app-process probe
|
||||
read them through these guest paths:
|
||||
|
||||
| Guest path under the library | Before #233 | After #233 |
|
||||
|---|---|---|
|
||||
| `SteamLinuxRuntime_4-arm64` | Correct private marker | Correct private marker |
|
||||
| `steamlinuxruntime_4-arm64` | Correct private marker | Correct private marker |
|
||||
| `proton experimental (arm64)` | `ENOENT` | Correct private marker |
|
||||
| Legacy `steamapps/shadercache` | `ENOENT` | Correct private marker |
|
||||
|
||||
Each successful read returned the 17-byte `private-override\n` marker. The
|
||||
temporary markers and helper executable were removed afterward.
|
||||
|
||||
### Deployment and checks
|
||||
|
||||
The full fresh install used #221 plus #229. #233 was then integrated locally,
|
||||
tested, built and installed for the binding regression and game-launch check.
|
||||
It was not merged on GitHub during this investigation.
|
||||
|
||||
The combined integration commit was
|
||||
`f5d3e93b8eb8912ca3834f7447a6498c95250177`. The production-signed APK was installed
|
||||
in place with `adb install -r`; its SHA-256 was:
|
||||
|
||||
```text
|
||||
099a0d7e76b9462b3b238c6f1abeb03570ec9708ace3092adf178ec2bee63e2a
|
||||
```
|
||||
|
||||
`tools/build_local.sh` completed for the combined build. The release unit tests
|
||||
for `SecondaryLibraryTest` and `ProotFastPathTest` passed. The latter covers
|
||||
96/256 bindings, exceeding capacity, and overlong paths. #225 was reviewed for
|
||||
scope; its tests were not run as part of this storage validation.
|
||||
|
||||
## Fresh real Steam install
|
||||
|
||||
Game: **1000xRESIST**, app 1675830, depot 1675831, BuildID 21498291. Steam reported
|
||||
6,582,351,824 download bytes and 7,997,085,707 staging bytes, approximately 8 GB.
|
||||
|
||||
| Event | Time | Evidence |
|
||||
|---|---|---|
|
||||
| Fresh preallocation began | 17:31:20 | Steam entered `Preallocating` |
|
||||
| Preallocation completed | 17:38:04 | 2,216 files, 7,626 MB in Steam's log |
|
||||
| Download/staging began | 17:38:06 | `Downloading,Staging` |
|
||||
| Commit began | 17:45:25 | 2,230 updated files |
|
||||
| Fully installed | 17:47:55 | `Fully Installed`, BuildID 21498291 |
|
||||
| Scheduler completed | 17:47:56 | `No Error` |
|
||||
|
||||
Preallocation took **404 seconds**. Start to scheduler completion took
|
||||
**996 seconds**. Live counters showed 2,216 allocation calls, zero allocation
|
||||
failures and about **7.1 seconds aggregate allocation-wrapper time**. The saved
|
||||
diagnostic detail log is capped at the first 64 allocation details per process;
|
||||
that file alone cannot reproduce the live aggregate counter observation.
|
||||
|
||||
The final manifest had `StateFlags=4`, `UpdateResult=0`, all expected download
|
||||
and staging bytes completed, and `SizeOnDisk=7997085707`. The completed executable
|
||||
was under `steamapps/common/1000xRESIST`; the active staging directory was gone.
|
||||
|
||||
Transfer rates were mostly 117–132 Mbps, about 15–16.5 MB/s. Two root metadata
|
||||
experiments ran during downloading, so this is not a clean network/card
|
||||
throughput benchmark. No matched full internal-storage install was performed.
|
||||
|
||||
The run's `storage.log` reported native exFAT and a passed truncate reservation
|
||||
probe. Recorded allocation details used `api=fallocate_ftruncate`, with
|
||||
`result=0` and `api_error=0`.
|
||||
|
||||
## What the metadata measurements show
|
||||
|
||||
Metadata is filesystem bookkeeping: names, directory entries, sizes and space
|
||||
allocation. Steam's `Preallocating` phase includes work outside the observed
|
||||
allocation wrappers, including creating and locating files.
|
||||
|
||||
### Directory reads through native and FUSE paths
|
||||
|
||||
A helper ran in a normal app process and listed the same stable asset directory
|
||||
from a saved staging tree through both host paths. It used the existing fast-path
|
||||
syscall trampoline to bypass PRoot path translation while retaining the app's
|
||||
Android permissions. The read buffer was 128 KiB.
|
||||
|
||||
| View | Elapsed | Successful `getdents64` calls | Directory bytes |
|
||||
|---|---|---|---|
|
||||
| Native exFAT | About 2.3 ms | 2 | 154,920 |
|
||||
| Android FUSE | About 501 ms | 112 | 154,872 |
|
||||
|
||||
The views returned slightly different byte counts. This was a single
|
||||
native-first pass, not a repeated benchmark with fully controlled cache state.
|
||||
It demonstrates a large cost difference for that directory read, not a
|
||||
corresponding multiplier for a full Steam install.
|
||||
|
||||
An earlier root listing during concurrent file creation measured roughly
|
||||
5.56 seconds through FUSE and 9.25 milliseconds native. Its directory was
|
||||
changing, so it is weaker evidence and is not used as a controlled comparison.
|
||||
|
||||
### File creation and size updates
|
||||
|
||||
During the fresh native-path Steam run, sampled kernel stacks included:
|
||||
|
||||
```text
|
||||
__sync_dirty_buffer -> exfat_update_bh -> exfat_init_ext_entry
|
||||
-> exfat_add_entry -> exfat_create -> path_openat
|
||||
|
||||
__wait_on_buffer -> exfat_update_bhs -> exfat_free_dentry_set
|
||||
-> __exfat_write_inode -> exfat_truncate -> do_sys_ftruncate
|
||||
```
|
||||
|
||||
These were live samples, not a continuous attribution profile. They show time
|
||||
waiting in the native filesystem's creation/update paths during the delay.
|
||||
|
||||
A root-only helper created a temporary directory, then 64 long-named files using
|
||||
`open(O_CREAT|O_EXCL|O_WRONLY, 0600)`, `ftruncate(fd, 262144)` and `close`. It timed
|
||||
the creation and truncate calls separately and removed its files afterward.
|
||||
|
||||
| Location/run | Sum of open times | Sum of truncate times | Whole loop |
|
||||
|---|---|---|---|
|
||||
| Native SD, original mount | 6.707435 s | 0.084806 s | 6.794807 s |
|
||||
| Native SD, later repeat | 25.181255 s | 0.309577 s | 25.493210 s |
|
||||
| Internal app cache, live observation | About 7.88 ms | About 0.498 ms | About 9.1 ms |
|
||||
|
||||
The SD results varied substantially, and these root probes do not reproduce the
|
||||
normal app's complete Steam workload. The measured internal probe is not a full
|
||||
internal-install benchmark. Within these probes, file creation cost far more
|
||||
than the observed truncate calls.
|
||||
|
||||
### `dirsync` and the failed remount comparison
|
||||
|
||||
The [Linux mount documentation](https://man7.org/linux/man-pages/man2/mount.2.html)
|
||||
defines `MS_DIRSYNC` as synchronous directory changes. Combined with the observed
|
||||
exFAT buffer waits, this makes synchronous SD metadata updates a strong lead.
|
||||
It remains an inference about the remaining overall delay.
|
||||
|
||||
A toybox remount attempt returned `Invalid argument`. A direct mount-syscall
|
||||
experiment changed the root view, but Steam's observed mount table still showed
|
||||
`dirsync`. A subsequent 64-file probe took 2.112288 seconds; because the effective
|
||||
flag change was not established and timings were variable, this is **not proof
|
||||
that removing `dirsync` improves Steam allocation**. The Linux documentation also
|
||||
notes that attempts to change `MS_DIRSYNC` during a remount are silently ignored.
|
||||
|
||||
Original effective options were restored and checked in root and app/process
|
||||
views. No remount or root-launch behavior is included in the production patches.
|
||||
|
||||
### What remains attributable to PRoot
|
||||
|
||||
One sampled Steam worker was executing an `openat` through the fast-path
|
||||
trampoline at `0xffff00020`, with an absolute native host path. That establishes
|
||||
that some operations bypassed PRoot's path translation in the actual session.
|
||||
Other syscalls still use PRoot.
|
||||
|
||||
There was no matched PRoot-versus-direct full preallocation comparison. The
|
||||
fraction of the 404 seconds attributable to PRoot is therefore unknown. The
|
||||
earlier [PRoot performance notes](proot-performance.md) concern different hardware
|
||||
and workloads and cannot supply that missing percentage.
|
||||
|
||||
Seven seconds in public allocation wrappers does not mean the remaining
|
||||
397 seconds are unrelated to allocation: libc-internal/direct-syscall bypasses,
|
||||
explicit size changes and file-creation work may occur outside those counters.
|
||||
|
||||
## Experiments that did not become the fix
|
||||
|
||||
- **Directory caching:** eager scans held up directory open; creation-driven
|
||||
invalidation repeatedly reread growing directories. The capped design could
|
||||
also read and discard a large directory before rereading it. A lazy variant
|
||||
still left 182 files taking 4m49s while the allocation wrappers accounted for
|
||||
only 0.879 seconds. These were interrupted/resumed runs, not a matched A/B
|
||||
benchmark. The final #221 snapshot removes the cache entirely.
|
||||
- **Allocation fallback alone:** a FUSE-path attempt was still slow after
|
||||
unsupported allocation calls were handled. A later eager-cache resumed run
|
||||
accumulated roughly 8.36 seconds in 1,435 successful allocation calls over
|
||||
about 36 minutes of wall time. Successful wrappers alone were insufficient.
|
||||
- **Launching Steam through root:** experimental wrappers failed compositor
|
||||
startup. They did not produce allocation measurements and were reverted.
|
||||
- **Changing mount flags:** effective removal of `dirsync` from Steam's view was
|
||||
not demonstrated. The apparent probe speedup is not a validated fix.
|
||||
- **Replacing PRoot or reformatting storage:** no such replacement, adoptable
|
||||
storage conversion or ext4 loop-image deployment was validated here.
|
||||
|
||||
The early FUSE runs used partial staging state. Their file counts and durations
|
||||
must not be compared to the fresh 2,216-file run as a precise speedup ratio.
|
||||
|
||||
## Post-install launch and separate memory failure
|
||||
|
||||
After deploying #221 + #229 + #233, the installed SD game was launched through
|
||||
Steam. First-run Proton prefix initialization preceded the game window.
|
||||
|
||||
At **18:10:45**, the compositor changed from Steam Big Picture to **1000xRESIST**
|
||||
and received the game's 1280×720 GPU frames. A screenshot showed the game loading
|
||||
screen and spinner. A main menu or sustained gameplay was not validated.
|
||||
|
||||
At **18:13:48.454**, Android recorded the foreground app's exit as reason 3,
|
||||
`LOW_MEMORY`, with main-app PSS 227 MB and RSS 344 MB. Those figures describe that
|
||||
process, not the complete native process tree or its peak memory use. Subsequent
|
||||
gamescope/reaper shutdown messages followed the parent being killed.
|
||||
|
||||
This was after the install had completed. It is a separate unresolved launch/
|
||||
memory issue and does not undo the successful allocation/download result.
|
||||
Post-kill free-memory readings cannot establish peak memory pressure.
|
||||
|
||||
## Remaining questions and next useful tests
|
||||
|
||||
1. Run the same fresh game install on internal and secondary storage with the
|
||||
same manifest, awake device and no competing benchmarks. Compare preallocation,
|
||||
download/staging and commit separately.
|
||||
2. Match the file-creation/extension workload through PRoot and through the normal
|
||||
app's direct syscall path. Keep UID, filesystem, names and sizes identical,
|
||||
repeat runs and record background I/O. This can quantify the remaining tracer
|
||||
contribution without changing storage semantics.
|
||||
3. Establish an actual controlled difference in synchronous directory behavior
|
||||
before attributing a speedup to mount flags. A remount return code alone is
|
||||
insufficient; this is separate from a portable app patch.
|
||||
4. Validate native-path fallback under enforcing SELinux and on unrooted devices,
|
||||
plus allocation behavior near ENOSPC and after interruption. Preserve the
|
||||
configured-path fallback if either access or reservation verification fails.
|
||||
5. Investigate the low-memory exit with process-tree/cgroup memory and Android
|
||||
kill evidence during loading. Treat it separately from install performance.
|
||||
|
||||
At this point, one fresh SD install and the binding regression are validated.
|
||||
Internal-speed parity, the exact PRoot share, a `dirsync` remedy, broad device
|
||||
coverage and stable gameplay remain unproven.
|
||||
|
||||
## Evidence inventory and handoff state
|
||||
|
||||
The device captures used for the final measurements were:
|
||||
|
||||
- `2026-10-04-62-app-raw-probe`: normal-app native/FUSE directory probe.
|
||||
- `2026-10-04-64-steam`: fresh complete allocation/download/commit.
|
||||
- Probe sessions before and after #233: private binding markers.
|
||||
- `2026-10-04-67-steam`: combined-build launch, followed by Android's low-memory
|
||||
kill; the exit reason was obtained separately with `dumpsys activity exit-info`.
|
||||
|
||||
Local originals during the investigation were under
|
||||
`/tmp/droiddeck-storage-live/`: `direct-content-completed.log`,
|
||||
`direct-storage-completed.log`, `app-raw-probe-session.log`,
|
||||
`bind-probe-before.log`, `bind-probe-after.log`,
|
||||
`metadata-mount-comparison.log`, `metadata-syscall-comparison.log`,
|
||||
`game-launch-exit-info.txt`, `combined-233-build.log` and
|
||||
`combined-233-tests.log`. The repository evidence file preserves the selected
|
||||
excerpts even if those temporary local files disappear. Live-only counters,
|
||||
kernel samples and the internal metadata timing are identified above rather
|
||||
than represented as saved continuous traces.
|
||||
|
||||
The combined signed build and completed SD install remained on Thor. Storage
|
||||
diagnostics remained enabled. Temporary probe files, executables and obsolete
|
||||
partial staging were removed after validation. Original effective mount options
|
||||
were restored; no app-specific Magisk root policy remained. The primary checkout's
|
||||
unrelated `SessionService.kt` modification was preserved.
|
||||
@@ -0,0 +1,62 @@
|
||||
# DroidDeck 0.3.1
|
||||
|
||||
### Steam
|
||||
|
||||
- Decky plugins can be imported from ZIP files, with remote binaries verified and installed copies of the same plugin replaced. (@xXJSONDeruloXx, [#79](https://github.com/Droid-Deck/DroidDeck/pull/79))
|
||||
- Steam's Internet page shows Android Wi-Fi and cellular connections. Optional Wi-Fi discovery adds network names and nearby networks with Android's Location permission. (@xXJSONDeruloXx, [#150](https://github.com/Droid-Deck/DroidDeck/pull/150))
|
||||
- Paste text from the Android clipboard into Steam sessions. (@xXJSONDeruloXx, [#151](https://github.com/Droid-Deck/DroidDeck/pull/151))
|
||||
- The Proton selected in Components stays in sync with Steam's default compatibility tool, and runnable builds are marked **Compatible**. Windows titles with a Valve recommendation use that default unless you choose another compatible tool for the game. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#224](https://github.com/Droid-Deck/DroidDeck/pull/224))
|
||||
|
||||
### Compatibility and performance
|
||||
|
||||
- Choose **esync**, **fsync**, experimental **ntsync**, or **wineserver** for Proton games. These sync options work around Android's missing kernel support, with esync as the default for fresh installs and existing choices preserved. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#223](https://github.com/Droid-Deck/DroidDeck/pull/223))
|
||||
- droiddeck-esync packs are matched to the exact Valve, GE-Proton or CachyOS Proton build and fetched automatically. droiddeck-fsync stands in when a pack is not available. (@maxjivi05, [#189](https://github.com/Droid-Deck/DroidDeck/pull/189), [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- Linux-only x86 Steam titles can run through FEX using Steam's FEX and Steam Linux Runtime, with controller input and the device's graphics drivers. Missing dependencies are requested through Steam. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- .NET and Godot games get automatic launch fixes under FEX, including the settings needed for Slay the Spire 2. Per-game overrides are kept. (@magarto, [#168](https://github.com/Droid-Deck/DroidDeck/pull/168))
|
||||
|
||||
### Display and effects
|
||||
|
||||
- Live image scaling with AMD FSR 1, Snapdragon GSR and GSR quality, NVIDIA NIS, linear and nearest filtering, plus sharpening. (@magarto, @The412Banner, [#128](https://github.com/Droid-Deck/DroidDeck/pull/128), [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
|
||||
- An **Effects** page adds presets, FXAA, Toon, CRT, NTSC and Fake HDR. Advanced controls cover sharpening, debanding, brightness, contrast, gamma and saturation, with choices saved between sessions. (@The412Banner, @xXJSONDeruloXx, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198), [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
|
||||
- Anisotropic filtering and texture sharpness for DirectX 9–11 games, applied on the next game launch. (@The412Banner, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
|
||||
- One **Resolution** menu before starting a session, with preset dimensions, Custom and **Match screen**. Resolution, image scaling and fullscreen compatibility have separate controls. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
|
||||
|
||||
### Controls and sessions
|
||||
|
||||
- Standard Android picture-in-picture, opened manually or automatically when leaving a ready session, with second-screen controls restored when you return. (@xXJSONDeruloXx, [#156](https://github.com/Droid-Deck/DroidDeck/pull/156), [#195](https://github.com/Droid-Deck/DroidDeck/pull/195))
|
||||
- A quick toggle hides or restores on-screen controls. **Touch Off** ignores touches on the game picture while keeping Android controls usable. (@xXJSONDeruloXx, [#147](https://github.com/Droid-Deck/DroidDeck/pull/147), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
|
||||
- Optional game vibration and on-screen Steam, Quick Access and keyboard buttons. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
|
||||
- Steam session settings use tabs for Display, Controls, Games, Audio, Session and Steam. The session menu starts with Controller, gives second-screen modes their own page, and groups live display changes separately from settings for the next game launch. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
|
||||
- Compact Steam Home and Quick Access shortcuts beside Stop. Switching to Desktop stays in Steam's own menu. (@xXJSONDeruloXx, [#188](https://github.com/Droid-Deck/DroidDeck/pull/188), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
|
||||
- Back's double-press window is back to 500 ms, making Quick Access easier to open. (@xXJSONDeruloXx, [#193](https://github.com/Droid-Deck/DroidDeck/pull/193))
|
||||
|
||||
### Apps and launcher
|
||||
|
||||
- Android home-screen shortcuts for installed Steam games and added games. (@xXJSONDeruloXx, [#157](https://github.com/Droid-Deck/DroidDeck/pull/157))
|
||||
- Export per-game `.droiddeck` launch files and Steam icons for Android frontends, with optional automatic folder sync as the library changes. (@xXJSONDeruloXx, [#179](https://github.com/Droid-Deck/DroidDeck/pull/179))
|
||||
- Import x86 AppImages and run x86 Linux scripts and programs through FEX. Added apps have a **Use FEX** Auto/On/Off choice, and GitHub imports can use an x86_64 AppImage when no ARM64 build is available. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- Vulkan Qt Quick AppImages such as OpenNOW use Wayland to avoid X11 startup failures. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
- Preview updates show recent published changes and how many builds behind you are when the installed Preview can be matched. (@xXJSONDeruloXx, [#159](https://github.com/Droid-Deck/DroidDeck/pull/159))
|
||||
- A new empty-library intro and DroidDeck wordmark on the Steam page, plus an option to reduce app animations. (@xXJSONDeruloXx, [#182](https://github.com/Droid-Deck/DroidDeck/pull/182), [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
|
||||
- The Linux file manager bookmarks the selected game library automatically. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
|
||||
|
||||
### Languages and logs
|
||||
|
||||
- Simplified Chinese. (@Dantalian-dev, [#202](https://github.com/Droid-Deck/DroidDeck/pull/202))
|
||||
- Japanese, Korean and Traditional Chinese for Taiwan, Hong Kong and Macau. (@starwishHi, [#216](https://github.com/Droid-Deck/DroidDeck/pull/216))
|
||||
- Optional **Storage diagnostics** include allocation and storage timing in shared session logs to help investigate slow Steam installs. (@xXJSONDeruloXx, [#158](https://github.com/Droid-Deck/DroidDeck/pull/158))
|
||||
- Session logs retain their final output and include more detail when an AppImage or FEX program fails to start or crashes. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Steam's Deck controller not being found on handhelds with older kernels. (@xXJSONDeruloXx, [#146](https://github.com/Droid-Deck/DroidDeck/pull/146))
|
||||
- Physical and on-screen controllers doing nothing on ColorOS devices with linked app data directories. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
|
||||
- Game vibration going to the phone instead of the active physical controller. The device vibrator remains the fallback for on-screen controls or controllers without motors. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
|
||||
- A physical mouse stopping camera movement at the screen edge. Games that request a pointer lock now use Android's relative pointer capture, released while menus or PiP are open. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
|
||||
- Steam remaining on a black screen after idle sleep or Android session resume. (@xXJSONDeruloXx, @jmarti326, [#155](https://github.com/Droid-Deck/DroidDeck/pull/155), [#172](https://github.com/Droid-Deck/DroidDeck/pull/172))
|
||||
- Steam stuck at **Waiting for Steam** after an interrupted client update. After two starts without a frame, DroidDeck fetches the client again while preserving games, settings and user data; **Repair Steam client** also queues a manual repair. (@maxjivi05, [#225](https://github.com/Droid-Deck/DroidDeck/pull/225))
|
||||
- Play racing runtime removal. Removal now shows progress and verifies completion, and first-run setup respects the selected Steam channel and saved UI scaling. (@xXJSONDeruloXx, [#185](https://github.com/Droid-Deck/DroidDeck/pull/185))
|
||||
- GE-Proton and CachyOS Proton failing their protonfixes checks because Android blocks `/proc/sys/fs/file-max`. (@magarto, [#169](https://github.com/Droid-Deck/DroidDeck/pull/169))
|
||||
- DirectAudio not being selected correctly, mismatched FEX helpers surviving component swaps, and FEX or its runtime not being executable from an SD-card Steam library. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
|
||||
|
||||
[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.0...0.3.1)
|
||||
@@ -16,6 +16,11 @@ export QT_QPA_PLATFORMTHEME=lxqt
|
||||
# The prefs for this are only advisory and a profile can override them; the environment is not.
|
||||
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
|
||||
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
|
||||
# Firefox seals the size of the shared memory it hands a tab; on Android 16 (Galaxy S25/S26,
|
||||
# RedMagic) adding the seal fails, Firefox only warns, and the tab refuses the unsealed memory
|
||||
# ("Shared memory PlatformHandle is not safe to map") and dies on signal 11 - every page. Firefox's
|
||||
# own switch skips the seals.
|
||||
export MOZ_SHM_NO_SEALS=1
|
||||
# pixman composites in software and gives programs shm buffers only, so a Vulkan program on it
|
||||
# fails at its swapchain. vulkan (or gles2) composite on the GPU and give programs DMA-BUFs, but
|
||||
# stock wlroots allocates through gbm on a real DRM node and the KGSL stand-in is not one ("unable
|
||||
|
||||
@@ -24,6 +24,8 @@ fi
|
||||
# X11, which it picks from the session type.
|
||||
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
|
||||
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
|
||||
# Android 16 refuses Firefox the size seal on a tab's shared memory; see droiddeck-desktop.
|
||||
export MOZ_SHM_NO_SEALS=1
|
||||
# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and
|
||||
# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads.
|
||||
if [ -s /etc/ld.so.preload ]; then
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user