mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-21 06:34:44 -04:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef261d9724 | ||
|
|
9310d19857 | ||
|
|
4454a6d39a | ||
|
|
453f6f5180 | ||
|
|
6d0484941e | ||
|
|
cf47910881 | ||
|
|
4f0f0cf5b1 | ||
|
|
a78d5cb600 | ||
|
|
206c8a8846 | ||
|
|
bf17ce1151 | ||
|
|
ef38c975b9 | ||
|
|
61953c4f7b | ||
|
|
1f13285ab8 | ||
|
|
1bc23ac169 | ||
|
|
50f50b16e0 | ||
|
|
adc86fc7df | ||
|
|
f3fd3a2307 | ||
|
|
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 |
No files matched your search
@@ -6,6 +6,7 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/aaudio-sink/**'
|
||||
- 'tools/directaudio-relay/**'
|
||||
- '.github/workflows/build-aaudio-sink.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -25,6 +26,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
|
||||
tools/directaudio-relay/build.sh "$PWD/out"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audio-sink-modules
|
||||
|
||||
@@ -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"
|
||||
@@ -190,7 +210,7 @@ jobs:
|
||||
done
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
@@ -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"
|
||||
@@ -305,6 +345,11 @@ jobs:
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
- name: Build the DirectAudio relay
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/directaudio-relay/build.sh app/src/main/jniLibs/arm64-v8a
|
||||
|
||||
- name: Cache proot
|
||||
id: proot
|
||||
@@ -343,11 +388,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 drmPrimeFDToHandle drmCloseBufferHandle drmIoctl; 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
|
||||
|
||||
@@ -14,6 +14,7 @@ app/src/main/assets/linuxfs/
|
||||
app/src/main/jniLibs/arm64-v8a/libproot.so
|
||||
app/src/main/jniLibs/arm64-v8a/.proot-inputs
|
||||
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
|
||||
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -192,6 +192,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')
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Session logs go to Downloads so a failed run can be handed over as one folder. -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
@@ -71,6 +70,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -65,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
|
||||
@@ -156,8 +159,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var forceFullscreen by mutableStateOf(false)
|
||||
private var clientDirectAudio by mutableStateOf(true)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
@@ -173,6 +175,16 @@ class MainActivity : ComponentActivity() {
|
||||
refreshWifiDiscovery()
|
||||
}
|
||||
}
|
||||
private val mediaRefresh = Runnable {
|
||||
refresh()
|
||||
refreshAddedGames()
|
||||
}
|
||||
private val mediaReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context?, intent: Intent?) {
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
|
||||
refreshWifiDiscovery()
|
||||
@@ -316,6 +328,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var settingsMode by mutableStateOf<String?>(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")
|
||||
@@ -327,6 +340,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var renderer by mutableStateOf("vulkan")
|
||||
@@ -407,10 +421,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(this, 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(this, 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))
|
||||
@@ -424,10 +443,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
|
||||
@@ -440,7 +459,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, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
|
||||
return false
|
||||
@@ -455,10 +474,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)
|
||||
}
|
||||
@@ -470,10 +491,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +510,7 @@ 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)
|
||||
@@ -566,7 +591,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()
|
||||
},
|
||||
@@ -634,13 +659,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, R.string.main_no_logs_yet, 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, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
},
|
||||
onClearLogs = {
|
||||
Thread({
|
||||
@@ -800,9 +822,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
|
||||
|
||||
// The session's logs land in Downloads so a failed run can be handed over as a folder
|
||||
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
|
||||
// grants exactly that.
|
||||
val wanted = ArrayList<String>()
|
||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
@@ -915,6 +934,11 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
|
||||
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
|
||||
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
|
||||
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
|
||||
addDataScheme("file")
|
||||
})
|
||||
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
@@ -926,6 +950,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
unregisterReceiver(wifiLocationReceiver)
|
||||
unregisterReceiver(mediaReceiver)
|
||||
ui.removeCallbacks(mediaRefresh)
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
// The session covers the page by now; coming back finds it as it was.
|
||||
flood = null
|
||||
@@ -1152,13 +1178,13 @@ class MainActivity : ComponentActivity() {
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
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,
|
||||
@@ -1166,6 +1192,7 @@ class MainActivity : ComponentActivity() {
|
||||
storageOptions = storageOptions,
|
||||
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
|
||||
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,
|
||||
@@ -1211,6 +1238,10 @@ class MainActivity : ComponentActivity() {
|
||||
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onSteamDownloadsInBackground = { enabled ->
|
||||
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
@@ -1219,7 +1250,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)
|
||||
@@ -1240,6 +1270,7 @@ class MainActivity : ComponentActivity() {
|
||||
storageDiagnostics = 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)
|
||||
@@ -1332,7 +1363,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
@@ -1361,6 +1392,7 @@ class MainActivity : ComponentActivity() {
|
||||
showMapping = false
|
||||
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)
|
||||
@@ -1378,11 +1410,11 @@ class MainActivity : ComponentActivity() {
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
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()
|
||||
@@ -1607,6 +1639,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val MEDIA_SETTLE_MS = 1500L
|
||||
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
|
||||
private val ZIP_EXT = listOf("zip")
|
||||
private val WCP_EXT = listOf("wcp")
|
||||
|
||||
@@ -234,7 +234,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** 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)
|
||||
@@ -247,6 +246,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var onScreenButtonsVisible by mutableStateOf(false)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
@@ -507,19 +507,18 @@ 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, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
|
||||
touchAuto = getString(if (usingTouchpad()) R.string.session_touch_auto_touchpad else R.string.session_touch_auto_direct),
|
||||
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)
|
||||
@@ -565,6 +564,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
suspendPolicy = policy
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onSteamDownloadsInBackground = { enabled ->
|
||||
SessionPrefs.setSteamDownloadsInBackground(this@SessionActivity, enabled)
|
||||
steamDownloadsInBackground = enabled
|
||||
SessionService.suspendPolicyChanged(this@SessionActivity)
|
||||
},
|
||||
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
@@ -756,28 +760,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Toast.makeText(this, R.string.session_no_logs, 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, R.string.session_logs_archive_failed, 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, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, R.string.session_logs_archive_failed, 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, R.string.session_logs_share_failed, 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)
|
||||
@@ -798,6 +796,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
@@ -1175,6 +1174,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
|
||||
@@ -1937,6 +1937,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)
|
||||
@@ -2077,6 +2079,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) {
|
||||
@@ -2087,6 +2090,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)
|
||||
}
|
||||
@@ -2102,6 +2111,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"
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
@@ -50,6 +51,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
val response = try {
|
||||
when (method) {
|
||||
"state" -> state(context).put("ok", true)
|
||||
"logs" -> exportLogs(context)
|
||||
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
|
||||
"stop" -> stop(context)
|
||||
"resume" -> resume(context)
|
||||
@@ -63,7 +65,7 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
|
||||
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
|
||||
val session = JSONObject()
|
||||
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
|
||||
.put("phase", SessionState.phase.name)
|
||||
@@ -101,10 +103,12 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
.put("session", session)
|
||||
}
|
||||
|
||||
private fun latestSessionDirectory(): File? {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
|
||||
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
|
||||
private fun exportLogs(context: Context): JSONObject {
|
||||
val dir = SessionState.logDirectory ?: SessionLogShare.latest(context)
|
||||
?: return error("NO_SESSION_LOGS", "No session logs are available")
|
||||
val zip = SessionLogShare.zipFolder(context, dir)
|
||||
?: return error("NO_SESSION_LOGS", "No diagnostic logs are available")
|
||||
return JSONObject().put("ok", true).put("sessionId", dir.name).put("path", zip.absolutePath)
|
||||
}
|
||||
|
||||
private fun stop(context: Context): JSONObject {
|
||||
|
||||
@@ -143,8 +143,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
} else {
|
||||
// The classic sink: the adaptive AAudio module 0.1.5 shipped, unchanged.
|
||||
config.add("load-module module-aaudio-classic-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
}
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
|
||||
@@ -22,7 +22,7 @@ object AppLanguage {
|
||||
const val SYSTEM = ""
|
||||
|
||||
/** The languages the app ships, as BCP 47 tags, in the order the picker lists them. */
|
||||
val supported: List<String> = listOf("en", "es", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
|
||||
val supported: List<String> = listOf("en", "es", "fr", "ru", "ja", "ko", "zh-CN", "zh-TW", "zh-HK")
|
||||
|
||||
private const val PREFS = "language"
|
||||
private const val KEY = "app_language"
|
||||
@@ -99,7 +99,7 @@ object AppLanguage {
|
||||
|
||||
/** Which of the app's languages [locale] reads as, or null when the app does not ship it. */
|
||||
fun supportedTag(locale: Locale): String? = when (locale.language) {
|
||||
"en", "es", "ja", "ko" -> locale.language
|
||||
"en", "es", "fr", "ru", "ja", "ko" -> locale.language
|
||||
"zh" -> when {
|
||||
locale.script == "Hans" -> "zh-CN"
|
||||
locale.country == "HK" || locale.country == "MO" -> "zh-HK"
|
||||
@@ -117,6 +117,8 @@ object AppLanguage {
|
||||
fun nativeName(tag: String): String = when (tag) {
|
||||
"en" -> "English"
|
||||
"es" -> "Español"
|
||||
"fr" -> "Français"
|
||||
"ru" -> "Русский"
|
||||
"ja" -> "日本語"
|
||||
"ko" -> "한국어"
|
||||
"zh-CN" -> "简体中文"
|
||||
|
||||
@@ -109,8 +109,11 @@ object DeviceReport {
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
|
||||
k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize))
|
||||
k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize))
|
||||
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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -36,11 +36,16 @@ object LogRedactor {
|
||||
* WebAPI key is caught by [WEBAPI_KEY]. The value class excludes `<` and `>` so running this
|
||||
* twice over an already-scrubbed line changes nothing.
|
||||
*/
|
||||
private val SECRET_KV = Regex(
|
||||
"(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
|
||||
private const val SECRET_KEYS =
|
||||
"access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
|
||||
"auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" +
|
||||
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" +
|
||||
"(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})"
|
||||
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret"
|
||||
private val SECRET_KV = Regex(
|
||||
"(?i)\\b($SECRET_KEYS)" +
|
||||
"([\"']?\\s*[=:]\\s*[\"']?)([^\\s\"'<>&;,]{1,})"
|
||||
)
|
||||
private val QUOTED_SECRET = Regex(
|
||||
"(?i)\\b($SECRET_KEYS)([\"']?\\s*[=:]\\s*)(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*')"
|
||||
)
|
||||
/** A Steam Guard code, only where the text around it says that is what it is. */
|
||||
private val GUARD_CODE = Regex(
|
||||
@@ -53,6 +58,7 @@ object LogRedactor {
|
||||
"(?i)\\b(jwt|token|ticket|sessionid|steamloginsecure|machineauth)\\b[\\s=:]*([A-Za-z0-9+/=_\\-]{12,})"
|
||||
)
|
||||
private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}")
|
||||
private val AUTHORIZATION = Regex("(?i)(\\b(?:proxy-)?authorization[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+")
|
||||
private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b")
|
||||
private val STEAMID3 = Regex("\\[U:1:(\\d+)]")
|
||||
/** "external address 2607:..." / "external IP: 203.0.113.9" - the client stating ours. */
|
||||
@@ -179,22 +185,6 @@ object LogRedactor {
|
||||
return out
|
||||
}
|
||||
|
||||
/** A file worth scrubbing: text (no NUL in its first 4 KB) and not huge. */
|
||||
fun isText(file: java.io.File): Boolean = try {
|
||||
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
|
||||
val buf = ByteArray(4096)
|
||||
val n = input.read(buf)
|
||||
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** [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") }
|
||||
}
|
||||
|
||||
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
|
||||
fun redact(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
@@ -203,6 +193,11 @@ object LogRedactor {
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
out = AUTHORIZATION.replace(out) { "${it.groupValues[1]}<redacted:authorization>" }
|
||||
out = QUOTED_SECRET.replace(out) {
|
||||
val quote = it.groupValues[3].first()
|
||||
"${it.groupValues[1]}${it.groupValues[2]}$quote<redacted:token>$quote"
|
||||
}
|
||||
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
|
||||
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
|
||||
out = WEBAPI_KEY.replace(out, "<redacted:key>")
|
||||
|
||||
@@ -49,7 +49,7 @@ object SessionLogCapture {
|
||||
) { line ->
|
||||
synchronized(this) {
|
||||
try {
|
||||
writer?.apply { write(line); newLine(); flush() }
|
||||
if (writer === out) out.apply { write(line); newLine(); flush() }
|
||||
} catch (e: Exception) {
|
||||
// A full or unmounted card must not take the session with it.
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileCopy
|
||||
@@ -312,6 +313,47 @@ internal fun FileItemRow(
|
||||
}
|
||||
}
|
||||
|
||||
// The ".." row at the top of a folder: one level up, styled like a folder row so it reads as part
|
||||
// of the list and takes controller focus the same way.
|
||||
@Composable
|
||||
internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.clickable(onClick = onTap),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "..",
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.fm_up_one_level),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated Favorites list that replaces the file list while the star toggle is on.
|
||||
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
|
||||
@Composable
|
||||
|
||||
@@ -99,5 +99,10 @@ fun describeLocation(file: File): FavLocation {
|
||||
}
|
||||
}
|
||||
|
||||
// The Linux system's root (the app's files/linuxfs, the sessions' /) and anything under it.
|
||||
if (Regex("/files/linuxfs(/|$)").containsMatchIn(abs)) {
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_linux, abs)
|
||||
}
|
||||
|
||||
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -41,6 +42,7 @@ import androidx.compose.material.icons.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
@@ -143,7 +145,14 @@ fun FileManagerScreen(
|
||||
val name = abs.removePrefix("/storage/").substringBefore('/')
|
||||
if (name.isNotEmpty() && name != "emulated" && name != "self") File("/storage/$name") else rootDir
|
||||
}
|
||||
else -> rootDir
|
||||
// A game's own folder or Proton prefix lives in the app's private storage, which has no
|
||||
// volume above it: floor at the app's files dir so up/back still reach the library roots.
|
||||
else -> {
|
||||
val appFiles = runCatching { context.filesDir.canonicalPath }.getOrNull()
|
||||
generateSequence(rootDir) { it.parentFile }
|
||||
.firstOrNull { appFiles != null && runCatching { it.canonicalPath }.getOrNull() == appFiles }
|
||||
?: rootDir
|
||||
}
|
||||
}
|
||||
mutableStateOf(vol)
|
||||
}
|
||||
@@ -232,7 +241,9 @@ fun FileManagerScreen(
|
||||
loadDirectory(dir)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { openDrive(rootDir) }
|
||||
// Open the start dir without openDrive(): that pins the floor to the start dir itself, which
|
||||
// greyed out up/back whenever the manager was opened at a game's folder.
|
||||
LaunchedEffect(Unit) { loadDirectory(rootDir) }
|
||||
|
||||
// System/gesture Back: while the Favorites view is open it closes that first; otherwise
|
||||
// it goes up one directory. Only at the current drive's root with Favorites closed is it
|
||||
@@ -246,6 +257,14 @@ fun FileManagerScreen(
|
||||
if (parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// One level up, never above the current drive's root: shared by the toolbar arrow and the
|
||||
// ".." row at the top of the list.
|
||||
val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
|
||||
fun goUp() {
|
||||
val parent = currentDir.parentFile
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}
|
||||
|
||||
// Resolve a non-colliding destination in [dir] for [name] (foo.txt -> "foo (1).txt").
|
||||
fun uniqueDestination(dir: File, name: String): File {
|
||||
var candidate = File(dir, name)
|
||||
@@ -412,6 +431,9 @@ fun FileManagerScreen(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val drives = remember(storageTick) { StorageRoots.list(context) }
|
||||
val linuxRoot = remember(storageTick) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
|
||||
}
|
||||
|
||||
// ── Dialogs ──
|
||||
|
||||
@@ -617,11 +639,7 @@ fun FileManagerScreen(
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val parent = currentDir.parentFile
|
||||
// Don't climb above the current drive's root.
|
||||
if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
|
||||
}, enabled = currentDir != currentRoot) {
|
||||
IconButton(onClick = { goUp() }, enabled = canGoUp) {
|
||||
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
@@ -676,6 +694,16 @@ fun FileManagerScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
linuxRoot?.let { root ->
|
||||
MenuItemDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.fm_drive_linux)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
onClick = { showDriveMenu = false; openDrive(root) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,13 +1012,14 @@ fun FileManagerScreen(
|
||||
showFavorites = false; if (d.readable) openDrive(d.dir)
|
||||
})
|
||||
}
|
||||
// The Linux system the sessions run in: its own / (the desktop's root), once installed.
|
||||
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
|
||||
}
|
||||
val quickItems = buildList {
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
@@ -1044,6 +1073,9 @@ fun FileManagerScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
items(shownEntries, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
FavoritesStore.isFavorite(context, file.absolutePath)
|
||||
@@ -1109,6 +1141,9 @@ fun FileManagerScreen(
|
||||
}
|
||||
} else
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
if (canGoUp) item(key = "..") {
|
||||
ParentFolderRow(compact = compactRows, onTap = { goUp() })
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
|
||||
@@ -98,7 +98,7 @@ object AddedGames {
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
val steamInstalls = steamInstallDirs(dir)
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
if (folder.name.lowercase() in steamInstalls) continue
|
||||
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
|
||||
scanGame(context, folder, out)
|
||||
}
|
||||
}
|
||||
@@ -126,32 +126,59 @@ object AddedGames {
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
val candidates = candidates(folder)
|
||||
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = chosen ?: candidates.firstOrNull() ?: return
|
||||
val guestExe = guestPath(context, exe)
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val found = chosen ?: candidates.firstOrNull() ?: return
|
||||
val foundGuest = guestPath(context, found)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
|
||||
val name = folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
val adopted = guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
private fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
|
||||
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
|
||||
return account?.let { File(root, "userdata/$it/config") }
|
||||
}
|
||||
|
||||
private fun steamRoute(config: File, appId: Long): Int? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||
}.getOrNull()
|
||||
|
||||
internal fun steamTarget(folder: File, folderGuest: String, record: JSONObject?, guestExe: String, seen: Int): File? {
|
||||
if (record == null || record.optInt("rev", 0) == seen) return null
|
||||
val asked = unquote(record.optJSONObject("app")?.optString("Exe"))
|
||||
val steam = unquote(record.optJSONObject("steam")?.optString("Exe"))
|
||||
if (asked != guestExe || steam.isEmpty() || steam == asked || !steam.startsWith("$folderGuest/")) return null
|
||||
val file = File(folder, steam.removePrefix("$folderGuest/"))
|
||||
val inside = runCatching { file.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)
|
||||
return file.takeIf { inside && it.isFile }
|
||||
}
|
||||
|
||||
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
@@ -161,6 +188,7 @@ object AddedGames {
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,17 @@ package com.droiddeck.launcher.frontend
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.core.util.readText
|
||||
import androidx.core.util.writeBytes
|
||||
import androidx.core.util.writeText
|
||||
import com.droiddeck.launcher.R
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
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"
|
||||
@@ -47,35 +47,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(context: Context, folder: File, game: Library.SteamGame): File {
|
||||
@Synchronized fun export(context: Context, folder: File, game: Library.SteamGame): File =
|
||||
exportNamed(context, folder, game, filename(game))
|
||||
|
||||
private fun exportNamed(context: Context, folder: File, game: Library.SteamGame, name: String): File {
|
||||
require(folder.isDirectory) { context.getString(R.string.gfiles_folder_unavailable) }
|
||||
val target = child(context, folder, filename(game))
|
||||
val text = contents(game.gameIdString)
|
||||
val target = exportTarget(context, folder, game, name)
|
||||
require(!target.exists() || matches(target, text)) { context.getString(R.string.gfiles_name_taken, target.name) }
|
||||
if (!target.exists()) write(target, text)
|
||||
exportArt(context, folder, game)
|
||||
return target
|
||||
}
|
||||
|
||||
private fun exportTarget(context: Context, folder: File, game: Library.SteamGame, name: String): File {
|
||||
val target = child(context, folder, name)
|
||||
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
|
||||
child(context, folder, filename(game, disambiguate = true))
|
||||
} else target
|
||||
}
|
||||
|
||||
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
|
||||
private fun exportArt(context: Context, 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('-')
|
||||
@@ -96,12 +110,20 @@ object GameFiles {
|
||||
record.getJSONObject("games")
|
||||
} else JSONObject()
|
||||
val next = JSONObject()
|
||||
val exports = games.distinctBy { it.gameId }.map { game ->
|
||||
val target = child(context, 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(context, folder, name)
|
||||
val target = if (inventoryOwns(context, folder, previous, preferred, game.gameIdString)) preferred
|
||||
else exportTarget(context, 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(context, folder, game)
|
||||
next.put(file.name, game.gameIdString)
|
||||
val owned = inventoryOwns(context, folder, previous, target, game.gameIdString)
|
||||
val file = if (owned && target.exists() &&
|
||||
!matches(target, text)) target else exportNamed(context, 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.
|
||||
@@ -120,6 +142,16 @@ object GameFiles {
|
||||
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
|
||||
}
|
||||
|
||||
private fun inventoryOwns(context: Context, 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(context, folder, name).let { old ->
|
||||
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun child(context: Context, 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()
|
||||
}
|
||||
@@ -86,17 +86,20 @@ object Library {
|
||||
return listOfNotNull(
|
||||
root to "internal",
|
||||
GameStorage.effective(context)?.let { File(it.path) to it.label },
|
||||
)
|
||||
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
|
||||
}
|
||||
|
||||
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
|
||||
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
|
||||
fun protonPrefix(
|
||||
context: Context, appId: Long, preferredLibrary: File? = null,
|
||||
libraries: List<File> = steamLibraries(context).map { it.first },
|
||||
): File? {
|
||||
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
|
||||
val secondary = GameStorage.effective(context)?.let { File(it.path) }
|
||||
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
|
||||
val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet()
|
||||
fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary)
|
||||
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
|
||||
else root
|
||||
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
|
||||
val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot)
|
||||
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
|
||||
return roots.asSequence()
|
||||
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
|
||||
@@ -107,6 +110,7 @@ object Library {
|
||||
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val cache = File(root, "appcache/librarycache")
|
||||
val libraries = steamLibraries(context)
|
||||
val roots = libraries.map { it.first }
|
||||
val out = LinkedHashMap<Int, SteamGame>()
|
||||
for ((library, label) in libraries) {
|
||||
val steamapps = File(library, "steamapps")
|
||||
@@ -132,7 +136,7 @@ object Library {
|
||||
out[appId] = SteamGame(
|
||||
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
|
||||
gameFiles = gameFiles,
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library),
|
||||
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
|
||||
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
|
||||
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
|
||||
)
|
||||
@@ -142,16 +146,19 @@ object Library {
|
||||
}
|
||||
|
||||
/** The same installed-game inventory used for links, shortcuts and file exports. */
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
|
||||
val roots = steamLibraries(context).map { it.first }
|
||||
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
|
||||
AddedGameArt.resolve(context, g).let { art ->
|
||||
SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
|
||||
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
|
||||
)
|
||||
}
|
||||
}).distinctBy { it.gameId }
|
||||
}
|
||||
|
||||
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
|
||||
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import java.io.File
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
* support level is what the app has actually been tested on (Adreno 650, 725 and newer) - an
|
||||
* Adreno 610 may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
@@ -18,10 +18,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"),
|
||||
@@ -42,6 +46,7 @@ data class GpuInfo(
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A6XX && model == 650 -> Support.TESTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
@@ -52,7 +57,7 @@ data class GpuInfo(
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
else "Outside tested hardware (Adreno 650, 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
@@ -74,17 +79,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 { UNNAMED } 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ object GuestCommand {
|
||||
linkDir: File? = null, onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try {
|
||||
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
File(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.AppLanguage
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Fonts for every script the app's languages (and Steam's) are written in.
|
||||
*
|
||||
* The runtime ships DejaVu and Adwaita only, which have no Chinese, Japanese, Korean or Thai, so
|
||||
* Steam drew those as boxes (#118, #206). Android already carries Noto for all of them in
|
||||
* /system/fonts; the guest gets that folder read-only at [GUEST_DIR], where fontconfig finds it
|
||||
* with the rest of /usr/share/fonts. Nothing is downloaded and the runtime is not changed.
|
||||
*
|
||||
* One CJK font holds Chinese, Japanese and Korean under one set of characters, and a character
|
||||
* they share is drawn the way the chosen region writes it. [preferenceConf] puts that region's
|
||||
* variant first, after DejaVu, which stays the font for Latin text.
|
||||
*/
|
||||
object GuestFonts {
|
||||
const val HOST_DIR = "/system/fonts"
|
||||
const val GUEST_DIR = "/usr/share/fonts/android"
|
||||
/** Rewritten at every session start, so it follows the language picked in Setup. */
|
||||
const val CONF = "etc/fonts/conf.d/65-droiddeck-language.conf"
|
||||
|
||||
/** The bind spec for Android's fonts, or null on a device without the folder. */
|
||||
@JvmStatic
|
||||
fun bindSpec(): String? = File(HOST_DIR).takeIf { it.isDirectory }?.let { "${it.path}:$GUEST_DIR" }
|
||||
|
||||
/** Writes the guest's font preference for the app's current language; best effort. */
|
||||
@JvmStatic
|
||||
fun prepare(context: Context, root: File) {
|
||||
val conf = File(root, CONF)
|
||||
val text = preferenceConf(AppLanguage.effective(context))
|
||||
try {
|
||||
if (conf.isFile && conf.readText() == text) return
|
||||
conf.parentFile?.mkdirs()
|
||||
conf.writeText(text)
|
||||
} catch (e: Exception) {
|
||||
// fontconfig still finds every font; shared characters just take its own default shape.
|
||||
}
|
||||
}
|
||||
|
||||
/** The CJK variant whose shapes match how [locale]'s readers write shared characters. */
|
||||
fun cjkRegion(locale: Locale): String {
|
||||
val script = locale.script
|
||||
return when (locale.language) {
|
||||
"ja" -> "JP"
|
||||
"ko" -> "KR"
|
||||
"zh" -> when {
|
||||
script == "Hans" -> "SC"
|
||||
locale.country == "HK" || locale.country == "MO" -> "HK"
|
||||
script == "Hant" || locale.country == "TW" -> "TC"
|
||||
else -> "SC"
|
||||
}
|
||||
else -> "SC"
|
||||
}
|
||||
}
|
||||
|
||||
fun preferenceConf(locale: Locale): String {
|
||||
val region = cjkRegion(locale)
|
||||
fun alias(generic: String, latin: String, cjk: String) =
|
||||
" <alias binding=\"same\">\n <family>$generic</family>\n <prefer>\n" +
|
||||
" <family>$latin</family>\n <family>$cjk $region</family>\n" +
|
||||
" </prefer>\n </alias>\n"
|
||||
return "<?xml version=\"1.0\"?>\n<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">\n" +
|
||||
"<!-- Written by DroidDeck at session start for its language (${locale.toLanguageTag()}). -->\n" +
|
||||
"<fontconfig>\n" +
|
||||
alias("sans-serif", "DejaVu Sans", "Noto Sans CJK") +
|
||||
alias("serif", "DejaVu Serif", "Noto Serif CJK") +
|
||||
alias("monospace", "DejaVu Sans Mono", "Noto Sans Mono CJK") +
|
||||
"</fontconfig>\n"
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,12 @@ public final class LinuxRuntime {
|
||||
* session ended at once. A long host path behind it is proot's to shorten.
|
||||
*/
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
private static final String SYSTEM_FONTS = "/system/fonts";
|
||||
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
public static File debugLogDir() {
|
||||
return new File(android.os.Environment.getExternalStoragePublicDirectory(
|
||||
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
|
||||
public static File debugLogDir(Context context) {
|
||||
return new File(context.getFilesDir(), "logs");
|
||||
}
|
||||
|
||||
private LinuxRuntime() {}
|
||||
@@ -207,6 +207,7 @@ public final class LinuxRuntime {
|
||||
File shm = new File(context.getCacheDir(), "shm");
|
||||
shm.mkdirs();
|
||||
bind(cmd, shm.getPath() + ":/dev/shm");
|
||||
bindSystemFonts(cmd, root);
|
||||
|
||||
// Android denies apps these; glibc, Steam and libcap read them at startup.
|
||||
File fakeProc = new File(root, "etc/droiddeck/proc");
|
||||
@@ -270,13 +271,6 @@ public final class LinuxRuntime {
|
||||
if (extraBinds != null) {
|
||||
for (String spec : extraBinds) bind(cmd, spec);
|
||||
}
|
||||
// Android's fonts, for the scripts the runtime's own fonts lack (GuestFonts). Last, so a
|
||||
// fast path that keeps only its first binds drops these before a game library.
|
||||
String fonts = GuestFonts.bindSpec();
|
||||
if (fonts != null) {
|
||||
bind(cmd, fonts);
|
||||
GuestFonts.prepare(context, root);
|
||||
}
|
||||
List<String> specs = new ArrayList<>();
|
||||
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
|
||||
return specs;
|
||||
@@ -472,6 +466,15 @@ public final class LinuxRuntime {
|
||||
return byType;
|
||||
}
|
||||
|
||||
private static void bindSystemFonts(List<String> cmd, File root) {
|
||||
File fonts = new File(SYSTEM_FONTS);
|
||||
File target = new File(root, GUEST_SYSTEM_FONTS.substring(1));
|
||||
if (!fonts.isDirectory() || !fonts.canRead() || !new File(root, "usr/local").isDirectory()) return;
|
||||
if (target.isDirectory() || target.mkdirs()) {
|
||||
bind(cmd, SYSTEM_FONTS + ":" + GUEST_SYSTEM_FONTS);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bind(List<String> cmd, String spec) {
|
||||
cmd.add("-b");
|
||||
cmd.add(spec);
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -6,6 +6,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
|
||||
@@ -673,6 +674,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(context, 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/")) { context.getString(R.string.cmgr_nightlies_only) }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.StatFs
|
||||
import android.os.storage.StorageManager
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,25 @@ object GameStorage {
|
||||
}
|
||||
}
|
||||
|
||||
class Library(val host: File, val guest: String, val label: String)
|
||||
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
|
||||
val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) }
|
||||
val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)
|
||||
Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' '))
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
/** The label the client shows for a chosen folder: its last name, or the volume's. */
|
||||
fun labelFor(context: Context, path: String): String =
|
||||
options(context).firstOrNull { it.path == path }?.name
|
||||
|
||||
@@ -23,8 +23,10 @@ object SecondaryLibrary {
|
||||
return File(files, "steam-libraries/$digest")
|
||||
}
|
||||
|
||||
val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")
|
||||
|
||||
/** Parent binds come first; the more specific private directories override them in PRoot. */
|
||||
fun binds(files: File, library: File): List<String> {
|
||||
fun binds(files: File, library: File, guests: List<String> = CARD_GUESTS): List<String> {
|
||||
val private = privateRoot(files, library)
|
||||
val apps = File(library, "steamapps")
|
||||
val tools = LinkedHashMap(bootstrapTools)
|
||||
@@ -57,7 +59,7 @@ object SecondaryLibrary {
|
||||
if (lower != relative) overrides.add(target to lower)
|
||||
}
|
||||
}
|
||||
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
|
||||
return guests.flatMap { guest ->
|
||||
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,9 @@ object SessionArtifacts {
|
||||
/** Written last; a folder without it did not get its ending. */
|
||||
const val COMPLETE_MARKER = ".complete"
|
||||
|
||||
/** 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"
|
||||
|
||||
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
|
||||
fun collect(context: Context, dir: File, reason: String) {
|
||||
SessionLogCapture.stopFor(dir)
|
||||
try {
|
||||
val wayland = File(dir, "wayland.log")
|
||||
if (!wayland.exists()) {
|
||||
@@ -47,8 +45,7 @@ object SessionArtifacts {
|
||||
copySteamLogs(context, dir)
|
||||
// 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) {}
|
||||
scrubTree(dir)
|
||||
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
|
||||
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
|
||||
} catch (e: Exception) {
|
||||
@@ -56,62 +53,52 @@ object SessionArtifacts {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once, for session folders written before every file was scrubbed and before the device's own
|
||||
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
|
||||
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
|
||||
* A marker records it. Runs at app start with [finishAbandoned].
|
||||
*/
|
||||
@Synchronized
|
||||
fun scrubOlder(context: Context) {
|
||||
val current = SessionPaths.current()
|
||||
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
|
||||
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
|
||||
} ?: return
|
||||
if (dirs.isEmpty()) return
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
dirs.forEach { dir ->
|
||||
scrubFolder(dir)
|
||||
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
|
||||
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
|
||||
}
|
||||
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
|
||||
SessionPaths.sessionFolders(context).filter {
|
||||
it.parentFile == LinuxRuntime.debugLogDir(context) && it != SessionPaths.current() &&
|
||||
!File(it, COMPLETE_MARKER).exists()
|
||||
}.forEach { scrubTree(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's own files (session.log, app.log, desktop.log, the Steam desktop client's
|
||||
* steam-desktop.log, ...) were written as they happened, unscrubbed; this pass puts every one
|
||||
* 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 ->
|
||||
if (!LogRedactor.isText(f)) return@forEach
|
||||
internal fun scrubTree(dir: File) {
|
||||
SessionLogFiles.candidates(dir).forEach { file ->
|
||||
if (!SessionLogFiles.safeFile(dir, file)) return@forEach
|
||||
var tmp: File? = null
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam's logs: redacted into steam/, never copied verbatim. */
|
||||
private fun copySteamLogs(context: Context, dir: File) {
|
||||
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 ->
|
||||
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
|
||||
tmp = File.createTempFile(".redacted-", ".tmp", dir)
|
||||
val size = file.length()
|
||||
val modified = file.lastModified()
|
||||
val limit = if (file.parentFile?.name == "steam") SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
|
||||
tmp.bufferedWriter().use { SessionLogFiles.scrubTo(dir, file, it, limit) }
|
||||
if (file.length() == size && file.lastModified() == modified) {
|
||||
check(tmp.renameTo(file))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${file.name}", e)
|
||||
} finally {
|
||||
tmp?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copySteamLogs(context: Context, dir: File) {
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
if (!logs.isDirectory || java.nio.file.Files.isSymbolicLink(logs.toPath())) return
|
||||
val out = File(dir, "steam")
|
||||
if (java.nio.file.Files.isSymbolicLink(out.toPath()) || !(out.isDirectory || out.mkdirs())) return
|
||||
SessionLogFiles.steamNames.map { File(logs, it) }.filter { it.exists() }.forEach { src ->
|
||||
val tmp = File.createTempFile(".redacted-", ".tmp", out)
|
||||
try {
|
||||
tmp.bufferedWriter().use { SessionLogFiles.scrubTo(logs, src, it, SessionLogFiles.STEAM_MAX_BYTES) }
|
||||
check(tmp.renameTo(File(out, src.name)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not scrub ${src.name}", e)
|
||||
} finally {
|
||||
tmp?.delete()
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,10 +109,10 @@ object SessionArtifacts {
|
||||
*/
|
||||
@Synchronized
|
||||
fun finishAbandoned(context: Context) {
|
||||
val parent = LinuxRuntime.debugLogDir()
|
||||
val abandoned = parent.listFiles { f ->
|
||||
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
|
||||
}?.sortedWith(SessionPaths.chronological) ?: return
|
||||
SessionLogMigration.run(context)
|
||||
val abandoned = SessionPaths.sessionFolders(context).filter {
|
||||
it.parentFile == LinuxRuntime.debugLogDir(context) && !File(it, COMPLETE_MARKER).exists()
|
||||
}
|
||||
if (abandoned.isEmpty()) return
|
||||
val current = SessionPaths.current()
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
@@ -143,12 +130,10 @@ 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)
|
||||
}
|
||||
if (newest) copySteamLogs(context, dir)
|
||||
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
|
||||
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
|
||||
scrubFolder(dir)
|
||||
scrubTree(dir)
|
||||
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) {
|
||||
@@ -168,7 +153,7 @@ object SessionArtifacts {
|
||||
val current = SessionPaths.current()
|
||||
val finished = SessionPaths.sessionFolders(context).filter { it != current && File(it, COMPLETE_MARKER).exists() }
|
||||
val old = finished.dropLast(SessionPaths.KEEP_SESSIONS)
|
||||
old.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
old.forEach { SessionLogFiles.deleteTree(it) }
|
||||
if (old.isNotEmpty()) Log.i(TAG, "deleted ${old.size} session folder(s) past the newest ${SessionPaths.KEEP_SESSIONS}")
|
||||
}
|
||||
|
||||
@@ -181,10 +166,11 @@ object SessionArtifacts {
|
||||
fun clearAll(context: Context): Int {
|
||||
val current = SessionPaths.current()
|
||||
val gone = SessionPaths.sessionFolders(context).filter { it != current }
|
||||
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
|
||||
gone.forEach { SessionLogFiles.deleteTree(it) }
|
||||
LinuxRuntime.debugLogDir(context).listFiles { f -> f.name == SessionPaths.TOOLS_DIR || f.name.matches(Regex("tools-\\d+")) }
|
||||
?.forEach { SessionLogFiles.deleteTree(it) }
|
||||
// Before tools/, those logs sat loose beside the session folders.
|
||||
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
|
||||
LinuxRuntime.debugLogDir(context).listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
|
||||
Log.i(TAG, "cleared ${gone.size} session folder(s)")
|
||||
return gone.size
|
||||
}
|
||||
|
||||
@@ -6,23 +6,40 @@ 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"
|
||||
const val DEFAULT_RESOLUTION = "1280x720"
|
||||
|
||||
/**
|
||||
* 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) screenSize(panel) else {
|
||||
// Legacy displays may exceed the custom dialog's size limit. Keep their exact size.
|
||||
val parts = choice.split('x').map { it.toIntOrNull() }
|
||||
if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
|
||||
else 1280 to 720
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/** Fixed dimensions and the current panel, with no duplicate panel-size preset. */
|
||||
fun resolutionOptions(panel: Pair<Int, Int>): List<String> =
|
||||
listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080")
|
||||
/** 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
|
||||
|
||||
@@ -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",
|
||||
@@ -104,6 +108,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")
|
||||
@@ -115,7 +123,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)
|
||||
}
|
||||
@@ -341,26 +349,9 @@ object SessionFiles {
|
||||
indent + "}",
|
||||
).joinToString(newline)
|
||||
|
||||
/**
|
||||
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
|
||||
* folder rather than dug out of app-private storage - but the session script redirects its own
|
||||
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
|
||||
* shell. So a public directory is used only once it is proven writable; otherwise the app's
|
||||
* own files directory, which is bound into the session anyway, stands in.
|
||||
*/
|
||||
fun logDirectory(context: Context): File {
|
||||
val public = LinuxRuntime.debugLogDir()
|
||||
if (public.isDirectory || public.mkdirs()) {
|
||||
val probe = File(public, ".writable")
|
||||
try {
|
||||
if (probe.createNewFile() || probe.isFile) {
|
||||
probe.delete()
|
||||
return public
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
fun logDirectory(context: Context): File =
|
||||
LinuxRuntime.debugLogDir(context).apply {
|
||||
check(!java.nio.file.Files.isSymbolicLink(toPath()))
|
||||
check(isDirectory || mkdirs())
|
||||
}
|
||||
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
|
||||
return File(context.filesDir, "logs").apply { mkdirs() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.Writer
|
||||
import java.nio.channels.Channels
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
import java.nio.file.StandardOpenOption.READ
|
||||
|
||||
internal object SessionLogFiles {
|
||||
const val MAX_BYTES = 64L * 1024 * 1024
|
||||
const val STEAM_MAX_BYTES = 8L * 1024 * 1024
|
||||
private const val MAX_LINE_CHARS = 64 * 1024
|
||||
|
||||
val steamNames = setOf(
|
||||
"bootstrap_log.txt", "connection_log.txt", "console_log.txt", "content_log.txt",
|
||||
"controller.txt", "compat_log.txt", "cef_log.txt", "cef_log.previous.txt",
|
||||
"shader_log.txt", "systemaudiomanager.txt", "steamwebhelper.log", "webhelper.txt",
|
||||
"steamui_html.txt", "steamui_system.txt", "steamui_library.txt", "steamui_login.txt",
|
||||
"remote_connections.txt", "streaming_log.txt", "video.txt", "vrclient_steam.txt"
|
||||
)
|
||||
private val paths = setOf(
|
||||
"session.log", "session.log.1", "app.log", "crash.log", "crash-app.txt",
|
||||
"wayland.log", "audio.log", "device.txt", "network.txt", "events.jsonl",
|
||||
"desktop.log", "steam-desktop.log", "steam.log", "pad.log", "storage.log",
|
||||
"ended-without-teardown.txt", "droiddeck-esync/launches.log",
|
||||
"droiddeck-esync/tools.tsv", "droiddeck-esync/wanted.tsv",
|
||||
"droiddeck-esync/prefixes.json", "droiddeck-fex/status.json", "droiddeck-fex/Config.json"
|
||||
) + steamNames.map { "steam/$it" }
|
||||
|
||||
fun candidates(folder: File): List<File> = paths.map { File(folder, it) }
|
||||
.filter { Files.exists(it.toPath(), NOFOLLOW_LINKS) }
|
||||
|
||||
fun deleteTree(file: File) {
|
||||
if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return
|
||||
Files.walk(file.toPath()).use { stream ->
|
||||
stream.iterator().asSequence().toList().asReversed().forEach { Files.deleteIfExists(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun safeFile(root: File, file: File): Boolean {
|
||||
val base = root.toPath().toAbsolutePath().normalize()
|
||||
var path = file.toPath().toAbsolutePath().normalize()
|
||||
if (!path.startsWith(base) || path == base) return false
|
||||
while (path != base) {
|
||||
if (Files.isSymbolicLink(path)) return false
|
||||
path = path.parent ?: return false
|
||||
}
|
||||
return !Files.isSymbolicLink(base) && Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS)
|
||||
}
|
||||
|
||||
fun scrubTo(root: File, file: File, out: Writer, maxBytes: Long = MAX_BYTES) {
|
||||
if (!safeFile(root, file)) throw IOException("unsafe path")
|
||||
Files.newByteChannel(file.toPath(), READ, NOFOLLOW_LINKS).use { channel ->
|
||||
var remaining = channel.size()
|
||||
if (remaining >= maxBytes) throw IOException("size limit")
|
||||
val source = Channels.newInputStream(channel)
|
||||
val snapshot = object : InputStream() {
|
||||
override fun read(): Int {
|
||||
val one = ByteArray(1)
|
||||
return if (read(one, 0, 1) < 0) -1 else one[0].toInt() and 255
|
||||
}
|
||||
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||
if (length == 0) return 0
|
||||
if (remaining == 0L) return -1
|
||||
val count = source.read(bytes, offset, minOf(length.toLong(), remaining).toInt())
|
||||
if (count < 0) throw IOException("truncated snapshot")
|
||||
remaining -= count
|
||||
return count
|
||||
}
|
||||
}
|
||||
val decoder = Charsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
val reader = InputStreamReader(snapshot, decoder)
|
||||
val line = StringBuilder()
|
||||
val buffer = CharArray(4096)
|
||||
fun writeLine() {
|
||||
out.write(LogRedactor.redact(line.toString().removeSuffix("\r")))
|
||||
out.write("\n")
|
||||
line.setLength(0)
|
||||
}
|
||||
while (true) {
|
||||
val count = reader.read(buffer)
|
||||
if (count < 0) break
|
||||
for (i in 0 until count) {
|
||||
val char = buffer[i]
|
||||
if (char == '\u0000') throw IOException("binary data")
|
||||
if (char == '\n') writeLine() else {
|
||||
if (line.length >= MAX_LINE_CHARS) throw IOException("line limit")
|
||||
line.append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (line.isNotEmpty()) {
|
||||
if (channel.size() != channel.position()) out.write("<incomplete live line withheld>\n")
|
||||
else writeLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption.NOFOLLOW_LINKS
|
||||
|
||||
internal object SessionLogMigration {
|
||||
fun run(context: Context) {
|
||||
val old = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), LinuxRuntime.DEBUG_LOG_DIR)
|
||||
val target = LinuxRuntime.debugLogDir(context)
|
||||
if (!old.isDirectory || Files.isSymbolicLink(old.toPath())) return
|
||||
check(target.isDirectory || target.mkdirs())
|
||||
old.listFiles().orEmpty().filter {
|
||||
SessionPaths.isSessionFolder(it) || it.name == SessionPaths.TOOLS_DIR ||
|
||||
(it.isFile && it.name.endsWith(".log"))
|
||||
}.forEach { source ->
|
||||
try {
|
||||
move(source, target)
|
||||
} catch (e: Exception) {
|
||||
Log.w("SessionLogMigration", "could not move legacy logs into private storage", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun move(source: File, target: File) {
|
||||
check(!Files.isSymbolicLink(target.toPath()))
|
||||
val paths = Files.walk(source.toPath()).use { it.iterator().asSequence().toList() }
|
||||
check(paths.all { !Files.isSymbolicLink(it) &&
|
||||
(Files.isRegularFile(it, NOFOLLOW_LINKS) || Files.isDirectory(it, NOFOLLOW_LINKS)) })
|
||||
var destination = File(target, source.name)
|
||||
var number = 1
|
||||
val stem = Regex("^session-\\d{8}-\\d{6}").find(source.name)?.value ?: source.name
|
||||
while (Files.exists(destination.toPath(), NOFOLLOW_LINKS)) {
|
||||
val name = if (source.isFile) "${source.nameWithoutExtension}-${number++}.${source.extension}"
|
||||
else "$stem-${number++}"
|
||||
destination = File(target, name)
|
||||
}
|
||||
if (source.renameTo(destination)) return
|
||||
val stage = File.createTempFile(".migrating-", ".tmp", target)
|
||||
val versions = paths.associate { it.toFile().let { file -> file to (file.length() to file.lastModified()) } }
|
||||
try {
|
||||
if (source.isDirectory) { check(stage.delete()); check(stage.mkdir()) }
|
||||
paths.forEach { path ->
|
||||
val file = path.toFile()
|
||||
val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
|
||||
if (Files.isDirectory(path, NOFOLLOW_LINKS)) {
|
||||
check(out.isDirectory || out.mkdirs())
|
||||
} else {
|
||||
check(file == source || SessionLogFiles.safeFile(source, file))
|
||||
Files.newByteChannel(path, java.nio.file.StandardOpenOption.READ, NOFOLLOW_LINKS).use { input ->
|
||||
out.outputStream().use { output -> java.nio.channels.Channels.newInputStream(input).copyTo(output) }
|
||||
}
|
||||
check(out.length() == versions.getValue(file).first)
|
||||
}
|
||||
}
|
||||
check(versions.all { (file, version) -> file.length() == version.first && file.lastModified() == version.second })
|
||||
paths.asReversed().forEach { path ->
|
||||
val file = path.toFile()
|
||||
val out = if (file == source) stage else File(stage, file.relativeTo(source).path)
|
||||
check(out.setLastModified(versions.getValue(file).second))
|
||||
}
|
||||
check(stage.renameTo(destination))
|
||||
paths.asReversed().forEach { Files.delete(it) }
|
||||
} finally {
|
||||
stage.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.R
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
@@ -11,63 +17,86 @@ import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Zips the most recent session's log folder and hands it to Android's share sheet. Works where
|
||||
* the folder could not be written to Downloads (no storage permission, or a device that refuses
|
||||
* 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? {
|
||||
if (!folder.isDirectory) return null
|
||||
val files = folder.walkTopDown().filter { it.isFile }.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.
|
||||
@Synchronized
|
||||
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
|
||||
if (!folder.isDirectory || java.nio.file.Files.isSymbolicLink(folder.toPath())) return null
|
||||
val files = SessionLogFiles.candidates(folder).map { Triple(folder, it, it.relativeTo(folder).invariantSeparatorsPath) }
|
||||
val liveRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
val live = if (folder == SessionPaths.current()) SessionLogFiles.steamNames
|
||||
.filter { name -> files.none { it.third == "steam/$name" } }
|
||||
.map { File(liveRoot, it) }.filter { java.nio.file.Files.exists(it.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) }
|
||||
.map { Triple(liveRoot, it, "steam/${it.name}") } else emptyList()
|
||||
val sources = files + live
|
||||
if (sources.isEmpty()) return null
|
||||
val out = File(context.cacheDir, "shared-logs").apply { SessionLogFiles.deleteTree(this); check(mkdirs()) }
|
||||
val name = folder.name.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val zip = File(out, "DroidDeck-$name.zip")
|
||||
val partial = File(out, ".export")
|
||||
val stage = File.createTempFile("redacted-", ".tmp", context.cacheDir)
|
||||
val omitted = ArrayList<String>()
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
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) }
|
||||
try {
|
||||
ZipOutputStream(partial.outputStream().buffered()).use { z ->
|
||||
sources.forEachIndexed { index, (root, src, relative) ->
|
||||
val limit = if (relative.startsWith("steam/")) SessionLogFiles.STEAM_MAX_BYTES else SessionLogFiles.MAX_BYTES
|
||||
val failure = runCatching {
|
||||
stage.bufferedWriter().use { SessionLogFiles.scrubTo(root, src, it, limit) }
|
||||
}.exceptionOrNull()
|
||||
if (failure == null) {
|
||||
z.putNextEntry(ZipEntry("$name/$relative"))
|
||||
stage.inputStream().use { it.copyTo(z) }
|
||||
z.closeEntry()
|
||||
} else {
|
||||
omitted += "$relative: omitted because it could not be safely sanitized."
|
||||
}
|
||||
onProgress?.invoke((index + 1).toFloat() / sources.size)
|
||||
}
|
||||
if (omitted.isNotEmpty()) {
|
||||
z.putNextEntry(ZipEntry("$name/omitted.txt"))
|
||||
z.write((omitted.joinToString("\n") + "\n").toByteArray(Charsets.UTF_8))
|
||||
z.closeEntry()
|
||||
}
|
||||
}
|
||||
check(partial.renameTo(zip))
|
||||
return zip
|
||||
} catch (e: Exception) {
|
||||
partial.delete()
|
||||
zip.delete()
|
||||
throw e
|
||||
} finally {
|
||||
stage.delete()
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
if (LogRedactor.isText(f)) {
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubTo(f, w)
|
||||
w.flush()
|
||||
} else {
|
||||
f.inputStream().use { it.copyTo(z) }
|
||||
}
|
||||
z.closeEntry()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's own logs as they stand, for a session shared while it runs. The session script
|
||||
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
|
||||
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
|
||||
* touch report needs most. Only for the running session: an older folder would get this
|
||||
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
|
||||
*/
|
||||
private fun liveSteamLogs(context: Context, folder: File): List<File> {
|
||||
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
|
||||
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
|
||||
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
|
||||
}
|
||||
|
||||
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
|
||||
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
|
||||
fun shareIntent(context: Context, zip: File): Intent {
|
||||
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
|
||||
@@ -7,29 +7,6 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
|
||||
* named for the day, that day's session number and what was run, so the one a report is about can
|
||||
* be picked out without opening any:
|
||||
*
|
||||
* ```
|
||||
* Download/DroidDeck/2026-09-30-03-steam/
|
||||
* device.txt what this device is, and every setting the session ran with
|
||||
* session.log the guest session: proot, gamescope, the client's stdout
|
||||
* wayland.log the app's compositor
|
||||
* steam.log the Steam client's own log, scrubbed (Steam mode)
|
||||
* steam/ the rest of the client's logs, scrubbed (Steam mode)
|
||||
* desktop.log labwc, the panel and the programs on it (desktop mode)
|
||||
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
|
||||
* ```
|
||||
*
|
||||
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
|
||||
* pruned with the rest.
|
||||
*
|
||||
* The folder is claimed by whoever starts first - the activity starts the compositor before the
|
||||
* service starts the session - so both write into the same one, and a recreated activity (a
|
||||
* foldable opening mid-session) joins the folder in progress instead of opening another.
|
||||
*/
|
||||
object SessionPaths {
|
||||
private const val TAG = "SessionPaths"
|
||||
|
||||
@@ -50,12 +27,6 @@ object SessionPaths {
|
||||
@Volatile
|
||||
private var ended: File? = null
|
||||
|
||||
/**
|
||||
* The folder for the session now starting, or the one already in progress. With logs turned
|
||||
* off on the main screen the folder lives in the app's cache instead of Downloads - the
|
||||
* scripts and the compositor still need somewhere to write - and [release] deletes it.
|
||||
* [label] says what is being run (see [label]); without one it comes from the session's state.
|
||||
*/
|
||||
@Synchronized
|
||||
fun beginOrCurrent(context: Context, label: String? = null): File {
|
||||
dir?.let { if (it.isDirectory) return it }
|
||||
@@ -104,8 +75,8 @@ object SessionPaths {
|
||||
// next session may have claimed its own by the time the last one's collector gets here.
|
||||
if (dir == ended) dir = null
|
||||
// A folder kept in the cache was never meant to outlive its session.
|
||||
if (ended != null && ended.path.startsWith(context.cacheDir.path)) {
|
||||
com.droiddeck.launcher.core.FileUtils.delete(ended)
|
||||
if (ended.parentFile == File(context.cacheDir, "session-logs")) {
|
||||
SessionLogFiles.deleteTree(ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,22 +94,28 @@ object SessionPaths {
|
||||
|
||||
/** Whether [f] is a session's folder, in either naming. */
|
||||
fun isSessionFolder(f: File): Boolean =
|
||||
f.isDirectory && (NAMED.matches(f.name) || LEGACY.matches(f.name))
|
||||
f.isDirectory && !java.nio.file.Files.isSymbolicLink(f.toPath()) &&
|
||||
(NAMED.matches(f.name) || LEGACY.matches(f.name))
|
||||
|
||||
/**
|
||||
* Oldest first: by day, then by when in the day. A folder from before the new naming sorts
|
||||
* ahead of a new one from the same day, which it always predates.
|
||||
*/
|
||||
val chronological: Comparator<File> = compareBy { f ->
|
||||
val chronological: Comparator<File> = compareBy<File> { f ->
|
||||
NAMED.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}${g[2]}${g[3]}1${g[4].padStart(6, '0')}" }
|
||||
?: LEGACY.matchEntire(f.name)?.groupValues?.let { g -> "${g[1]}0${g[2]}${g[3].removePrefix("-").padStart(2, '0')}" }
|
||||
?: f.name
|
||||
}
|
||||
}.thenBy { it.lastModified() }
|
||||
|
||||
/** Every session folder in the places logs are written, oldest first. */
|
||||
fun sessionFolders(context: Context): List<File> =
|
||||
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
|
||||
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
|
||||
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(context),
|
||||
File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS),
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.DEBUG_LOG_DIR))
|
||||
.flatMap { parent ->
|
||||
if (java.nio.file.Files.isSymbolicLink(parent.toPath())) emptyList()
|
||||
else parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList()
|
||||
}
|
||||
.sortedWith(chronological)
|
||||
|
||||
internal fun slug(text: String): String =
|
||||
|
||||
@@ -10,8 +10,11 @@ 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"
|
||||
private const val LEGACY_SUSPEND_DOWNLOADS = "downloads"
|
||||
private const val STEAM_DOWNLOADS_IN_BACKGROUND = "steamDownloadsInBackground"
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
@@ -159,49 +162,20 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* The Steam client's own sound through the DirectAudio relay instead of the classic AAudio
|
||||
* sink. Off by default: on an AYN Thor (Android 13, 20 ms bursts) the relay path stayed choppy
|
||||
* where the classic sink - the one 0.1.5 shipped - was fine.
|
||||
* sink. On unless the user picked Classic.
|
||||
*/
|
||||
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", false)
|
||||
fun clientDirectAudio(context: Context): Boolean = prefs(context).getBoolean("clientDirectAudio", true)
|
||||
|
||||
fun setClientDirectAudio(context: Context, on: Boolean) {
|
||||
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. A game whose resolution differs
|
||||
* from the screen's (DiRT 3 at 1280x720) fights it: it rebuilds its swapchain on every forced
|
||||
* resize and the picture flickers between its own size and the screen's, often from the first
|
||||
* menu, before the player can reach its resolution setting. Off unless turned on.
|
||||
*/
|
||||
fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", false)
|
||||
|
||||
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
|
||||
@@ -519,7 +493,7 @@ object SessionPrefs {
|
||||
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) return 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
|
||||
@@ -529,7 +503,7 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
fun setResolutionChoice(context: Context, mode: String, choice: String) {
|
||||
val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else {
|
||||
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}"
|
||||
}
|
||||
@@ -546,6 +520,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) }
|
||||
@@ -603,8 +593,25 @@ object SessionPrefs {
|
||||
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
|
||||
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
|
||||
|
||||
private val addedGameLock = Any()
|
||||
|
||||
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
|
||||
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
|
||||
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
|
||||
}
|
||||
|
||||
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
|
||||
|
||||
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
|
||||
synchronized(addedGameLock) {
|
||||
val unchanged = addedGameExe(context, folderPath) == expected
|
||||
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
|
||||
unchanged
|
||||
}
|
||||
|
||||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
|
||||
}
|
||||
|
||||
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
|
||||
@@ -791,13 +798,41 @@ 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 }
|
||||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||||
?.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 (mode != SessionService.MODE_STEAM && it == SUSPEND_NATIVE) 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()
|
||||
val prefs = prefs(context)
|
||||
val key = "suspendPolicy.${prefMode(mode)}"
|
||||
prefs.edit().apply {
|
||||
if (mode == SessionService.MODE_STEAM && prefs.getString(key, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, true)
|
||||
}
|
||||
putString(key, normalized)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun steamDownloadsInBackground(context: Context): Boolean {
|
||||
val prefs = prefs(context)
|
||||
return prefs.getBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, false) ||
|
||||
prefs.getString("suspendPolicy.${SessionService.MODE_STEAM}", null) == LEGACY_SUSPEND_DOWNLOADS
|
||||
}
|
||||
|
||||
fun setSteamDownloadsInBackground(context: Context, enabled: Boolean) {
|
||||
val prefs = prefs(context)
|
||||
val policyKey = "suspendPolicy.${SessionService.MODE_STEAM}"
|
||||
prefs.edit().apply {
|
||||
putBoolean(STEAM_DOWNLOADS_IN_BACKGROUND, enabled)
|
||||
if (prefs.getString(policyKey, null) == LEGACY_SUSPEND_DOWNLOADS) {
|
||||
putString(policyKey, SUSPEND_AUTO)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
// ── Game storage ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.TrafficStats
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
@@ -43,6 +44,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
|
||||
@@ -74,12 +76,21 @@ class SessionService : Service() {
|
||||
private val auxiliaryProcessesLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var suspendController: SessionSuspendController? = null
|
||||
private val steamDownloadMonitor = SteamDownloadMonitor()
|
||||
private var downloadMonitorTask: Runnable? = null
|
||||
private var downloadActive = false
|
||||
private var suspendDownloadNow = false
|
||||
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
|
||||
private var steamDownloadsInBackground = false
|
||||
private var activityVisible = true
|
||||
private var screenOn = true
|
||||
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
|
||||
@@ -162,6 +173,7 @@ class SessionService : Service() {
|
||||
ACTION_ACTIVITY_VISIBLE -> {
|
||||
if (!SessionState.pipActive) pipTask = false
|
||||
activityVisible = true
|
||||
suspendDownloadNow = false
|
||||
resumeSteamSleepOnReturn()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -176,10 +188,18 @@ class SessionService : Service() {
|
||||
ACTION_SUSPEND_POLICY_CHANGED -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
updateDownloadMonitoring()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SUSPEND_NOW -> {
|
||||
if (!SessionState.running) return START_NOT_STICKY
|
||||
suspendDownloadNow = true
|
||||
updateSuspendPolicy()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_RESUME -> {
|
||||
resumeSession()
|
||||
return START_NOT_STICKY
|
||||
@@ -194,10 +214,15 @@ class SessionService : Service() {
|
||||
}
|
||||
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
steamSleepToken = null
|
||||
completeNativeSleep()
|
||||
suspendOperationPending = false
|
||||
suspendAttemptFailed = false
|
||||
suspendController = null
|
||||
@@ -252,9 +277,6 @@ class SessionService : Service() {
|
||||
try {
|
||||
SessionArtifacts.collect(this, dir, "session stopped")
|
||||
} finally {
|
||||
// Last, so everything above is in the file it is about - and only this session's:
|
||||
// a session that replaced this one may already own the capture and the folder.
|
||||
SessionLogCapture.stopFor(dir)
|
||||
SessionPaths.release(this, dir)
|
||||
}
|
||||
}
|
||||
@@ -306,6 +328,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
|
||||
@@ -393,7 +423,7 @@ class SessionService : Service() {
|
||||
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
|
||||
FileUtils.clear(File(cacheDir, "shm"))
|
||||
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest)
|
||||
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir)
|
||||
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
|
||||
val fastPathKey = if (ProotFastPath.enabled(this)) {
|
||||
val prootBinds = LinuxRuntime.binds(
|
||||
@@ -405,6 +435,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
|
||||
|
||||
@@ -508,6 +541,7 @@ class SessionService : Service() {
|
||||
},
|
||||
)
|
||||
mainHandler.post { updateSuspendPolicy() }
|
||||
updateDownloadMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,9 +633,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
|
||||
@@ -613,6 +645,7 @@ class SessionService : Service() {
|
||||
// gamescope's realtime Vulkan queues (the session script turns this into
|
||||
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
|
||||
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
|
||||
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
@@ -741,6 +774,8 @@ class SessionService : Service() {
|
||||
fakeInputDir: File,
|
||||
sessionDir: File,
|
||||
guest: MutableList<String>,
|
||||
sessionRoot: File,
|
||||
runtimeDir: File,
|
||||
): ArrayList<String> {
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
@@ -861,6 +896,45 @@ class SessionService : Service() {
|
||||
} else if (roms != null) {
|
||||
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
|
||||
}
|
||||
val libraries = ArrayList<Pair<String, String>>()
|
||||
if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label)
|
||||
val gamesLibraries = GameStorage.gamesFolderLibraries(this)
|
||||
if (gamesLibraries.isNotEmpty()) {
|
||||
var room = if (ProotFastPath.enabled(this)) {
|
||||
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
|
||||
} else Int.MAX_VALUE
|
||||
for (games in gamesLibraries) {
|
||||
if (games.host.path + ":" + games.guest !in binds) continue
|
||||
val problem = GameStorage.prepare(this, games.host.path)
|
||||
if (problem != null) {
|
||||
Log.w(TAG, "steam library: $problem")
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1)
|
||||
if (extra.size > room) {
|
||||
Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session")
|
||||
continue
|
||||
}
|
||||
binds.addAll(extra)
|
||||
room -= extra.size
|
||||
libraries.add(games.guest to games.label)
|
||||
Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
|
||||
val json = org.json.JSONArray()
|
||||
for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' ')))
|
||||
listing.writeText(json.toString())
|
||||
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
|
||||
guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam library: the list could not be written; the card alone this session", e)
|
||||
}
|
||||
return binds
|
||||
}
|
||||
|
||||
@@ -1001,6 +1075,9 @@ class SessionService : Service() {
|
||||
activityVisible = true
|
||||
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
|
||||
manualPauseRequested = false
|
||||
suspendDownloadNow = false
|
||||
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = false
|
||||
updateSuspendPolicy()
|
||||
@@ -1079,11 +1156,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
|
||||
@@ -1151,18 +1245,30 @@ class SessionService : Service() {
|
||||
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) {
|
||||
manualPauseRequested = true
|
||||
}
|
||||
val controller = suspendController ?: return
|
||||
val hidden = !activityVisible || !screenOn
|
||||
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
|
||||
if (downloadsBlockingSuspend) acquireLocks()
|
||||
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
|
||||
completeNativeSleep()
|
||||
} else if (nativeSleepToken == null && steamSleepToken == null &&
|
||||
!SessionState.suspended && !suspendAttemptFailed) {
|
||||
prepareNativeSleep()
|
||||
}
|
||||
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
|
||||
SessionPrefs.SUSPEND_AUTO -> !activityVisible || !screenOn
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
|
||||
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
|
||||
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
|
||||
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 ->
|
||||
@@ -1175,6 +1281,7 @@ class SessionService : Service() {
|
||||
releaseLocks()
|
||||
refreshNotification()
|
||||
} else {
|
||||
completeNativeSleep()
|
||||
completeSteamSleep()
|
||||
suspendAttemptFailed = true
|
||||
Log.w(TAG, "could not confirm that the session stopped")
|
||||
@@ -1220,7 +1327,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) {
|
||||
@@ -1275,10 +1438,12 @@ class SessionService : Service() {
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
cancelQueuedSteamGame()
|
||||
completeNativeSleep()
|
||||
synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
SessionState.running = false
|
||||
}
|
||||
updateDownloadMonitoring()
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
@@ -1447,11 +1612,26 @@ class SessionService : Service() {
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
|
||||
.setContentText(getString(
|
||||
when {
|
||||
SessionState.suspended -> R.string.session_notification_paused
|
||||
downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER -> R.string.session_notification_downloads
|
||||
else -> R.string.session_notification
|
||||
}
|
||||
))
|
||||
.setContentIntent(open)
|
||||
.apply {
|
||||
if (SessionState.suspended) {
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.resume_session), resume).build())
|
||||
} else if (downloadActive && steamDownloadsInBackground &&
|
||||
suspendPolicy != SessionPrefs.SUSPEND_NEVER) {
|
||||
val suspend = PendingIntent.getService(
|
||||
this@SessionService, 3,
|
||||
Intent(this@SessionService, SessionService::class.java).setAction(ACTION_SUSPEND_NOW),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
addAction(Notification.Action.Builder(null, getString(R.string.suspend_now), suspend).build())
|
||||
}
|
||||
}
|
||||
.addAction(Notification.Action.Builder(null, getString(R.string.stop_session), stop).build())
|
||||
@@ -1465,6 +1645,40 @@ class SessionService : Service() {
|
||||
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
|
||||
private fun updateDownloadMonitoring() {
|
||||
val enabled = SessionState.running && SessionState.mode == MODE_STEAM &&
|
||||
steamDownloadsInBackground && suspendPolicy != SessionPrefs.SUSPEND_NEVER
|
||||
if (!enabled) {
|
||||
val wasActive = downloadActive
|
||||
downloadMonitorTask?.let(mainHandler::removeCallbacks)
|
||||
downloadMonitorTask = null
|
||||
steamDownloadMonitor.reset()
|
||||
downloadActive = false
|
||||
if (wasActive) refreshNotification()
|
||||
return
|
||||
}
|
||||
if (downloadMonitorTask != null) return
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (downloadMonitorTask !== this || !SessionState.running ||
|
||||
SessionState.mode != MODE_STEAM || !steamDownloadsInBackground ||
|
||||
suspendPolicy == SessionPrefs.SUSPEND_NEVER) {
|
||||
return
|
||||
}
|
||||
val wasActive = downloadActive
|
||||
downloadActive = steamDownloadMonitor.poll(
|
||||
LinuxRuntime.rootDir(this@SessionService),
|
||||
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
|
||||
)
|
||||
if (wasActive != downloadActive) refreshNotification()
|
||||
updateSuspendPolicy()
|
||||
mainHandler.postDelayed(this, DOWNLOAD_POLL_MS)
|
||||
}
|
||||
}
|
||||
downloadMonitorTask = poll
|
||||
mainHandler.post(poll)
|
||||
}
|
||||
|
||||
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
|
||||
private fun logControllersAtStart() {
|
||||
val pads = ArrayList<String>()
|
||||
@@ -1489,6 +1703,7 @@ class SessionService : Service() {
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
|
||||
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
|
||||
const val ACTION_SUSPEND_NOW = "com.droiddeck.launcher.SUSPEND_NOW"
|
||||
private const val ACTION_PIP_BEGIN = "com.droiddeck.launcher.PIP_BEGIN"
|
||||
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
|
||||
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
|
||||
@@ -1508,6 +1723,7 @@ class SessionService : Service() {
|
||||
private const val STEAM_PICKUP_MS = 1500L
|
||||
private const val STEAM_EXIT_MS = 10_000L
|
||||
private const val GAME_LAUNCH_RETRY_MS = 250L
|
||||
private const val DOWNLOAD_POLL_MS = 2_000L
|
||||
private const val STEAM_GAME_REQUEST = "steam-game"
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
internal class SteamDownloadMonitor {
|
||||
private data class ManifestState(
|
||||
val bytesDownloaded: Long,
|
||||
val incomplete: Boolean,
|
||||
)
|
||||
|
||||
private var previous = emptyMap<String, ManifestState>()
|
||||
private var lastProgressAt = 0L
|
||||
private var contentLogOffset = 0L
|
||||
private val activeContentUpdates = mutableSetOf<String>()
|
||||
private var lastContentActivityAt = 0L
|
||||
private var lastReceivedBytes = -1L
|
||||
private var lastReceivedAt = 0L
|
||||
|
||||
/**
|
||||
* [receivedBytes] is the app uid's received-byte counter (the guest runs as that uid), or a
|
||||
* negative value when Android does not report one. Steam writes its rate line about once a
|
||||
* minute and flushes manifest counters even less often, so live traffic is what bridges them.
|
||||
*/
|
||||
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L): Boolean {
|
||||
val current = manifests(runtimeRoot).associate { it.path to read(it) }
|
||||
val progressed = current.any { (path, state) ->
|
||||
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
|
||||
}
|
||||
if (progressed) lastProgressAt = now
|
||||
previous = current
|
||||
val manifestActive = progressed || lastProgressAt > 0L && now - lastProgressAt <= MANIFEST_PROGRESS_GRACE_MS &&
|
||||
current.values.any { it.incomplete }
|
||||
if (receiving(receivedBytes, now)) lastContentActivityAt = now
|
||||
return manifestActive || readContentActivity(runtimeRoot, now)
|
||||
}
|
||||
|
||||
private fun receiving(receivedBytes: Long, now: Long): Boolean {
|
||||
val previousBytes = lastReceivedBytes
|
||||
val previousAt = lastReceivedAt
|
||||
lastReceivedBytes = receivedBytes
|
||||
lastReceivedAt = now
|
||||
if (receivedBytes < 0L || previousBytes < 0L || receivedBytes < previousBytes || now <= previousAt) return false
|
||||
return (receivedBytes - previousBytes) * 1000L / (now - previousAt) >= MIN_RECEIVE_BYTES_PER_SEC
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
previous = emptyMap()
|
||||
lastProgressAt = 0L
|
||||
contentLogOffset = 0L
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
lastReceivedBytes = -1L
|
||||
lastReceivedAt = 0L
|
||||
}
|
||||
|
||||
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
|
||||
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
|
||||
if (!log.isFile) return false
|
||||
if (log.length() < contentLogOffset) {
|
||||
contentLogOffset = 0L
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
runCatching {
|
||||
RandomAccessFile(log, "r").use { input ->
|
||||
input.seek(contentLogOffset)
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
when {
|
||||
"Client version:" in line -> {
|
||||
activeContentUpdates.clear()
|
||||
lastContentActivityAt = 0L
|
||||
}
|
||||
CONTENT_UPDATE.find(line)?.let { match ->
|
||||
val appId = match.groupValues[1]
|
||||
val updateType = match.groupValues[2]
|
||||
val state = match.groupValues[3]
|
||||
val updateId = "$updateType:$appId"
|
||||
if ("Running Update" in state && "Stopping" !in state) {
|
||||
activeContentUpdates += updateId
|
||||
lastContentActivityAt = now
|
||||
} else {
|
||||
activeContentUpdates -= updateId
|
||||
}
|
||||
true
|
||||
} == true -> Unit
|
||||
CURRENT_RATE.find(line)?.groupValues?.get(1)?.toDoubleOrNull()?.let { it > 0.0 } == true ->
|
||||
lastContentActivityAt = now
|
||||
"Downloading " in line && " chunks for depot " in line ->
|
||||
lastContentActivityAt = now
|
||||
}
|
||||
if (activeContentUpdates.isNotEmpty()) lastContentActivityAt = now
|
||||
}
|
||||
contentLogOffset = input.filePointer
|
||||
}
|
||||
}
|
||||
return activeContentUpdates.isNotEmpty() && lastContentActivityAt > 0L &&
|
||||
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
|
||||
}
|
||||
|
||||
private fun manifests(runtimeRoot: File): List<File> {
|
||||
val steamRoots = linkedSetOf(File(runtimeRoot, "root/.local/share/Steam"))
|
||||
val primary = steamRoots.first()
|
||||
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
|
||||
runCatching {
|
||||
PATH.findAll(libraryFolders.readText()).forEach { match ->
|
||||
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
|
||||
}
|
||||
}
|
||||
return steamRoots.flatMap { root ->
|
||||
File(root, "steamapps").listFiles { file ->
|
||||
file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
|
||||
}.orEmpty().toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(file: File): ManifestState {
|
||||
val text = runCatching { file.readText() }.getOrDefault("")
|
||||
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
|
||||
val downloaded = value(text, "BytesDownloaded")?.toLongOrNull() ?: 0L
|
||||
val total = value(text, "BytesToDownload")?.toLongOrNull() ?: 0L
|
||||
return ManifestState(downloaded, flags != 4L && (total <= 0L || downloaded < total))
|
||||
}
|
||||
|
||||
private fun value(text: String, key: String): String? =
|
||||
Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
|
||||
|
||||
private companion object {
|
||||
const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
|
||||
// Longer than Steam's ~60 s between rate lines, for when no traffic counter is available.
|
||||
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
|
||||
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
|
||||
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
|
||||
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
|
||||
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +307,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),
|
||||
@@ -429,10 +430,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 ->
|
||||
@@ -444,7 +455,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
|
||||
|
||||
@@ -70,7 +70,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true)
|
||||
/** 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,31 +191,106 @@ 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)
|
||||
}
|
||||
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
|
||||
// needs: a long one (in some languages) would squeeze the name to a letter a line.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
|
||||
// needs: a long one (in some languages) would squeeze the name to a letter a line.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ class ModeSettings(
|
||||
val upscaleSharpness: Int = 75,
|
||||
val touchMode: String,
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val pipSupported: Boolean = false,
|
||||
val pipAutoEnter: Boolean = false,
|
||||
/** Steam only. */
|
||||
@@ -74,7 +75,7 @@ class ModeSettings(
|
||||
/** Steam only: whether single and double Back actions are swapped. */
|
||||
val backActionsInverted: Boolean = false,
|
||||
val directAudio: Boolean?,
|
||||
val clientDirectAudio: Boolean = false,
|
||||
val clientDirectAudio: Boolean = true,
|
||||
val mic: Boolean?,
|
||||
val renderer: String?,
|
||||
val gameStorage: String? = null,
|
||||
@@ -82,10 +83,10 @@ class ModeSettings(
|
||||
/** Steam only: record allocation and sampled storage timings in the next session's Share logs. */
|
||||
val storageDiagnostics: 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: forces game windows fullscreen (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,
|
||||
@@ -134,6 +135,7 @@ class ModeSettingsActions(
|
||||
val onUpscaleSharpness: (Int) -> Unit = {},
|
||||
val onTouch: (String) -> Unit,
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
|
||||
val onPipAutoEnter: (Boolean) -> Unit = {},
|
||||
val onOsc: (String) -> Unit,
|
||||
val onBackActionsInverted: (Boolean) -> Unit = {},
|
||||
@@ -145,8 +147,8 @@ class ModeSettingsActions(
|
||||
val onPickGameStorageFolder: () -> Unit = {},
|
||||
val onStorageDiagnostics: (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 = {},
|
||||
@@ -302,11 +304,18 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
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(R.string.mode_suspend_note),
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
@@ -424,10 +433,6 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((tab == ModeSettingsTab.GAMES || (!steam && tab == ModeSettingsTab.DISPLAY)) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
|
||||
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
|
||||
stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen)
|
||||
}
|
||||
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/") }
|
||||
@@ -446,12 +451,14 @@ 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 && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (s.syncBackend != null) SettingsRow(
|
||||
@@ -473,6 +480,7 @@ 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()
|
||||
|
||||
}
|
||||
|
||||
@@ -223,7 +223,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,12 +191,11 @@ class DrawerActions(
|
||||
val oscMode: String,
|
||||
val onScreenButtonsVisible: Boolean,
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val backActionsInverted: Boolean,
|
||||
val touchMode: String,
|
||||
val touchAuto: String,
|
||||
val fexPreset: String,
|
||||
/** Steam only: forces game windows fullscreen, 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. */
|
||||
@@ -223,10 +222,10 @@ class DrawerActions(
|
||||
val onQamButton: (Boolean) -> Unit = {},
|
||||
val onKeyboardButton: (Boolean) -> Unit = {},
|
||||
val onSuspendPolicy: (String) -> Unit,
|
||||
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
|
||||
val onBackActionsInverted: (Boolean) -> Unit,
|
||||
val onTouch: (String) -> Unit,
|
||||
val onFexPreset: (String) -> Unit,
|
||||
val onFillScreen: (Boolean) -> Unit = {},
|
||||
val onUpscaler: (Int) -> Unit = {},
|
||||
val onUpscaleSharpness: (Int) -> Unit = {},
|
||||
val onEffects: (ScreenEffects) -> Unit = {},
|
||||
@@ -407,11 +406,6 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
|
||||
}
|
||||
if (a.fillScreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
|
||||
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
|
||||
stringResource(R.string.display_force_fullscreen_live), a.fillScreen,
|
||||
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen)
|
||||
}
|
||||
}
|
||||
SessionDrawerPage.EFFECTS -> {
|
||||
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
|
||||
@@ -507,16 +501,25 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
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,
|
||||
)
|
||||
if (a.steam && a.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
a.steamDownloadsInBackground,
|
||||
onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -592,7 +595,7 @@ private fun DrawerActionButton(text: String, icon: androidx.compose.ui.graphics.
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
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() }
|
||||
@@ -605,7 +608,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
|
||||
|
||||
@@ -639,8 +639,8 @@ 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
|
||||
|
||||
@@ -115,8 +115,9 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
|
||||
}
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Landscape has the width for channels and status side by side; a narrow pane, or a small
|
||||
// phone's (where each channel's name and date would be cut to a word), stacks them.
|
||||
val stacked = LocalNarrowPane.current || maxWidth < 720.dp
|
||||
// phone's (where each channel's name and date would be cut to a word), stacks them. The
|
||||
// Thor's top screen leaves about 690dp here and keeps them side by side.
|
||||
val stacked = LocalNarrowPane.current || maxWidth < 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
|
||||
if (stacked) {
|
||||
StatusPanel(s, u, ua, me)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
@@ -41,7 +41,7 @@
|
||||
<string name="hud_fg_failed">%1$s: falló el motor</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: requiere Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · iniciando</string>
|
||||
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
|
||||
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam. Con Segunda biblioteca en Automático, Steam también puede instalar juegos en esta carpeta.</string>
|
||||
<string name="second_library_import_hint">Añade una biblioteca de Steam y busca juegos de Windows en ella. Los juegos que tienes usan sus entradas de Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
|
||||
<string name="added_games_forget_hint">Olvidar quita los accesos directos en la próxima sesión. Los archivos se conservan.</string>
|
||||
<string name="game_env_title">Entorno del juego</string>
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="mode_suspend">Comportamiento en segundo plano</string>
|
||||
<string name="mode_suspend_hint">Cuando la app se oculta o la pantalla se apaga.</string>
|
||||
<string name="mode_suspend_manual">Manual</string>
|
||||
<string name="mode_suspend_native">Nativo</string>
|
||||
<string name="mode_suspend_steam_note">Auto pausa y reanuda al mostrarse. Nativo prepara Steam para la suspensión primero. Manual espera a Reanudar. Nunca sigue ejecutándose.</string>
|
||||
<string name="mode_suspend_note">Auto pausa en segundo plano y reanuda al mostrarse. Manual espera a Reanudar. Nunca sigue ejecutándose.</string>
|
||||
<string name="mode_startup">Inicio</string>
|
||||
<string name="mode_steam_startup">Iniciar Steam con DroidDeck</string>
|
||||
@@ -307,8 +309,29 @@
|
||||
<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="wincomp_title">Componentes de Windows</string>
|
||||
<string name="wincomp_pad_hint">LB / RB saltan entre las secciones.</string>
|
||||
<string name="wincomp_card_none">Ninguno</string>
|
||||
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes de Bannerlator. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
|
||||
<string name="wincomp_offline">No se pudo acceder a la lista de componentes. Los componentes ya descargados se pueden seguir activando o desactivando.</string>
|
||||
<string name="wincomp_recommended">Recomendados para este juego</string>
|
||||
<string name="wincomp_found_bundled">El juego incluye %1$s</string>
|
||||
<string name="wincomp_found_shipped">El juego tiene %1$s (suele funcionar tal cual)</string>
|
||||
<string name="wincomp_found_steam">Steam instala %1$s con este juego</string>
|
||||
<string name="wincomp_already_here">Ya está en este juego</string>
|
||||
<string name="wincomp_no_recommendation">Nada en la carpeta del juego apunta a un componente.</string>
|
||||
<string name="wincomp_all">Todos los componentes (%1$d)</string>
|
||||
<string name="wincomp_installers">Necesitan un instalador de Windows (%1$d)</string>
|
||||
<string name="wincomp_installers_note">Estos ejecutan un instalador real dentro del prefijo (.NET, PhysX, XNA, los instaladores completos de Visual C++…). DroidDeck aún no puede hacerlo; es lo siguiente.</string>
|
||||
<string name="wincomp_show">Mostrar</string>
|
||||
<string name="wincomp_hide">Ocultar</string>
|
||||
<string name="wincomp_needs_installer">Necesita un instalador de Windows (próximamente)</string>
|
||||
<string name="wincomp_unsupported">Aún no compatible aquí</string>
|
||||
<string name="wincomp_loading">Cargando los componentes…</string>
|
||||
<string name="wincomp_failed">No se pudo instalar: %1$s</string>
|
||||
<string name="wincomp_downloaded">Descargado</string>
|
||||
<string name="wincomp_download_size">Descarga de %1$s</string>
|
||||
<string name="wincomp_next_launch">Se aplica la próxima vez que se inicie el juego.</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>
|
||||
@@ -330,8 +353,8 @@
|
||||
<string name="mode_wifi_explain_title">¿Activar la detección de Wi-Fi?</string>
|
||||
<string name="mode_wifi_explain_text">Android exige el permiso de ubicación para leer los nombres de Wi-Fi y detectar redes cercanas. DroidDeck usa este acceso para mostrar esas redes en Steam. No rastreamos tu ubicación. El acceso a internet funciona sin este permiso.</string>
|
||||
<string name="mode_storage">Almacenamiento de juegos</string>
|
||||
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente</string>
|
||||
<string name="mode_storage_auto_none">Automático - la tarjeta SD si está presente (ninguna ahora)</string>
|
||||
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente, y tus carpetas de juegos</string>
|
||||
<string name="mode_storage_auto_none">Automático - tus carpetas de juegos, y la tarjeta SD si está presente (ninguna ahora)</string>
|
||||
<string name="mode_storage_internal">Solo interno</string>
|
||||
<string name="mode_storage_folder">Carpeta: %1$s</string>
|
||||
<string name="mode_second_library">Segunda biblioteca</string>
|
||||
@@ -488,7 +511,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>
|
||||
@@ -618,7 +640,7 @@
|
||||
<string name="setup_choose">Elegir</string>
|
||||
<string name="setup_linux_apps">Apps de Linux (beta)</string>
|
||||
<string name="setup_store">Tienda Flathub</string>
|
||||
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck.</string>
|
||||
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse.</string>
|
||||
<string name="setup_store_off">Instala apps y juegos de Linux desde Flathub</string>
|
||||
<string name="setup_tool_files">Archivos</string>
|
||||
<string name="setup_tool_files_hint">Explorar y gestionar archivos</string>
|
||||
@@ -1290,8 +1312,6 @@
|
||||
<!-- Screens, files, controller and store -->
|
||||
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">Cómo se ajusta la imagen a la pantalla; los modos de nitidez enfocan donde se amplía.</string>
|
||||
<string name="mode_upscaler_note">Los modos de nitidez solo funcionan cuando la sesión es más pequeña que la pantalla; Lineal, Más cercano y Solo nitidez funcionan con cualquier tamaño. Usa algo de GPU.</string>
|
||||
<string name="mode_decky_plugins">Plugins</string>
|
||||
<string name="mode_decky_plugins_hint">Instala un ZIP de plugin para la próxima sesión de Steam.</string>
|
||||
<string name="mode_decky_plugin_downloading">Descargando…</string>
|
||||
@@ -1381,6 +1401,7 @@
|
||||
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">Interno</string>
|
||||
<string name="fm_drive_linux">Sistema Linux</string>
|
||||
<string name="fm_drive_sd">Tarjeta SD</string>
|
||||
<string name="fm_drive_storage">Almacenamiento</string>
|
||||
<string name="fm_select">Seleccionar</string>
|
||||
@@ -1547,6 +1568,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">Se quitó \"%1$s\" de Favoritos</string>
|
||||
<string name="fm_empty_directory">Carpeta vacía</string>
|
||||
<string name="fm_up_one_level">Subir un nivel</string>
|
||||
<string name="fm_choose_folder">Elige una carpeta</string>
|
||||
<string name="fm_choose_file">Elige un archivo</string>
|
||||
|
||||
@@ -1880,12 +1902,8 @@
|
||||
<string name="cmgr_checksum_mismatch">La suma de verificación no coincide</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">Auto (16:9+)</string>
|
||||
<string name="sprefs_shape_exact">Igual a la pantalla</string>
|
||||
<string name="sprefs_shape_wide">Siempre 16:9</string>
|
||||
<string name="sprefs_upscaler_linear">Lineal</string>
|
||||
<string name="sprefs_upscaler_nearest">Más cercano</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1 (ajustar)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR (calidad)</string>
|
||||
<string name="sprefs_upscaler_sharpen">Solo nitidez</string>
|
||||
<string name="sprefs_lod_bias_auto">Auto (según el escalado)</string>
|
||||
@@ -2072,11 +2090,15 @@
|
||||
<string name="display_filter_note">FSR, GSR y NIS se aplican al ampliar la imagen de la sesión. Solo nitidez también funciona sin ampliar.</string>
|
||||
<string name="display_sharpness">Nitidez del filtro</string>
|
||||
<string name="display_frame_rate">Velocidad de fotogramas</string>
|
||||
<string name="display_window_compatibility">Compatibilidad de ventanas de juego</string>
|
||||
<string name="display_force_fullscreen">Forzar ventanas de juego a pantalla completa</string>
|
||||
<string name="display_force_fullscreen_hint">Hace que cada ventana de juego ocupe toda la pantalla. Actívalo si un juego vuelve pequeño o en una esquina al cambiar de app; déjalo desactivado si un juego parpadea entre tamaños. Se aplica al instante durante una sesión.</string>
|
||||
<string name="display_force_fullscreen_live">Actívalo si un juego vuelve pequeño al cambiar de app; desactívalo si parpadea. Se aplica al instante.</string>
|
||||
<string name="display_look_filter">%1$s Cambia el escalado a %2$s.</string>
|
||||
<string name="display_match_screen">Igual a la pantalla (%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">Mostrar detalles del dispositivo</string>
|
||||
<string name="device_info_hide">Ocultar detalles del dispositivo</string>
|
||||
<string name="device_info_loading">Leyendo el dispositivo…</string>
|
||||
<string name="device_info_copy">Copiar</string>
|
||||
<string name="device_info_copy_hint">Copia estos detalles en un informe de error.</string>
|
||||
<string name="device_info_copied">Copiado.</string>
|
||||
<string name="force_ssbs_title">Forzar SSBS</string>
|
||||
<string name="force_ssbs_hint">Mantiene activado un ajuste de velocidad de la CPU que Wine sigue desactivando. Puede hacer más rápidos los juegos de Windows en Snapdragon 8 Elite; poco cambio en chips anteriores. Se aplica en el próximo inicio de un juego.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,951 @@
|
||||
<?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. Si Bibliothèque secondaire est sur Auto, Steam peut aussi installer des jeux dans ce dossier.</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, ainsi que vos dossiers de jeux</string>
|
||||
<string name="mode_storage_auto_none">Auto - Utiliser vos dossiers de jeux, ainsi que 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.</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>
|
||||
<string name="fm_up_one_level">Remonter d\'un niveau</string>
|
||||
<string name="fm_drive_linux">Système Linux</string>
|
||||
</resources>
|
||||
@@ -41,7 +41,7 @@
|
||||
<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="added_games_import_hint">複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。第2ライブラリが自動の場合、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>
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="mode_suspend">バックグラウンド動作</string>
|
||||
<string name="mode_suspend_hint">アプリが非表示または画面オフ時の動作。</string>
|
||||
<string name="mode_suspend_manual">手動</string>
|
||||
<string name="mode_suspend_native">ネイティブ</string>
|
||||
<string name="mode_suspend_steam_note">自動では一時停止し、表示すると再開します。ネイティブでは先にSteamをスリープに備えさせます。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。</string>
|
||||
<string name="mode_suspend_note">自動ではバックグラウンドで一時停止し、表示すると再開します。手動では「再開」をタップするまで待機します。「しない」では実行し続けます。</string>
|
||||
<string name="mode_startup">起動</string>
|
||||
<string name="mode_steam_startup">DroidDeck起動時にSteamを起動</string>
|
||||
@@ -307,8 +309,29 @@
|
||||
<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="wincomp_title">Windowsコンポーネント</string>
|
||||
<string name="wincomp_pad_hint">LB / RBでセクション間を移動します。</string>
|
||||
<string name="wincomp_card_none">なし</string>
|
||||
<string name="wincomp_applies">Protonに含まれないWindows DLLです(Bannerlatorのコンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
|
||||
<string name="wincomp_offline">コンポーネント一覧に接続できませんでした。ダウンロード済みのコンポーネントは引き続き切り替えられます。</string>
|
||||
<string name="wincomp_recommended">このゲームにおすすめ</string>
|
||||
<string name="wincomp_found_bundled">ゲームに%1$sが同梱されています</string>
|
||||
<string name="wincomp_found_shipped">ゲームに%1$sがあります(そのままで動くことが多いです)</string>
|
||||
<string name="wincomp_found_steam">Steamがこのゲームと一緒に%1$sをインストールします</string>
|
||||
<string name="wincomp_already_here">このゲームに導入済み</string>
|
||||
<string name="wincomp_no_recommendation">ゲームのフォルダーには、必要なコンポーネントを示すものがありません。</string>
|
||||
<string name="wincomp_all">すべてのコンポーネント(%1$d)</string>
|
||||
<string name="wincomp_installers">Windowsインストーラーが必要(%1$d)</string>
|
||||
<string name="wincomp_installers_note">これらはプレフィックス内で実際のインストーラーを実行します(.NET、PhysX、XNA、Visual C++のフルインストーラーなど)。DroidDeckはまだ対応していませんが、次に対応予定です。</string>
|
||||
<string name="wincomp_show">表示</string>
|
||||
<string name="wincomp_hide">非表示</string>
|
||||
<string name="wincomp_needs_installer">Windowsインストーラーが必要(対応予定)</string>
|
||||
<string name="wincomp_unsupported">ここではまだ未対応</string>
|
||||
<string name="wincomp_loading">コンポーネントを読み込み中…</string>
|
||||
<string name="wincomp_failed">インストールできませんでした: %1$s</string>
|
||||
<string name="wincomp_downloaded">ダウンロード済み</string>
|
||||
<string name="wincomp_download_size">ダウンロード %1$s</string>
|
||||
<string name="wincomp_next_launch">次にゲームを起動したときに反映されます。</string>
|
||||
<string name="mode_stretch169">16:9ゲームを引き伸ばす</string>
|
||||
<string name="mode_stretch169_hint">アスペクト比が16:9未満の画面で、黒帯を表示する代わりにゲームを引き伸ばして画面を埋めます。次回のセッションで有効になります。</string>
|
||||
<string name="mode_audio">オーディオ</string>
|
||||
@@ -330,8 +353,8 @@
|
||||
<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カードがあればSDカード</string>
|
||||
<string name="mode_storage_auto_none">自動 - SDカードがあればSDカード(現在なし)</string>
|
||||
<string name="mode_storage_auto">自動 - SDカードがあれば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">第2ライブラリ</string>
|
||||
@@ -486,7 +509,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_scaling">スケーリングモード</string>
|
||||
<string name="drawer_scaling_sharpness">スケーリングのシャープネス</string>
|
||||
<string name="drawer_effects">画面エフェクト</string>
|
||||
@@ -616,7 +638,7 @@
|
||||
<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_on">メニューに表示されます。一部のアプリは起動できない場合があります。</string>
|
||||
<string name="setup_store_off">FlathubからLinuxアプリとゲームをインストール</string>
|
||||
<string name="setup_tool_files">ファイル</string>
|
||||
<string name="setup_tool_files_hint">ファイルの閲覧と管理</string>
|
||||
@@ -1266,8 +1288,6 @@
|
||||
<!-- Screens, files, controller and store -->
|
||||
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">画面に合わせて映像をリサイズする方法。シャープ化モードでは拡大された部分をシャープにします。</string>
|
||||
<string name="mode_upscaler_note">シャープ化モードは、セッションが画面より小さい場合にのみ機能します。「リニア」「ニアレスト」「シャープのみ」は任意のサイズで機能します。GPUを少し消費します。</string>
|
||||
<string name="mode_decky_plugins">プラグイン</string>
|
||||
<string name="mode_decky_plugins_hint">次回のSteamセッション用にプラグインのzipをインストールします。</string>
|
||||
<string name="mode_decky_plugin_downloading">ダウンロード中…</string>
|
||||
@@ -1357,6 +1377,7 @@
|
||||
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">内部</string>
|
||||
<string name="fm_drive_linux">Linux システム</string>
|
||||
<string name="fm_drive_sd">SDカード</string>
|
||||
<string name="fm_drive_storage">ストレージ</string>
|
||||
<string name="fm_select">選択</string>
|
||||
@@ -1511,6 +1532,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">「%1$s」をお気に入りから削除しました</string>
|
||||
<string name="fm_empty_directory">空のフォルダ</string>
|
||||
<string name="fm_up_one_level">1つ上の階層へ</string>
|
||||
<string name="fm_choose_folder">フォルダを選択</string>
|
||||
<string name="fm_choose_file">ファイルを選択</string>
|
||||
|
||||
@@ -1842,12 +1864,8 @@
|
||||
<string name="cmgr_checksum_mismatch">チェックサムが一致しません</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">自動(16:9以上)</string>
|
||||
<string name="sprefs_shape_exact">画面に合わせる</string>
|
||||
<string name="sprefs_shape_wide">常に16:9</string>
|
||||
<string name="sprefs_upscaler_linear">リニア</string>
|
||||
<string name="sprefs_upscaler_nearest">ニアレスト</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1(フィット)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR(品質)</string>
|
||||
<string name="sprefs_upscaler_sharpen">シャープのみ</string>
|
||||
<string name="sprefs_lod_bias_auto">自動(スケーリングに合わせる)</string>
|
||||
@@ -2034,11 +2052,15 @@
|
||||
<string name="display_filter_note">FSR、GSR、NISはセッションの映像を拡大するときに機能します。「シャープのみ」は拡大しなくても機能します。</string>
|
||||
<string name="display_sharpness">フィルターのシャープネス</string>
|
||||
<string name="display_frame_rate">フレームレート</string>
|
||||
<string name="display_window_compatibility">ゲームウィンドウの互換性</string>
|
||||
<string name="display_force_fullscreen">ゲームウィンドウを強制的に全画面にする</string>
|
||||
<string name="display_force_fullscreen_hint">すべてのゲームウィンドウを画面サイズにします。アプリを切り替えた後にゲームが小さくなったり隅に表示されたりする場合はオンに、ゲームのサイズがちらつく場合はオフのままにしてください。セッション中もすぐに反映されます。</string>
|
||||
<string name="display_force_fullscreen_live">切り替え後にゲームが小さくなる場合はオン、ちらつく場合はオフ。すぐに反映されます。</string>
|
||||
<string name="display_look_filter">%1$s スケーリングを%2$sに設定します。</string>
|
||||
<string name="display_match_screen">画面に合わせる(%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">デバイスの詳細を表示</string>
|
||||
<string name="device_info_hide">デバイスの詳細を非表示</string>
|
||||
<string name="device_info_loading">デバイス情報を読み取り中…</string>
|
||||
<string name="device_info_copy">コピー</string>
|
||||
<string name="device_info_copy_hint">この詳細をバグ報告にコピーします。</string>
|
||||
<string name="device_info_copied">コピーしました。</string>
|
||||
<string name="force_ssbs_title">SSBSを強制</string>
|
||||
<string name="force_ssbs_hint">Wineがオフにし続けるCPUの速度設定をオンに保ちます。Snapdragon 8 EliteではWindowsゲームが速くなることがあります。古いチップではほとんど変化がありません。次のゲーム起動時に反映されます。</string>
|
||||
</resources>
|
||||
@@ -41,7 +41,7 @@
|
||||
<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="added_games_import_hint">여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 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>
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="mode_suspend">백그라운드 동작</string>
|
||||
<string name="mode_suspend_hint">앱이 숨겨지거나 화면이 꺼졌을 때의 동작입니다.</string>
|
||||
<string name="mode_suspend_manual">수동</string>
|
||||
<string name="mode_suspend_native">네이티브</string>
|
||||
<string name="mode_suspend_steam_note">자동: 일시 중지되고 다시 표시되면 재개됩니다. 네이티브: 먼저 Steam을 절전에 대비시킵니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다.</string>
|
||||
<string name="mode_suspend_note">자동: 백그라운드에서 일시 중지되고 다시 표시되면 재개됩니다. 수동: \"재개\"를 탭할 때까지 기다립니다. 안 함: 계속 실행됩니다.</string>
|
||||
<string name="mode_startup">시작</string>
|
||||
<string name="mode_steam_startup">DroidDeck을 열 때 Steam 시작</string>
|
||||
@@ -307,8 +309,29 @@
|
||||
<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="wincomp_title">Windows 구성 요소</string>
|
||||
<string name="wincomp_pad_hint">LB / RB로 섹션 사이를 이동합니다.</string>
|
||||
<string name="wincomp_card_none">없음</string>
|
||||
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, Bannerlator의 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
|
||||
<string name="wincomp_offline">구성 요소 목록에 연결할 수 없습니다. 이미 다운로드한 구성 요소는 계속 켜고 끌 수 있습니다.</string>
|
||||
<string name="wincomp_recommended">이 게임에 권장</string>
|
||||
<string name="wincomp_found_bundled">게임에 %1$s이(가) 포함되어 있습니다</string>
|
||||
<string name="wincomp_found_shipped">게임에 %1$s이(가) 있습니다 (대개 그대로 작동)</string>
|
||||
<string name="wincomp_found_steam">Steam이 이 게임과 함께 %1$s을(를) 설치합니다</string>
|
||||
<string name="wincomp_already_here">이 게임에 이미 있음</string>
|
||||
<string name="wincomp_no_recommendation">게임 폴더에서 필요한 구성 요소를 가리키는 것이 없습니다.</string>
|
||||
<string name="wincomp_all">모든 구성 요소 (%1$d)</string>
|
||||
<string name="wincomp_installers">Windows 설치 프로그램 필요 (%1$d)</string>
|
||||
<string name="wincomp_installers_note">이 항목들은 프리픽스 안에서 실제 설치 프로그램을 실행합니다(.NET, PhysX, XNA, 전체 Visual C++ 설치 프로그램 등). DroidDeck은 아직 지원하지 않으며 다음에 지원할 예정입니다.</string>
|
||||
<string name="wincomp_show">표시</string>
|
||||
<string name="wincomp_hide">숨기기</string>
|
||||
<string name="wincomp_needs_installer">Windows 설치 프로그램 필요 (예정)</string>
|
||||
<string name="wincomp_unsupported">아직 여기서 지원되지 않음</string>
|
||||
<string name="wincomp_loading">구성 요소를 불러오는 중…</string>
|
||||
<string name="wincomp_failed">설치할 수 없습니다: %1$s</string>
|
||||
<string name="wincomp_downloaded">다운로드됨</string>
|
||||
<string name="wincomp_download_size">%1$s 다운로드</string>
|
||||
<string name="wincomp_next_launch">다음에 게임을 시작할 때 적용됩니다.</string>
|
||||
<string name="mode_stretch169">16:9 게임 늘리기</string>
|
||||
<string name="mode_stretch169_hint">가로세로 비율이 16:9 미만인 화면에서 검은색 여백 대신 게임을 늘려 화면을 채웁니다. 다음 세션에 적용됩니다.</string>
|
||||
<string name="mode_audio">오디오</string>
|
||||
@@ -330,8 +353,8 @@
|
||||
<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 카드가 있으면 SD 카드 사용</string>
|
||||
<string name="mode_storage_auto_none">자동 - SD 카드가 있으면 SD 카드 사용(현재 SD 카드 없음)</string>
|
||||
<string name="mode_storage_auto">자동 - SD 카드가 있으면 SD 카드 사용, 게임 폴더도 사용</string>
|
||||
<string name="mode_storage_auto_none">자동 - 게임 폴더 사용, SD 카드가 있으면 SD 카드도 사용(현재 SD 카드 없음)</string>
|
||||
<string name="mode_storage_internal">내부 저장 공간만</string>
|
||||
<string name="mode_storage_folder">폴더: %1$s</string>
|
||||
<string name="mode_second_library">보조 라이브러리</string>
|
||||
@@ -486,7 +509,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_scaling">스케일링 모드</string>
|
||||
<string name="drawer_scaling_sharpness">스케일링 선명도</string>
|
||||
<string name="drawer_effects">화면 효과</string>
|
||||
@@ -616,7 +638,7 @@
|
||||
<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_on">메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다.</string>
|
||||
<string name="setup_store_off">Flathub에서 Linux 앱 및 게임 설치</string>
|
||||
<string name="setup_tool_files">파일</string>
|
||||
<string name="setup_tool_files_hint">파일 탐색 및 관리</string>
|
||||
@@ -1266,8 +1288,6 @@
|
||||
<!-- Screens, files, controller and store -->
|
||||
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">화면에 맞게 이미지 크기를 조정하는 방식입니다. 샤프닝 모드는 확대되는 부분을 선명하게 만듭니다.</string>
|
||||
<string name="mode_upscaler_note">샤프닝 모드는 세션이 화면보다 작을 때만 작동합니다. 선형, 최근접, 샤프닝 전용은 크기와 관계없이 작동합니다. GPU 시간을 약간 사용합니다.</string>
|
||||
<string name="mode_decky_plugins">플러그인</string>
|
||||
<string name="mode_decky_plugins_hint">다음 Steam 세션에 사용할 플러그인 ZIP을 설치합니다.</string>
|
||||
<string name="mode_decky_plugin_downloading">다운로드 중…</string>
|
||||
@@ -1357,6 +1377,7 @@
|
||||
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">내부</string>
|
||||
<string name="fm_drive_linux">Linux 시스템</string>
|
||||
<string name="fm_drive_sd">SD 카드</string>
|
||||
<string name="fm_drive_storage">저장소</string>
|
||||
<string name="fm_select">선택</string>
|
||||
@@ -1511,6 +1532,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">\"%1$s\"을(를) 즐겨찾기에서 제거했습니다</string>
|
||||
<string name="fm_empty_directory">빈 폴더</string>
|
||||
<string name="fm_up_one_level">상위 폴더로</string>
|
||||
<string name="fm_choose_folder">폴더 선택</string>
|
||||
<string name="fm_choose_file">파일 선택</string>
|
||||
|
||||
@@ -1842,12 +1864,8 @@
|
||||
<string name="cmgr_checksum_mismatch">체크섬 불일치</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">자동(16:9 이상)</string>
|
||||
<string name="sprefs_shape_exact">화면에 맞춤</string>
|
||||
<string name="sprefs_shape_wide">항상 16:9</string>
|
||||
<string name="sprefs_upscaler_linear">선형</string>
|
||||
<string name="sprefs_upscaler_nearest">최근접</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1(맞춤)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR(품질)</string>
|
||||
<string name="sprefs_upscaler_sharpen">샤프닝 전용</string>
|
||||
<string name="sprefs_lod_bias_auto">자동(스케일링에 맞춤)</string>
|
||||
@@ -2034,11 +2052,15 @@
|
||||
<string name="display_filter_note">FSR, GSR, NIS는 세션 이미지를 확대할 때 적용됩니다. 샤프닝 전용은 확대하지 않아도 작동합니다.</string>
|
||||
<string name="display_sharpness">필터 선명도</string>
|
||||
<string name="display_frame_rate">프레임 속도</string>
|
||||
<string name="display_window_compatibility">게임 창 호환성</string>
|
||||
<string name="display_force_fullscreen">게임 창 강제 전체 화면</string>
|
||||
<string name="display_force_fullscreen_hint">모든 게임 창을 화면 크기로 맞춥니다. 앱을 전환한 뒤 게임이 작아지거나 구석에 표시되면 켜고, 게임 크기가 깜박이면 끈 상태로 두세요. 세션 중에도 즉시 적용됩니다.</string>
|
||||
<string name="display_force_fullscreen_live">전환 후 게임이 작아지면 켜고, 깜박이면 끄세요. 즉시 적용됩니다.</string>
|
||||
<string name="display_look_filter">%1$s 스케일링을 %2$s(으)로 설정합니다.</string>
|
||||
<string name="display_match_screen">화면에 맞춤(%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">기기 세부 정보 표시</string>
|
||||
<string name="device_info_hide">기기 세부 정보 숨기기</string>
|
||||
<string name="device_info_loading">기기 정보를 읽는 중…</string>
|
||||
<string name="device_info_copy">복사</string>
|
||||
<string name="device_info_copy_hint">이 세부 정보를 버그 보고서에 복사합니다.</string>
|
||||
<string name="device_info_copied">복사했습니다.</string>
|
||||
<string name="force_ssbs_title">SSBS 강제</string>
|
||||
<string name="force_ssbs_hint">Wine이 계속 끄는 CPU 속도 설정을 켜 둡니다. Snapdragon 8 Elite에서 Windows 게임이 빨라질 수 있으며, 이전 칩에서는 변화가 거의 없습니다. 다음 게임 실행 시 적용됩니다.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,959 @@
|
||||
<?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. Если для параметра «Вторая библиотека» выбрано «Авто», 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">Показывается в меню. Некоторые приложения могут не запускаться.</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>
|
||||
<string name="fm_up_one_level">На уровень вверх</string>
|
||||
<string name="fm_drive_linux">Система Linux</string>
|
||||
</resources>
|
||||
@@ -41,7 +41,7 @@
|
||||
<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="added_games_import_hint">一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 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>
|
||||
@@ -170,7 +170,7 @@
|
||||
<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_flatpak_hint">一个 Flathub 应用 ID 或其 Flathub 页面。若尚未配置 Flatpak 会先进行配置。</string>
|
||||
<string name="add_app_file">文件</string>
|
||||
<string name="add_app_no_file">未选择文件</string>
|
||||
<string name="add_app_choose_file">选择文件</string>
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="mode_suspend">后台行为</string>
|
||||
<string name="mode_suspend_hint">应用被隐藏或屏幕关闭时。</string>
|
||||
<string name="mode_suspend_manual">手动</string>
|
||||
<string name="mode_suspend_native">原生</string>
|
||||
<string name="mode_suspend_steam_note">自动会暂停并在显示时恢复。原生会先让 Steam 做好睡眠准备。手动会等待点按恢复。“从不”则保持运行。</string>
|
||||
<string name="mode_suspend_note">自动会在后台暂停并在显示时恢复。手动会等待点按恢复。“从不”则保持运行。</string>
|
||||
<string name="mode_startup">启动</string>
|
||||
<string name="mode_steam_startup">随 DroidDeck 启动 Steam</string>
|
||||
@@ -307,8 +309,29 @@
|
||||
<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="wincomp_title">Windows 组件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各部分之间跳转。</string>
|
||||
<string name="wincomp_card_none">无</string>
|
||||
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自 Bannerlator 的组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
|
||||
<string name="wincomp_offline">无法访问组件列表。已下载的组件仍可开启或关闭。</string>
|
||||
<string name="wincomp_recommended">此游戏推荐</string>
|
||||
<string name="wincomp_found_bundled">游戏自带 %1$s</string>
|
||||
<string name="wincomp_found_shipped">游戏含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 会随此游戏安装 %1$s</string>
|
||||
<string name="wincomp_already_here">此游戏中已有</string>
|
||||
<string name="wincomp_no_recommendation">游戏文件夹中没有指向任何组件的内容。</string>
|
||||
<string name="wincomp_all">全部组件(%1$d)</string>
|
||||
<string name="wincomp_installers">需要 Windows 安装程序(%1$d)</string>
|
||||
<string name="wincomp_installers_note">这些组件需要在前缀中运行真正的安装程序(.NET、PhysX、XNA、完整的 Visual C++ 安装程序等)。DroidDeck 暂时无法做到,接下来会支持。</string>
|
||||
<string name="wincomp_show">显示</string>
|
||||
<string name="wincomp_hide">隐藏</string>
|
||||
<string name="wincomp_needs_installer">需要 Windows 安装程序(即将支持)</string>
|
||||
<string name="wincomp_unsupported">此处暂不支持</string>
|
||||
<string name="wincomp_loading">正在加载组件…</string>
|
||||
<string name="wincomp_failed">无法安装:%1$s</string>
|
||||
<string name="wincomp_downloaded">已下载</string>
|
||||
<string name="wincomp_download_size">下载 %1$s</string>
|
||||
<string name="wincomp_next_launch">将在下次启动游戏时生效。</string>
|
||||
<string name="mode_stretch169">拉伸 16:9 比例的游戏</string>
|
||||
<string name="mode_stretch169_hint">在比 16:9 更方的屏幕上,拉伸游戏填满屏幕而不是显示黑边。将在下次会话生效。</string>
|
||||
<string name="mode_audio">音频</string>
|
||||
@@ -330,8 +353,8 @@
|
||||
<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 卡时使用 SD 卡</string>
|
||||
<string name="mode_storage_auto_none">自动 — 有 SD 卡时使用 SD 卡(当前没有)</string>
|
||||
<string name="mode_storage_auto">自动 — 有 SD 卡时使用 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>
|
||||
@@ -486,7 +509,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_scaling">缩放模式</string>
|
||||
<string name="drawer_scaling_sharpness">缩放锐度</string>
|
||||
<string name="drawer_effects">屏幕效果</string>
|
||||
@@ -616,7 +638,7 @@
|
||||
<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_on">显示在菜单中。部分应用可能无法启动。</string>
|
||||
<string name="setup_store_off">从 Flathub 安装 Linux 应用和游戏</string>
|
||||
<string name="setup_tool_files">文件</string>
|
||||
<string name="setup_tool_files_hint">浏览和管理文件</string>
|
||||
@@ -638,9 +660,9 @@
|
||||
<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_title">配置 Flatpak</string>
|
||||
<string name="store_setup_text">添加 Flatpak 并连接到 Flathub(约 20 MB)。安装第一个应用时还会额外下载它的运行时。</string>
|
||||
<string name="store_setting_up">正在设置…</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>
|
||||
@@ -661,7 +683,7 @@
|
||||
<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_setup_first_note">请先配置 Flatpak 再安装应用。</string>
|
||||
<string name="store_update_available_prefix">有可用更新 · %1$s</string>
|
||||
<string name="store_open">打开</string>
|
||||
<string name="store_details">详情</string>
|
||||
@@ -669,7 +691,7 @@
|
||||
<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_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>
|
||||
@@ -1036,7 +1058,7 @@
|
||||
<string name="main_saves_close_session">请先关闭 Steam 会话,以免游戏存档覆盖导入的内容</string>
|
||||
|
||||
<!-- Busy line while saves are imported; %1$s is the game's name -->
|
||||
<string name="main_saves_importing">导入到 %1$s</string>
|
||||
<string name="main_saves_importing">正在导入到 %1$s</string>
|
||||
|
||||
<!-- %1$d files, %2$s the save layout (e.g. GameHub zip), %3$s the game's name -->
|
||||
<plurals name="main_saves_imported">
|
||||
@@ -1048,7 +1070,7 @@
|
||||
<string name="main_saves_pick_zip">为 %1$s 选择存档 zip 包</string>
|
||||
|
||||
<!-- Busy line while saves are exported; %1$s is the game's name -->
|
||||
<string name="main_saves_exporting">导出 %1$s</string>
|
||||
<string name="main_saves_exporting">正在导出 %1$s</string>
|
||||
|
||||
<!-- %1$d files, %2$s the save layout (e.g. GameHub zip), %3$s the zip's path -->
|
||||
<plurals name="main_saves_exported">
|
||||
@@ -1089,11 +1111,11 @@
|
||||
<string name="android_app_open_failed_display">无法在显示器 %2$d 上打开 %1$s</string>
|
||||
|
||||
<!-- Components page busy lines, shown with … after them -->
|
||||
<string name="comp_busy_importing">导入</string>
|
||||
<string name="comp_busy_swapping">替换</string>
|
||||
<string name="comp_busy_restoring">还原</string>
|
||||
<string name="comp_busy_cancelling">取消</string>
|
||||
<string name="comp_busy_deleting">删除</string>
|
||||
<string name="comp_busy_importing">正在导入</string>
|
||||
<string name="comp_busy_swapping">正在替换</string>
|
||||
<string name="comp_busy_restoring">正在还原</string>
|
||||
<string name="comp_busy_cancelling">正在取消</string>
|
||||
<string name="comp_busy_deleting">正在删除</string>
|
||||
<string name="comp_cannot_read">无法读取该文件</string>
|
||||
|
||||
<!-- %1$s is a version or a name -->
|
||||
@@ -1266,8 +1288,6 @@
|
||||
<!-- Screens, files, controller and store -->
|
||||
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">画面缩放到屏幕的方式;锐化类模式会在画面被放大时进行锐化。</string>
|
||||
<string name="mode_upscaler_note">锐化类模式仅在会话分辨率小于屏幕时生效;“线性”“最近邻”和“仅锐化”在任何尺寸下都可用。会占用少量 GPU 时间。</string>
|
||||
<string name="mode_decky_plugins">插件</string>
|
||||
<string name="mode_decky_plugins_hint">安装插件 ZIP 包,将在下次 Steam 会话中生效。</string>
|
||||
<string name="mode_decky_plugin_downloading">正在下载…</string>
|
||||
@@ -1293,7 +1313,7 @@
|
||||
<!-- Store front-page section of recently updated apps -->
|
||||
<string name="store_section_updated">最近更新</string>
|
||||
<string name="store_section_loading">加载中…</string>
|
||||
<string name="store_flatpak_setup">Flatpak 设置</string>
|
||||
<string name="store_flatpak_setup">Flatpak 配置</string>
|
||||
|
||||
<!-- %1$s is what failed (an app name or "Flatpak setup"), %2$s the error -->
|
||||
<string name="store_problem">%1$s:%2$s</string>
|
||||
@@ -1357,6 +1377,7 @@
|
||||
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">内部存储</string>
|
||||
<string name="fm_drive_linux">Linux 系统</string>
|
||||
<string name="fm_drive_sd">SD 卡</string>
|
||||
<string name="fm_drive_storage">存储</string>
|
||||
<string name="fm_select">选择</string>
|
||||
@@ -1511,6 +1532,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">已将“%1$s”从收藏夹移除</string>
|
||||
<string name="fm_empty_directory">空文件夹</string>
|
||||
<string name="fm_up_one_level">返回上一级</string>
|
||||
<string name="fm_choose_folder">选择文件夹</string>
|
||||
<string name="fm_choose_file">选择文件</string>
|
||||
|
||||
@@ -1590,10 +1612,10 @@
|
||||
<string name="deskpkg_install_failed">安装失败</string>
|
||||
|
||||
<!-- Flatpak store operations (FlatpakManager) -->
|
||||
<string name="flatpakmgr_setup_failed">Flatpak 设置失败(退出代码 %1$d)</string>
|
||||
<string name="flatpakmgr_setup_unfinished">Flatpak 设置未完成</string>
|
||||
<string name="flatpakmgr_setup_failed">Flatpak 配置失败(退出代码 %1$d)</string>
|
||||
<string name="flatpakmgr_setup_unfinished">Flatpak 配置未完成</string>
|
||||
<string name="flatpakmgr_busy">另一个商店任务正在运行</string>
|
||||
<string name="flatpakmgr_not_ready">请先设置 Flatpak</string>
|
||||
<string name="flatpakmgr_not_ready">请先配置 Flatpak</string>
|
||||
<string name="flatpakmgr_starting">启动中</string>
|
||||
<string name="flatpakmgr_exit_status">Flatpak 已退出,状态码 %1$d</string>
|
||||
|
||||
@@ -1734,7 +1756,7 @@
|
||||
<string name="adbpair_port_not_found">已配对,但未找到无线调试端口。请保持无线调试开启,然后重试。</string>
|
||||
<string name="adbpair_applying">正在应用设置…</string>
|
||||
<string name="adbpair_command_failed">无线调试命令执行失败</string>
|
||||
<string name="adbpair_channel_name">无线调试设置</string>
|
||||
<string name="adbpair_channel_name">无线调试配置</string>
|
||||
<string name="adbpair_channel_description">在“设置”打开时请求输入无线调试配对码</string>
|
||||
<string name="adbpair_title_pair">配对无线调试</string>
|
||||
<string name="adbpair_waiting_text">开启无线调试,然后点按“使用配对码配对设备”。</string>
|
||||
@@ -1748,10 +1770,10 @@
|
||||
|
||||
<!-- Notification action button -->
|
||||
<string name="adbpair_enter_code">输入配对码</string>
|
||||
<string name="adbpair_title_working">正在设置 Steam</string>
|
||||
<string name="adbpair_title_working">正在配置 Steam</string>
|
||||
<string name="adbpair_title_done">Steam 已就绪</string>
|
||||
<string name="adbpair_done_text">点按即可返回 DroidDeck。现在可以关闭无线调试了。</string>
|
||||
<string name="adbpair_title_failed">无线调试设置已中止</string>
|
||||
<string name="adbpair_title_failed">无线调试配置已中止</string>
|
||||
|
||||
<!-- DroidDeck's own updates (SelfInstaller, AppUpdates) -->
|
||||
<string name="selfinst_http">下载服务器返回 HTTP %1$d</string>
|
||||
@@ -1842,12 +1864,8 @@
|
||||
<string name="cmgr_checksum_mismatch">校验和不匹配</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">自动(16:9+)</string>
|
||||
<string name="sprefs_shape_exact">匹配屏幕</string>
|
||||
<string name="sprefs_shape_wide">始终 16:9</string>
|
||||
<string name="sprefs_upscaler_linear">线性</string>
|
||||
<string name="sprefs_upscaler_nearest">最近邻</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1(适应)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR(质量)</string>
|
||||
<string name="sprefs_upscaler_sharpen">仅锐化</string>
|
||||
<string name="sprefs_lod_bias_auto">自动(匹配缩放)</string>
|
||||
@@ -2034,11 +2052,15 @@
|
||||
<string name="display_filter_note">FSR、GSR 和 NIS 在放大会话画面时生效。“仅锐化”在不放大时也可用。</string>
|
||||
<string name="display_sharpness">滤镜锐度</string>
|
||||
<string name="display_frame_rate">帧率</string>
|
||||
<string name="display_window_compatibility">游戏窗口兼容性</string>
|
||||
<string name="display_force_fullscreen">强制游戏窗口全屏</string>
|
||||
<string name="display_force_fullscreen_hint">让每个游戏窗口都与屏幕大小一致。如果切换应用后游戏变小或显示在角落,请开启;如果游戏在不同尺寸间闪烁,请保持关闭。会话中即时生效。</string>
|
||||
<string name="display_force_fullscreen_live">切换后游戏变小请开启;出现闪烁请关闭。即时生效。</string>
|
||||
<string name="display_look_filter">%1$s 将缩放设为 %2$s。</string>
|
||||
<string name="display_match_screen">匹配屏幕(%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">显示设备详情</string>
|
||||
<string name="device_info_hide">隐藏设备详情</string>
|
||||
<string name="device_info_loading">正在读取设备信息…</string>
|
||||
<string name="device_info_copy">复制</string>
|
||||
<string name="device_info_copy_hint">将这些详情复制到错误报告中。</string>
|
||||
<string name="device_info_copied">已复制。</string>
|
||||
<string name="force_ssbs_title">强制 SSBS</string>
|
||||
<string name="force_ssbs_hint">保持开启一项 Wine 会不断关闭的 CPU 速度设置。在骁龙 8 至尊版上可能让 Windows 游戏更快;在较旧芯片上变化不大。下次启动游戏时生效。</string>
|
||||
</resources>
|
||||
@@ -41,7 +41,7 @@
|
||||
<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="added_games_import_hint">一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 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>
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="mode_suspend">背景行為</string>
|
||||
<string name="mode_suspend_hint">應用程式被隱藏或螢幕關閉時。</string>
|
||||
<string name="mode_suspend_manual">手動</string>
|
||||
<string name="mode_suspend_native">原生</string>
|
||||
<string name="mode_suspend_steam_note">「自動」會暫停,並在重新顯示時繼續;「原生」會先讓 Steam 準備好休眠;「手動」會等你按「繼續」;「從不」會保持執行。</string>
|
||||
<string name="mode_suspend_note">「自動」會在背景時暫停,並在重新顯示時繼續;「手動」會等你按「繼續」;「從不」會保持執行。</string>
|
||||
<string name="mode_startup">啟動</string>
|
||||
<string name="mode_steam_startup">隨 DroidDeck 啟動 Steam</string>
|
||||
@@ -307,8 +309,29 @@
|
||||
<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="wincomp_title">Windows 組件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各部分之間切換。</string>
|
||||
<string name="wincomp_card_none">無</string>
|
||||
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自 Bannerlator 的組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_offline">無法連接組件清單。已下載的組件仍可開啟或關閉。</string>
|
||||
<string name="wincomp_recommended">建議用於這款遊戲</string>
|
||||
<string name="wincomp_found_bundled">遊戲附帶 %1$s</string>
|
||||
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
|
||||
<string name="wincomp_already_here">這款遊戲中已有</string>
|
||||
<string name="wincomp_no_recommendation">遊戲資料夾內沒有指向任何組件的內容。</string>
|
||||
<string name="wincomp_all">所有組件(%1$d)</string>
|
||||
<string name="wincomp_installers">需要 Windows 安裝程式(%1$d)</string>
|
||||
<string name="wincomp_installers_note">這些組件需要在前綴內執行真正的安裝程式(.NET、PhysX、XNA、完整的 Visual C++ 安裝程式等)。DroidDeck 暫時未能做到,下一步就會支援。</string>
|
||||
<string name="wincomp_show">顯示</string>
|
||||
<string name="wincomp_hide">隱藏</string>
|
||||
<string name="wincomp_needs_installer">需要 Windows 安裝程式(即將支援)</string>
|
||||
<string name="wincomp_unsupported">這裏暫不支援</string>
|
||||
<string name="wincomp_loading">正在載入組件…</string>
|
||||
<string name="wincomp_failed">無法安裝:%1$s</string>
|
||||
<string name="wincomp_downloaded">已下載</string>
|
||||
<string name="wincomp_download_size">下載 %1$s</string>
|
||||
<string name="wincomp_next_launch">會在下次啟動遊戲時生效。</string>
|
||||
<string name="mode_stretch169">拉伸 16:9 遊戲</string>
|
||||
<string name="mode_stretch169_hint">在比 16:9 更方的螢幕上,拉伸遊戲以填滿畫面,而不顯示黑邊。下次會話生效。</string>
|
||||
<string name="mode_audio">音訊</string>
|
||||
@@ -330,8 +353,8 @@
|
||||
<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 卡時使用 SD 卡</string>
|
||||
<string name="mode_storage_auto_none">自動 - 有 SD 卡時使用 SD 卡(目前沒有)</string>
|
||||
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 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>
|
||||
@@ -486,7 +509,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_scaling">縮放模式</string>
|
||||
<string name="drawer_scaling_sharpness">縮放銳利度</string>
|
||||
<string name="drawer_effects">畫面效果</string>
|
||||
@@ -616,7 +638,7 @@
|
||||
<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_on">顯示在選單中。部分應用程式可能無法啟動。</string>
|
||||
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
|
||||
<string name="setup_tool_files">檔案</string>
|
||||
<string name="setup_tool_files_hint">瀏覽及管理檔案</string>
|
||||
@@ -1266,8 +1288,6 @@
|
||||
<!-- Screens, files, controller and store -->
|
||||
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">畫面如何縮放至螢幕大小;銳化模式會在放大的地方進行銳化。</string>
|
||||
<string name="mode_upscaler_note">銳化模式只在會話比螢幕小時才有效;線性、最近鄰和「只銳化」則在任何大小下都有效。會略為佔用 GPU 時間。</string>
|
||||
<string name="mode_decky_plugins">插件</string>
|
||||
<string name="mode_decky_plugins_hint">安裝插件 ZIP,在下次 Steam 會話使用。</string>
|
||||
<string name="mode_decky_plugin_downloading">正在下載…</string>
|
||||
@@ -1357,6 +1377,7 @@
|
||||
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">內部</string>
|
||||
<string name="fm_drive_linux">Linux 系統</string>
|
||||
<string name="fm_drive_sd">SD 卡</string>
|
||||
<string name="fm_drive_storage">儲存空間</string>
|
||||
<string name="fm_select">選取</string>
|
||||
@@ -1511,6 +1532,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">已將「%1$s」從我的最愛移除</string>
|
||||
<string name="fm_empty_directory">空白目錄</string>
|
||||
<string name="fm_up_one_level">返回上一層</string>
|
||||
<string name="fm_choose_folder">選擇資料夾</string>
|
||||
<string name="fm_choose_file">選擇檔案</string>
|
||||
|
||||
@@ -1842,12 +1864,8 @@
|
||||
<string name="cmgr_checksum_mismatch">校驗和不符</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">自動(16:9+)</string>
|
||||
<string name="sprefs_shape_exact">配合螢幕</string>
|
||||
<string name="sprefs_shape_wide">一律 16:9</string>
|
||||
<string name="sprefs_upscaler_linear">線性</string>
|
||||
<string name="sprefs_upscaler_nearest">最近鄰</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1(適合)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR(品質)</string>
|
||||
<string name="sprefs_upscaler_sharpen">只銳化</string>
|
||||
<string name="sprefs_lod_bias_auto">自動(配合縮放)</string>
|
||||
@@ -2034,11 +2052,15 @@
|
||||
<string name="display_filter_note">FSR、GSR 和 NIS 會在放大會話畫面時套用。「只銳化」不放大也能使用。</string>
|
||||
<string name="display_sharpness">濾鏡銳利度</string>
|
||||
<string name="display_frame_rate">幀率</string>
|
||||
<string name="display_window_compatibility">遊戲視窗兼容性</string>
|
||||
<string name="display_force_fullscreen">強制遊戲視窗全螢幕</string>
|
||||
<string name="display_force_fullscreen_hint">令每個遊戲視窗都與螢幕一樣大。如切換應用程式後遊戲變細或顯示在角落,請開啟;如遊戲在不同大小之間閃爍,請保持關閉。會話中即時生效。</string>
|
||||
<string name="display_force_fullscreen_live">切換後遊戲變細請開啟;出現閃爍請關閉。即時生效。</string>
|
||||
<string name="display_look_filter">%1$s 將縮放設為 %2$s。</string>
|
||||
<string name="display_match_screen">配合螢幕(%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">顯示裝置詳情</string>
|
||||
<string name="device_info_hide">隱藏裝置詳情</string>
|
||||
<string name="device_info_loading">正在讀取裝置資料…</string>
|
||||
<string name="device_info_copy">複製</string>
|
||||
<string name="device_info_copy_hint">將這些詳情複製到錯誤報告中。</string>
|
||||
<string name="device_info_copied">已複製。</string>
|
||||
<string name="force_ssbs_title">強制 SSBS</string>
|
||||
<string name="force_ssbs_hint">讓一項 Wine 會不斷關閉的 CPU 速度設定保持開啟。在 Snapdragon 8 Elite 上可能令 Windows 遊戲更快;在較舊的晶片上變化不大。下次啟動遊戲時生效。</string>
|
||||
</resources>
|
||||
@@ -41,7 +41,7 @@
|
||||
<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="added_games_import_hint">一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 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>
|
||||
@@ -206,7 +206,7 @@
|
||||
<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 的 prefix 修正,可在此啟動遊戲。預設為 Proton Experimental ARM64(%1$s);安裝較新的 Proton 11.0 ARM64(%1$s)後則改用該版本。Valve 自己未帶此標示的 Proton 項目無法在這部裝置上啟動遊戲。</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>
|
||||
@@ -265,6 +265,8 @@
|
||||
<string name="mode_suspend">背景行為</string>
|
||||
<string name="mode_suspend_hint">應用程式隱藏或螢幕關閉時的行為。</string>
|
||||
<string name="mode_suspend_manual">手動</string>
|
||||
<string name="mode_suspend_native">原生</string>
|
||||
<string name="mode_suspend_steam_note">「自動」會暫停,並在重新顯示時繼續。「原生」會先讓 Steam 做好睡眠準備。「手動」會等你點擊「繼續」。「從不」則會持續執行。</string>
|
||||
<string name="mode_suspend_note">「自動」會在背景時暫停,並在重新顯示時繼續。「手動」會等你點擊「繼續」。「從不」則會持續執行。</string>
|
||||
<string name="mode_startup">啟動</string>
|
||||
<string name="mode_steam_startup">隨 DroidDeck 啟動 Steam</string>
|
||||
@@ -307,8 +309,29 @@
|
||||
<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="wincomp_title">Windows 元件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各區段之間切換。</string>
|
||||
<string name="wincomp_card_none">無</string>
|
||||
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自 Bannerlator 的元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_offline">無法連上元件清單。已下載的元件仍可開啟或關閉。</string>
|
||||
<string name="wincomp_recommended">建議用於這款遊戲</string>
|
||||
<string name="wincomp_found_bundled">遊戲隨附 %1$s</string>
|
||||
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
|
||||
<string name="wincomp_already_here">這款遊戲中已有</string>
|
||||
<string name="wincomp_no_recommendation">遊戲資料夾中沒有指向任何元件的內容。</string>
|
||||
<string name="wincomp_all">所有元件(%1$d)</string>
|
||||
<string name="wincomp_installers">需要 Windows 安裝程式(%1$d)</string>
|
||||
<string name="wincomp_installers_note">這些元件需要在前綴中執行真正的安裝程式(.NET、PhysX、XNA、完整的 Visual C++ 安裝程式等)。DroidDeck 目前還做不到,接下來就會支援。</string>
|
||||
<string name="wincomp_show">顯示</string>
|
||||
<string name="wincomp_hide">隱藏</string>
|
||||
<string name="wincomp_needs_installer">需要 Windows 安裝程式(即將支援)</string>
|
||||
<string name="wincomp_unsupported">這裡尚不支援</string>
|
||||
<string name="wincomp_loading">正在載入元件…</string>
|
||||
<string name="wincomp_failed">無法安裝:%1$s</string>
|
||||
<string name="wincomp_downloaded">已下載</string>
|
||||
<string name="wincomp_download_size">下載 %1$s</string>
|
||||
<string name="wincomp_next_launch">將在下次啟動遊戲時生效。</string>
|
||||
<string name="mode_stretch169">拉伸 16:9 遊戲</string>
|
||||
<string name="mode_stretch169_hint">在長寬比小於 16:9 的螢幕上,將遊戲拉伸以填滿螢幕,而不是顯示黑邊。下次工作階段生效。</string>
|
||||
<string name="mode_audio">音訊</string>
|
||||
@@ -330,8 +353,8 @@
|
||||
<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 卡時使用 SD 卡</string>
|
||||
<string name="mode_storage_auto_none">自動 - 有 SD 卡時使用 SD 卡(目前無 SD 卡)</string>
|
||||
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾</string>
|
||||
<string name="mode_storage_auto_none">自動 - 使用你的遊戲資料夾,有 SD 卡時也使用 SD 卡(目前無 SD 卡)</string>
|
||||
<string name="mode_storage_internal">僅內部儲存空間</string>
|
||||
<string name="mode_storage_folder">資料夾:%1$s</string>
|
||||
<string name="mode_second_library">第二收藏庫</string>
|
||||
@@ -486,7 +509,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_scaling">縮放模式</string>
|
||||
<string name="drawer_scaling_sharpness">縮放銳利度</string>
|
||||
<string name="drawer_effects">畫面效果</string>
|
||||
@@ -616,7 +638,7 @@
|
||||
<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_on">顯示在選單中。部分應用程式可能無法啟動。</string>
|
||||
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
|
||||
<string name="setup_tool_files">檔案</string>
|
||||
<string name="setup_tool_files_hint">瀏覽和管理檔案</string>
|
||||
@@ -1266,8 +1288,6 @@
|
||||
<!-- Screens, files, controller and store -->
|
||||
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">畫面縮放到螢幕的方式;銳化模式會在畫面放大處進行銳化。</string>
|
||||
<string name="mode_upscaler_note">銳化模式僅在工作階段畫面小於螢幕時有效;「線性」、「最近鄰」和「僅銳化」在任何尺寸下都有效。會佔用少量 GPU 時間。</string>
|
||||
<string name="mode_decky_plugins">外掛程式</string>
|
||||
<string name="mode_decky_plugins_hint">安裝外掛程式 ZIP,供下次 Steam 工作階段使用。</string>
|
||||
<string name="mode_decky_plugin_downloading">正在下載…</string>
|
||||
@@ -1357,6 +1377,7 @@
|
||||
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">內部</string>
|
||||
<string name="fm_drive_linux">Linux 系統</string>
|
||||
<string name="fm_drive_sd">SD 卡</string>
|
||||
<string name="fm_drive_storage">儲存空間</string>
|
||||
<string name="fm_select">選取</string>
|
||||
@@ -1511,6 +1532,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">已將「%1$s」從我的最愛移除</string>
|
||||
<string name="fm_empty_directory">空的資料夾</string>
|
||||
<string name="fm_up_one_level">返回上一層</string>
|
||||
<string name="fm_choose_folder">選擇資料夾</string>
|
||||
<string name="fm_choose_file">選擇檔案</string>
|
||||
|
||||
@@ -1842,12 +1864,8 @@
|
||||
<string name="cmgr_checksum_mismatch">校驗碼不符</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">自動(16:9+)</string>
|
||||
<string name="sprefs_shape_exact">符合螢幕</string>
|
||||
<string name="sprefs_shape_wide">一律 16:9</string>
|
||||
<string name="sprefs_upscaler_linear">線性</string>
|
||||
<string name="sprefs_upscaler_nearest">最近鄰</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1(符合)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR(品質)</string>
|
||||
<string name="sprefs_upscaler_sharpen">僅銳化</string>
|
||||
<string name="sprefs_lod_bias_auto">自動(依縮放)</string>
|
||||
@@ -2034,11 +2052,15 @@
|
||||
<string name="display_filter_note">FSR、GSR 和 NIS 會在放大工作階段畫面時套用。「僅銳化」不放大也能使用。</string>
|
||||
<string name="display_sharpness">濾鏡銳利度</string>
|
||||
<string name="display_frame_rate">影格率</string>
|
||||
<string name="display_window_compatibility">遊戲視窗相容性</string>
|
||||
<string name="display_force_fullscreen">強制遊戲視窗全螢幕</string>
|
||||
<string name="display_force_fullscreen_hint">讓每個遊戲視窗都與螢幕同大。若切換應用程式後遊戲變小或顯示在角落,請開啟;若遊戲在不同尺寸間閃爍,請保持關閉。工作階段中立即生效。</string>
|
||||
<string name="display_force_fullscreen_live">切換後遊戲變小請開啟;出現閃爍請關閉。立即生效。</string>
|
||||
<string name="display_look_filter">%1$s 將縮放設為 %2$s。</string>
|
||||
<string name="display_match_screen">符合螢幕(%1$d×%2$d)</string>
|
||||
<string name="mode_tab_steam">Steam</string>
|
||||
<string name="device_info_show">顯示裝置詳細資料</string>
|
||||
<string name="device_info_hide">隱藏裝置詳細資料</string>
|
||||
<string name="device_info_loading">正在讀取裝置資訊…</string>
|
||||
<string name="device_info_copy">複製</string>
|
||||
<string name="device_info_copy_hint">將這些詳細資料複製到錯誤回報中。</string>
|
||||
<string name="device_info_copied">已複製。</string>
|
||||
<string name="force_ssbs_title">強制 SSBS</string>
|
||||
<string name="force_ssbs_hint">讓一項 Wine 會不斷關閉的 CPU 速度設定保持開啟。在 Snapdragon 8 Elite 上可能讓 Windows 遊戲更快;在較舊的晶片上變化不大。下次啟動遊戲時生效。</string>
|
||||
</resources>
|
||||
@@ -15,8 +15,10 @@
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Shows while a Steam or desktop session is open</string>
|
||||
<string name="session_notification">Steam is running</string>
|
||||
<string name="session_notification_downloads">Steam download active</string>
|
||||
<string name="session_notification_paused">Session paused - tap Resume when ready</string>
|
||||
<string name="resume_session">Resume</string>
|
||||
<string name="suspend_now">Suspend now</string>
|
||||
<string name="stop_session">Stop session</string>
|
||||
<string name="frame_gen_title">Frame generation</string>
|
||||
<string name="frame_gen_hint">Select the frame generation mode</string>
|
||||
@@ -41,7 +43,7 @@
|
||||
<string name="hud_fg_failed">%1$s: engine failed</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · starting</string>
|
||||
<string name="added_games_import_hint">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts.</string>
|
||||
<string name="added_games_import_hint">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts. With Second library on Automatic, Steam can install games into this folder too.</string>
|
||||
<string name="second_library_import_hint">Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts.</string>
|
||||
<string name="added_games_forget_hint">Forget removes the shortcuts next session. Files stay on disk.</string>
|
||||
<string name="game_env_title">Game environment</string>
|
||||
@@ -265,6 +267,10 @@
|
||||
<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_background_downloads">Keep downloads running</string>
|
||||
<string name="mode_background_downloads_hint">Delay background suspension while Steam is actively downloading.</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>
|
||||
@@ -307,8 +313,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>
|
||||
@@ -330,8 +357,8 @@
|
||||
<string name="mode_wifi_explain_title">Enable Wi-Fi discovery?</string>
|
||||
<string name="mode_wifi_explain_text">Android requires Location permission to read Wi-Fi names and discover nearby networks. DroidDeck uses this access to show those networks in Steam. We don\'t track your location. Internet access works without this permission.</string>
|
||||
<string name="mode_storage">Game storage</string>
|
||||
<string name="mode_storage_auto">Automatic - the SD card when present</string>
|
||||
<string name="mode_storage_auto_none">Automatic - the SD card when present (none now)</string>
|
||||
<string name="mode_storage_auto">Automatic - the SD card when present, plus your Games folders</string>
|
||||
<string name="mode_storage_auto_none">Automatic - your Games folders, plus the SD card when present (none now)</string>
|
||||
<string name="mode_storage_internal">Internal only</string>
|
||||
<string name="mode_storage_folder">Folder: %1$s</string>
|
||||
<string name="mode_second_library">Second library</string>
|
||||
@@ -487,7 +514,6 @@
|
||||
<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>
|
||||
@@ -617,7 +643,7 @@
|
||||
<string name="setup_choose">Choose</string>
|
||||
<string name="setup_linux_apps">Linux apps (beta)</string>
|
||||
<string name="setup_store">Flathub Store</string>
|
||||
<string name="setup_store_on">In the menu. Some apps may not start; logs are in Download/DroidDeck.</string>
|
||||
<string name="setup_store_on">In the menu. Some apps may not start.</string>
|
||||
<string name="setup_store_off">Install Linux apps and games from Flathub</string>
|
||||
<string name="setup_tool_files">Files</string>
|
||||
<string name="setup_tool_files_hint">Browse and manage files</string>
|
||||
@@ -1244,8 +1270,6 @@
|
||||
|
||||
<!-- Screens, files, controller and store -->
|
||||
<!-- Mode settings -->
|
||||
<string name="mode_upscaler_hint">How the picture is resized to the screen; the sharpening modes sharpen where it is enlarged.</string>
|
||||
<string name="mode_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.</string>
|
||||
<string name="mode_decky_plugins">Plugins</string>
|
||||
<string name="mode_decky_plugins_hint">Install a plugin ZIP for the next Steam session.</string>
|
||||
<string name="mode_decky_plugin_downloading">Downloading…</string>
|
||||
@@ -1321,6 +1345,7 @@
|
||||
<string name="content_system_many">Many systems</string>
|
||||
<!-- File manager -->
|
||||
<string name="fm_drive_internal">Internal</string>
|
||||
<string name="fm_drive_linux">Linux system</string>
|
||||
<string name="fm_drive_sd">SD card</string>
|
||||
<string name="fm_drive_storage">Storage</string>
|
||||
<string name="fm_select">Select</string>
|
||||
@@ -1461,6 +1486,7 @@
|
||||
<!-- %1$s is a folder name -->
|
||||
<string name="fm_favorite_removed">Removed \"%1$s\" from Favorites</string>
|
||||
<string name="fm_empty_directory">Empty directory</string>
|
||||
<string name="fm_up_one_level">Up one level</string>
|
||||
<string name="fm_choose_folder">Choose a folder</string>
|
||||
<string name="fm_choose_file">Choose a file</string>
|
||||
<!-- On-screen controller tint colors -->
|
||||
@@ -1759,12 +1785,8 @@
|
||||
<string name="cmgr_checksum_mismatch">Checksum mismatch</string>
|
||||
|
||||
<!-- Display choices -->
|
||||
<string name="sprefs_shape_auto">Auto (16:9+)</string>
|
||||
<string name="sprefs_shape_exact">Match screen</string>
|
||||
<string name="sprefs_shape_wide">Always 16:9</string>
|
||||
<string name="sprefs_upscaler_linear">Linear</string>
|
||||
<string name="sprefs_upscaler_nearest">Nearest</string>
|
||||
<string name="sprefs_upscaler_fsr_fit">AMD FSR 1 (fit)</string>
|
||||
<string name="sprefs_upscaler_gsr_quality">Snapdragon GSR (quality)</string>
|
||||
<string name="sprefs_upscaler_sharpen">Sharpen only</string>
|
||||
<string name="sprefs_lod_bias_auto">Auto (match scaling)</string>
|
||||
@@ -1829,6 +1851,7 @@
|
||||
<string name="gstore_sd_card">SD card</string>
|
||||
<!-- %1$s is a size, like 12.3 GB -->
|
||||
<string name="gstore_folder">Folder</string>
|
||||
<string name="gstore_folder_on_volume" translatable="false">%1$s (%2$s)</string>
|
||||
|
||||
<string name="logshare_chooser">Share session logs</string>
|
||||
<!-- Stands in for the Steam account name when the client did not record one -->
|
||||
@@ -1924,11 +1947,15 @@
|
||||
<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_window_compatibility">Game window compatibility</string>
|
||||
<string name="display_force_fullscreen">Force game windows fullscreen</string>
|
||||
<string name="display_force_fullscreen_hint">Makes every game window the size of the screen. Turn on if a game comes back small or in a corner after switching apps; leave off if a game flickers between sizes. Applies live during a session.</string>
|
||||
<string name="display_force_fullscreen_live">Turn on if a game comes back small after switching; off if it flickers. Applies live.</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,6 +4,8 @@
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="zh-CN" />
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -55,4 +55,37 @@ class LogRedactorTest {
|
||||
assertEquals(once, LogRedactor.redact(once))
|
||||
}
|
||||
}
|
||||
@Test fun quotedAndShortSecretsAreRedacted() {
|
||||
for (line in listOf("\"access_token\": \"short-secret\"", "\"password\"=\"abc\"", "pwd=x")) {
|
||||
val out = LogRedactor.redact(line)
|
||||
assertTrue(out.contains("<redacted:token>"))
|
||||
assertEquals(out, LogRedactor.redact(out))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun authorizationHeadersAreRedacted() {
|
||||
for (line in listOf("Authorization: Bearer secret-value", "Proxy-Authorization: Basic dXNlcjpwYXNz",
|
||||
"\"Authorization\":\"Bearer secret-value\"")) {
|
||||
val out = LogRedactor.redact(line)
|
||||
assertFalse(out.contains("secret-value"))
|
||||
assertFalse(out.contains("dXNlcjpwYXNz"))
|
||||
assertTrue(out.contains("<redacted:authorization>"))
|
||||
assertEquals(out, LogRedactor.redact(out))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun quotedSecretsWithSpacesAndEscapedQuotesAreFullyWithheld() {
|
||||
val lines = listOf(
|
||||
"password=\"my secret phrase\"",
|
||||
"password='my secret phrase'",
|
||||
"\"access_token\":\"my \\\"secret\\\" phrase\"",
|
||||
)
|
||||
for (line in lines) {
|
||||
val out = LogRedactor.redact(line)
|
||||
assertFalse(out.contains("phrase"))
|
||||
assertFalse(out.contains("secret"))
|
||||
assertTrue(out.contains("<redacted:token>"))
|
||||
assertEquals(out, LogRedactor.redact(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
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
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AddedGamesScanTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun theSteamLibraryInsideAGamesFolderIsNotAGame() {
|
||||
val games = tmp.newFolder("PC")
|
||||
File(games, "Example").mkdirs()
|
||||
File(games, "Example/Example.exe").writeText("game")
|
||||
File(games, "steamapps/downloading").mkdirs()
|
||||
File(games, "steamapps/downloading/Depot.exe").writeText("depot")
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(games.path))
|
||||
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
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
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class AddedGamesSteamEditsTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private lateinit var context: Context
|
||||
private lateinit var games: File
|
||||
private lateinit var folder: File
|
||||
private lateinit var main: File
|
||||
private lateinit var other: File
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
games = tmp.newFolder("PC")
|
||||
folder = File(games, "Example").apply { mkdirs() }
|
||||
main = File(folder, "Example.exe").apply { writeBytes(ByteArray(64)) }
|
||||
other = File(folder, "bin/Other.exe").apply { parentFile!!.mkdirs(); writeBytes(ByteArray(8)) }
|
||||
SessionPrefs.setGameStorage(context, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(games.path))
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/userdata/123/config").mkdirs()
|
||||
}
|
||||
|
||||
private fun record(appId: Long, asked: String, steam: String, rev: Int) {
|
||||
val game = JSONObject()
|
||||
.put("app", JSONObject().put("Exe", "\"$asked\""))
|
||||
.put("steam", JSONObject().put("Exe", "\"$steam\""))
|
||||
.put("rev", rev)
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/userdata/123/config/.droiddeck-shortcuts.json")
|
||||
.writeText(JSONObject().put("games", JSONObject().put(appId.toString(), game)).toString())
|
||||
}
|
||||
|
||||
private fun scanned() = AddedGames.scan(context).single { it.folder == folder }
|
||||
|
||||
@Test fun aTargetChangedInSteamBecomesTheAppsChoiceOnce() {
|
||||
val first = scanned()
|
||||
assertEquals(main, first.exe)
|
||||
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/bin/Other.exe", 1)
|
||||
|
||||
val adopted = scanned()
|
||||
assertEquals(other, adopted.exe)
|
||||
assertEquals("/root/Games/PC/Example/bin/Other.exe", adopted.guestExe)
|
||||
assertEquals(first.appId, adopted.appId)
|
||||
assertEquals(other.path, SessionPrefs.addedGameExe(context, folder.path))
|
||||
assertEquals(1, SessionPrefs.addedGameExeSeen(context, folder.path))
|
||||
|
||||
SessionPrefs.setAddedGameExe(context, folder.path, main.path)
|
||||
val chosen = scanned()
|
||||
assertEquals(main, chosen.exe)
|
||||
assertEquals(first.appId, chosen.appId)
|
||||
assertEquals(main.path, SessionPrefs.addedGameExe(context, folder.path))
|
||||
}
|
||||
|
||||
@Test fun theListingTellsTheSessionWhichSteamEditTheAppHasTaken() {
|
||||
val first = scanned()
|
||||
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/bin/Other.exe", 3)
|
||||
val listing = JSONObject(AddedGames.writeListing(context, listOf(scanned())).readText().removePrefix("[").removeSuffix("]"))
|
||||
assertEquals("/root/Games/PC/Example/bin/Other.exe", listing.getString("exe"))
|
||||
assertEquals("/root/Games/PC/Example/bin", listing.getString("dir"))
|
||||
assertEquals(3, listing.getInt("seen"))
|
||||
assertEquals(first.appId, listing.getLong("appid"))
|
||||
}
|
||||
|
||||
@Test fun theAppidStaysWithTheFolderWhenItsExeChanges() {
|
||||
val first = scanned()
|
||||
SessionPrefs.setAddedGameExe(context, folder.path, other.path)
|
||||
val changed = scanned()
|
||||
assertEquals(other, changed.exe)
|
||||
assertEquals(first.appId, changed.appId)
|
||||
assertEquals(first.gameId, changed.gameId)
|
||||
}
|
||||
|
||||
@Test fun aSteamTargetOutsideTheFolderIsNotTaken() {
|
||||
val outside = File(games, "Elsewhere/Game.exe").apply { parentFile!!.mkdirs(); writeBytes(ByteArray(8)) }
|
||||
val first = scanned()
|
||||
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/../Elsewhere/Game.exe", 1)
|
||||
assertEquals(main, scanned().exe)
|
||||
assertEquals("", SessionPrefs.addedGameExe(context, folder.path))
|
||||
assertEquals(0, SessionPrefs.addedGameExeSeen(context, folder.path))
|
||||
assertTrue(outside.isFile)
|
||||
}
|
||||
|
||||
@Test fun steamTargetNeedsANewEditThatStartsFromTheAppsExe() {
|
||||
val guest = "/root/Games/PC/Example"
|
||||
fun game(asked: String, steam: String, rev: Int) = JSONObject()
|
||||
.put("app", JSONObject().put("Exe", "\"$guest/$asked\""))
|
||||
.put("steam", JSONObject().put("Exe", "\"$guest/$steam\""))
|
||||
.put("rev", rev)
|
||||
assertEquals(other, AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/Example.exe", 1))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/Example.exe", 2))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/bin/Other.exe", 0))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "Example.exe", 2), "$guest/Example.exe", 0))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "Missing.exe", 2), "$guest/Example.exe", 0))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, null, "$guest/Example.exe", 0))
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -21,16 +21,16 @@ import java.io.File
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class GameFilesTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private fun game(id: String = "8400", name: String = "Geometry Wars") =
|
||||
Library.SteamGame(8400, name, null, "internal", id.toULong().toLong())
|
||||
|
||||
@Test fun exportsPortableFilesAndAcceptsFileUriRawPathAndContentUri() {
|
||||
val folder = tmp.newFolder()
|
||||
val file = GameFiles.export(app, 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)))
|
||||
@@ -45,15 +45,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"))))
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ class GameFilesTest {
|
||||
assertEquals("custom artwork", image.readText())
|
||||
GameFiles.sync(app, 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(app, fallback, game)
|
||||
@@ -81,7 +82,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")
|
||||
@@ -111,10 +112,12 @@ class GameFilesTest {
|
||||
GameFiles.sync(app, 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(app, folder, listOf(second)) }
|
||||
assertEquals("personal file", collision.readText())
|
||||
assertTrue(File(folder, GameFiles.filename(first)).isFile)
|
||||
collision.delete()
|
||||
fallback.delete()
|
||||
GameFiles.sync(app, folder, listOf(second))
|
||||
assertFalse(File(folder, GameFiles.filename(first)).exists())
|
||||
assertTrue(collision.isFile)
|
||||
@@ -133,10 +136,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(app, folder, game()) }
|
||||
assertEquals("droiddeck://game/8400\n", outside.readText())
|
||||
assertEquals("8400", outside.readText())
|
||||
}
|
||||
|
||||
@Test fun caseOnlyRenameKeepsTheLiveExportOnCaseInsensitiveStorage() {
|
||||
@@ -149,6 +152,8 @@ class GameFilesTest {
|
||||
assertTrue(file.isFile)
|
||||
assertEquals("8400", file.inputStream().use(GameFiles::read))
|
||||
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
GameFiles.sync(app, folder, emptyList())
|
||||
assertFalse(file.exists())
|
||||
}
|
||||
|
||||
@Test fun unrelatedInventoryIsPreserved() {
|
||||
@@ -159,6 +164,52 @@ class GameFilesTest {
|
||||
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
|
||||
}
|
||||
|
||||
@Test fun steamExportContainsOnlyIdForSteamAndAddedGames() {
|
||||
val folder = tmp.newFolder()
|
||||
val file = GameFiles.export(app, 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(app, 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(app, 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(app, folder, listOf(first, second, existing))
|
||||
assertEquals("user edit", owned.readText())
|
||||
GameFiles.sync(app, 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(app, folder, listOf(game()))
|
||||
GameFiles.sync(app, folder, emptyList())
|
||||
assertEquals("other file", collision.readText())
|
||||
assertEquals("8400", existing.readText())
|
||||
existing.writeText("another file")
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GameFiles.sync(app, 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")))
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,17 @@ class DriverPairsTest {
|
||||
|
||||
@Test
|
||||
fun onlyTestedHardwareCountsAsSupported() {
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(650).support)
|
||||
assertEquals("Supported", gpu(650).supportText)
|
||||
assertEquals(
|
||||
GpuInfo.Support.UNTESTED,
|
||||
GpuInfo("Adreno 650", 650, Family.ADRENO_UNKNOWN, "", false).support,
|
||||
)
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(740).support)
|
||||
assertEquals(GpuInfo.Support.TESTED, gpu(825).support)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(610).support)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(660).support)
|
||||
assertEquals("Outside tested hardware (Adreno 650, 725 and newer): it may not run", gpu(660).supportText)
|
||||
assertEquals(GpuInfo.Support.UNTESTED, gpu(720).support)
|
||||
assertEquals(GpuInfo.Support.UNSUPPORTED, gpu(0, adreno = false).support)
|
||||
}
|
||||
|
||||
@@ -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", ""))
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GuestFontsTest {
|
||||
private fun region(tag: String) = GuestFonts.cjkRegion(Locale.forLanguageTag(tag))
|
||||
|
||||
@Test fun sharedCharactersTakeTheReadersRegionalShapes() {
|
||||
assertEquals("JP", region("ja"))
|
||||
assertEquals("KR", region("ko"))
|
||||
assertEquals("SC", region("zh-CN"))
|
||||
assertEquals("TC", region("zh-TW"))
|
||||
assertEquals("HK", region("zh-HK"))
|
||||
assertEquals("HK", region("zh-MO"))
|
||||
assertEquals("SC", region("zh-Hans-HK"))
|
||||
assertEquals("TC", region("zh-Hant"))
|
||||
assertEquals("SC", region("en"))
|
||||
}
|
||||
|
||||
@Test fun latinStaysOnDejaVuWithTheRegionsCjkNext() {
|
||||
val conf = GuestFonts.preferenceConf(Locale.JAPANESE)
|
||||
val sans = conf.substringAfter("<family>sans-serif</family>").substringBefore("</alias>")
|
||||
assertTrue(sans, sans.indexOf("DejaVu Sans") in 0 until sans.indexOf("Noto Sans CJK JP"))
|
||||
assertTrue(conf.contains("<family>Noto Serif CJK JP</family>"))
|
||||
assertTrue(conf.contains("<family>Noto Sans Mono CJK JP</family>"))
|
||||
assertTrue(conf.startsWith("<?xml"))
|
||||
}
|
||||
}
|
||||
@@ -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,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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.frontend.AddedGames
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
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
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class GameStorageTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun gamesFoldersBecomeLibrariesOnAutomatic() {
|
||||
val pc = tmp.newFolder("PC")
|
||||
val missing = File(tmp.root, "Unplugged")
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, missing.path))
|
||||
val libraries = GameStorage.gamesFolderLibraries(app)
|
||||
assertEquals(listOf(pc), libraries.map { it.host })
|
||||
assertEquals(listOf(AddedGames.GUEST_DIR + "/PC"), libraries.map { it.guest })
|
||||
assertEquals(listOf("PC"), libraries.map { it.label })
|
||||
}
|
||||
|
||||
@Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() {
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path))
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
|
||||
SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen")
|
||||
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun namesTheClientCannotStoreAreLeftOut() {
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path))
|
||||
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ class SecondaryLibraryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aGamesFolderLibraryIsBoundOnlyAtItsOwnPath() {
|
||||
val files = tmp.newFolder("files")
|
||||
val library = tmp.newFolder("PC")
|
||||
val binds = SecondaryLibrary.binds(files, library, listOf("/root/Games/PC"))
|
||||
val private = SecondaryLibrary.privateRoot(files, library)
|
||||
assertEquals("${library.path}:/root/Games/PC", binds.first())
|
||||
assertTrue(binds.contains("$private/steamapps/compatdata:/root/Games/PC/steamapps/compatdata"))
|
||||
assertTrue(binds.drop(1).all { it.substringAfter(':').startsWith("/root/Games/PC/steamapps/") })
|
||||
assertFalse(binds.any { it.contains("/mnt/") })
|
||||
}
|
||||
|
||||
@Test fun migrationPreservesSavesLinksAndOriginalAndNeverOverwritesNewSaves() {
|
||||
val files = tmp.newFolder("files")
|
||||
val library = tmp.newFolder("card")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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))
|
||||
}
|
||||
|
||||
@Test fun backgroundDownloadsAreOptInAndMigrateTheTestBuildPolicy() {
|
||||
assertEquals(false, SessionPrefs.steamDownloadsInBackground(context))
|
||||
SessionPrefs.setSteamDownloadsInBackground(context, true)
|
||||
assertEquals(true, SessionPrefs.steamDownloadsInBackground(context))
|
||||
|
||||
clearSettings()
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
|
||||
.putString("suspendPolicy.steam", "downloads").commit()
|
||||
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
|
||||
assertEquals(true, SessionPrefs.steamDownloadsInBackground(context))
|
||||
|
||||
SessionPrefs.setSuspendPolicy(context, SessionService.MODE_STEAM, SessionPrefs.SUSPEND_MANUAL)
|
||||
assertEquals(SessionPrefs.SUSPEND_MANUAL, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
|
||||
assertEquals(true, SessionPrefs.steamDownloadsInBackground(context))
|
||||
|
||||
clearSettings()
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
|
||||
.putString("suspendPolicy.steam", "downloads").commit()
|
||||
SessionPrefs.setSteamDownloadsInBackground(context, false)
|
||||
assertEquals(SessionPrefs.SUSPEND_AUTO, SessionPrefs.suspendPolicy(context, SessionService.MODE_STEAM))
|
||||
assertEquals(false, SessionPrefs.steamDownloadsInBackground(context))
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,34 @@ import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDisplayTest {
|
||||
@Test fun explicitPresetsAndMatchScreenResolveWithoutAnyAspectSetting() {
|
||||
@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 panelOptionReplacesTheIdenticalPresetAndTracksPanelChanges() {
|
||||
@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("1280x720", "1600x900", SessionDisplay.MATCH_SCREEN), thor)
|
||||
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)
|
||||
assertFalse(small.contains("1280x720"))
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class SessionLogMigrationTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun source(name: String = "2026-10-06-01-steam"): File = tmp.newFolder(name).apply {
|
||||
File(this, "steam").mkdir()
|
||||
File(this, "session.log").writeText("Using JWT 25484942796017334\n")
|
||||
File(this, "steam/console_log.txt").writeText("password=private\n")
|
||||
File(this, ".complete").writeText("done")
|
||||
}
|
||||
|
||||
@Test fun legacySessionsMoveIntoPrivateStorage() {
|
||||
val source = source()
|
||||
val target = tmp.newFolder("private")
|
||||
SessionLogMigration.move(source, target)
|
||||
assertFalse(source.exists())
|
||||
val moved = File(target, source.name)
|
||||
assertTrue(SessionPaths.isSessionFolder(moved))
|
||||
assertTrue(File(moved, "steam/console_log.txt").readText().contains("password=private"))
|
||||
assertTrue(File(moved, ".complete").isFile)
|
||||
}
|
||||
|
||||
@Test fun crossFilesystemFallbackCopiesEverythingBeforeRemovingTheOriginal() {
|
||||
val original = source()
|
||||
val source = object : File(original.path) {
|
||||
override fun renameTo(dest: File): Boolean = false
|
||||
}
|
||||
val target = tmp.newFolder("private")
|
||||
SessionLogMigration.move(source, target)
|
||||
assertFalse(original.exists())
|
||||
assertEquals("Using JWT 25484942796017334\n", File(target, "${original.name}/session.log").readText())
|
||||
assertEquals(listOf(original.name), target.list()!!.toList())
|
||||
}
|
||||
|
||||
@Test fun aCopyFailurePreservesAllOriginalFiles() {
|
||||
val original = source()
|
||||
val source = object : File(original.path) {
|
||||
override fun renameTo(dest: File): Boolean = false
|
||||
}
|
||||
val target = tmp.newFolder("private")
|
||||
target.setWritable(false)
|
||||
try {
|
||||
assertThrows(java.io.IOException::class.java) { SessionLogMigration.move(source, target) }
|
||||
} finally {
|
||||
target.setWritable(true)
|
||||
}
|
||||
assertTrue(File(original, "session.log").isFile)
|
||||
assertTrue(File(original, "steam/console_log.txt").isFile)
|
||||
assertTrue(target.list()!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun collisionsPreserveBothSessionsAndKeepLegacyNamesRecognizable() {
|
||||
val original = source("session-20260930-180642-2")
|
||||
val target = tmp.newFolder("private")
|
||||
File(target, original.name).mkdir()
|
||||
SessionLogMigration.move(original, target)
|
||||
assertEquals(2, target.listFiles()!!.size)
|
||||
assertTrue(target.listFiles()!!.all { SessionPaths.isSessionFolder(it) })
|
||||
}
|
||||
|
||||
@Test fun linksAreRejectedWithoutReadingOrDeletingTheirTargets() {
|
||||
val source = source()
|
||||
val outside = tmp.newFile("outside").apply { writeText("outside data") }
|
||||
Files.createSymbolicLink(File(source, "steam/connection_log.txt").toPath(), outside.toPath())
|
||||
val target = tmp.newFolder("private")
|
||||
assertThrows(IllegalStateException::class.java) { SessionLogMigration.move(source, target) }
|
||||
assertTrue(File(source, "session.log").exists())
|
||||
assertTrue(target.list()!!.isEmpty())
|
||||
assertEquals("outside data", outside.readText())
|
||||
}
|
||||
|
||||
@Test fun cleanupDoesNotFollowNestedDirectoryLinks() {
|
||||
val source = source()
|
||||
val outside = tmp.newFolder("outside")
|
||||
val preserved = File(outside, "session.log").apply { writeText("outside data") }
|
||||
Files.createSymbolicLink(File(source, "linked").toPath(), outside.toPath())
|
||||
SessionLogFiles.deleteTree(source)
|
||||
assertFalse(source.exists())
|
||||
assertEquals("outside data", preserved.readText())
|
||||
}
|
||||
@Test fun aCopiedCollisionKeepsTheNewerSessionAsTheLatest() {
|
||||
val original = source()
|
||||
original.setLastModified(1000L)
|
||||
val source = object : File(original.path) {
|
||||
override fun renameTo(dest: File): Boolean = false
|
||||
}
|
||||
val target = tmp.newFolder("private")
|
||||
val newer = File(target, original.name).apply { mkdir(); setLastModified(2000L) }
|
||||
SessionLogMigration.move(source, target)
|
||||
assertEquals(newer, target.listFiles()!!.sortedWith(SessionPaths.chronological).last())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.junit.Assert.*
|
||||
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.io.RandomAccessFile
|
||||
import java.io.StringWriter
|
||||
import java.io.Writer
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class SessionLogShareTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
private val secret = "Using JWT 25484942796017334 sessionid=abcdef123456 password=secret123"
|
||||
private val leaked = listOf("25484942796017334", "abcdef123456", "secret123")
|
||||
|
||||
private fun write(folder: File, path: String, text: String = "$secret\n"): File =
|
||||
File(folder, path).apply { parentFile!!.mkdirs(); writeText(text) }
|
||||
|
||||
private fun zip(folder: File): Map<String, String> =
|
||||
ZipFile(SessionLogShare.zipFolder(context, folder)!!).use { z ->
|
||||
z.entries().toList().associate { it.name.substringAfter('/') to z.getInputStream(it).bufferedReader().readText() }
|
||||
}
|
||||
|
||||
private fun assertSafe(files: Map<String, String>) {
|
||||
files.forEach { (name, text) -> leaked.forEach { assertFalse(name, text.contains(it)) } }
|
||||
}
|
||||
|
||||
@Test fun workingLogsAndCommandLogsUsePrivateStorage() {
|
||||
assertEquals(File(context.filesDir, "logs"), SessionFiles.logDirectory(context))
|
||||
assertEquals(SessionFiles.logDirectory(context), LinuxRuntime.debugLogDir(context))
|
||||
assertTrue(File(LinuxRuntime.debugLogDir(context), SessionPaths.TOOLS_DIR).path.startsWith(context.filesDir.path + "/"))
|
||||
val dir = SessionPaths.beginOrCurrent(context, "privacy-test")
|
||||
try {
|
||||
assertTrue(dir.path.startsWith(context.filesDir.path + "/") || dir.path.startsWith(context.cacheDir.path + "/"))
|
||||
} finally {
|
||||
SessionPaths.take()
|
||||
SessionPaths.release(context, dir)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun forgedAndStaleScrubRecordsNeverBypassRedaction() {
|
||||
val folder = tmp.newFolder("2026-10-06-01-steam")
|
||||
val file = write(folder, "session.log")
|
||||
write(folder, ".scrubbed-tree", "rules 1\n${file.length()}\t${file.lastModified()}\tsession.log\n")
|
||||
write(folder, ".complete", "done")
|
||||
assertSafe(zip(folder))
|
||||
val modified = file.lastModified()
|
||||
file.writeText("$secret\n")
|
||||
file.setLastModified(modified)
|
||||
assertSafe(zip(folder))
|
||||
assertEquals(setOf("session.log"), zip(folder).keys)
|
||||
}
|
||||
|
||||
@Test fun allowlistIncludesDiagnosticsAndExcludesCredentialStoresAndUnknownFiles() {
|
||||
val folder = tmp.newFolder("2026-10-06-02-steam")
|
||||
val allowed = listOf("session.log", "app.log", "crash-app.txt", "events.jsonl",
|
||||
"steam/console_log.txt", "droiddeck-esync/launches.log", "droiddeck-fex/status.json")
|
||||
val excluded = listOf(".hidden.txt", ".session.log.scrub", "steam/deep/nested.txt",
|
||||
"steam/loginusers.vdf", "steam/config.vdf", "ssfn123", "arbitrary.txt",
|
||||
"steam/token.txt", "droiddeck-fex/config/secrets.json")
|
||||
(allowed + excluded).forEach { write(folder, it) }
|
||||
val files = zip(folder)
|
||||
assertEquals(allowed.toSet(), files.keys)
|
||||
assertSafe(files)
|
||||
}
|
||||
|
||||
@Test fun oversizedLiveAndFinishedLogsAreOmittedWithoutRawFallback() {
|
||||
for (finished in listOf(false, true)) for (size in listOf(SessionLogFiles.MAX_BYTES, SessionLogFiles.MAX_BYTES + 1)) {
|
||||
val folder = tmp.newFolder("2026-10-06-${if (finished) "03" else "04"}-steam-$size")
|
||||
write(folder, "app.log")
|
||||
val large = write(folder, "session.log")
|
||||
RandomAccessFile(large, "rw").use { it.setLength(size) }
|
||||
if (finished) write(folder, ".complete", "done")
|
||||
val files = zip(folder)
|
||||
assertFalse(files.containsKey("session.log"))
|
||||
assertTrue(files.getValue("omitted.txt").contains("session.log"))
|
||||
assertSafe(files)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun binaryDataPastTheProbeMalformedUtf8AndLongLinesAreOmitted() {
|
||||
val folder = tmp.newFolder("2026-10-06-05-steam")
|
||||
write(folder, "session.log", "safe\n".repeat(2000) + "$secret\n\u0000")
|
||||
File(folder, "app.log").writeBytes(byteArrayOf(0xc3.toByte(), 0x28))
|
||||
write(folder, "crash.log", "x".repeat(65537) + secret)
|
||||
val files = zip(folder)
|
||||
assertEquals(setOf("omitted.txt"), files.keys)
|
||||
assertSafe(files)
|
||||
}
|
||||
|
||||
@Test fun symlinkFilesAndDirectoriesCannotExportOutsideData() {
|
||||
val folder = tmp.newFolder("2026-10-06-06-steam")
|
||||
val outside = tmp.newFolder("outside")
|
||||
val target = write(outside, "console_log.txt", "outside data\n")
|
||||
Files.createSymbolicLink(File(folder, "session.log").toPath(), target.toPath())
|
||||
Files.createSymbolicLink(File(folder, "steam").toPath(), outside.toPath())
|
||||
val files = zip(folder)
|
||||
assertEquals(setOf("omitted.txt"), files.keys)
|
||||
assertTrue(files.values.none { it.contains("outside data") })
|
||||
val alias = File(tmp.root, "2026-10-06-07-steam")
|
||||
Files.createSymbolicLink(alias.toPath(), folder.toPath())
|
||||
assertFalse(SessionPaths.isSessionFolder(alias))
|
||||
assertNull(SessionLogShare.zipFolder(context, alias))
|
||||
}
|
||||
|
||||
@Test fun failedTeardownScrubbingStillRedactsExports() {
|
||||
val folder = tmp.newFolder("2026-10-06-08-steam")
|
||||
val file = write(folder, "session.log")
|
||||
folder.setWritable(false)
|
||||
try {
|
||||
SessionArtifacts.scrubTree(folder)
|
||||
} finally {
|
||||
folder.setWritable(true)
|
||||
}
|
||||
assertTrue(file.readText().contains(leaked.first()))
|
||||
assertSafe(zip(folder))
|
||||
}
|
||||
|
||||
@Test fun unreadableAllowedFilesAreOmitted() {
|
||||
val folder = tmp.newFolder("2026-10-06-09-steam")
|
||||
val file = write(folder, "session.log")
|
||||
file.setReadable(false)
|
||||
try {
|
||||
val files = zip(folder)
|
||||
assertEquals(setOf("omitted.txt"), files.keys)
|
||||
assertSafe(files)
|
||||
} finally {
|
||||
file.setReadable(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun successfulTeardownScrubsNestedDiagnosticsWithoutRecords() {
|
||||
val folder = tmp.newFolder("2026-10-06-10-steam")
|
||||
val paths = listOf("session.log", "crash-app.txt", "droiddeck-esync/launches.log", "steam/console_log.txt")
|
||||
paths.forEach { write(folder, it) }
|
||||
SessionArtifacts.scrubTree(folder)
|
||||
paths.forEach { path -> leaked.forEach { assertFalse(File(folder, path).readText().contains(it)) } }
|
||||
assertSafe(zip(folder))
|
||||
assertTrue(folder.walkTopDown().none { it.name.startsWith(".redacted-") })
|
||||
}
|
||||
|
||||
@Test fun steamLogsAtTheSizeLimitStayOut() {
|
||||
val folder = tmp.newFolder("2026-10-06-11-steam")
|
||||
write(folder, "session.log")
|
||||
val file = write(folder, "steam/console_log.txt")
|
||||
RandomAccessFile(file, "rw").use { it.setLength(SessionLogFiles.STEAM_MAX_BYTES) }
|
||||
val files = zip(folder)
|
||||
assertFalse(files.containsKey("steam/console_log.txt"))
|
||||
assertTrue(files.containsKey("omitted.txt"))
|
||||
assertSafe(files)
|
||||
}
|
||||
|
||||
@Test fun aGrowingFileIsLimitedToItsOpeningSnapshot() {
|
||||
val folder = tmp.newFolder("2026-10-06-12-steam")
|
||||
val file = write(folder, "session.log", "safe\n".repeat(2000) + "$secret\nunfinished")
|
||||
val output = StringWriter()
|
||||
var appended = false
|
||||
val writer = object : Writer() {
|
||||
override fun write(chars: CharArray, offset: Int, length: Int) {
|
||||
if (!appended) { appended = true; file.appendText("appended-marker $secret\n") }
|
||||
output.write(chars, offset, length)
|
||||
}
|
||||
override fun flush() = Unit
|
||||
override fun close() = Unit
|
||||
}
|
||||
SessionLogFiles.scrubTo(folder, file, writer)
|
||||
assertFalse(output.toString().contains("appended-marker"))
|
||||
assertFalse(output.toString().contains("unfinished"))
|
||||
assertTrue(output.toString().contains("<incomplete live line withheld>"))
|
||||
leaked.forEach { assertFalse(output.toString().contains(it)) }
|
||||
}
|
||||
|
||||
@Test fun aTruncatedFileFailsInsteadOfExportingAPartialSnapshot() {
|
||||
val folder = tmp.newFolder("2026-10-06-13-steam")
|
||||
val file = write(folder, "session.log", "safe\n".repeat(20000))
|
||||
val writer = object : Writer() {
|
||||
override fun write(chars: CharArray, offset: Int, length: Int) { file.writeText("") }
|
||||
override fun flush() = Unit
|
||||
override fun close() = Unit
|
||||
}
|
||||
assertThrows(java.io.IOException::class.java) { SessionLogFiles.scrubTo(folder, file, writer) }
|
||||
}
|
||||
|
||||
@Test fun anOutputFailureDoesNotLeaveAShareableArchive() {
|
||||
val folder = tmp.newFolder("2026-10-06-14-steam")
|
||||
write(folder, "session.log")
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
SessionLogShare.zipFolder(context, folder) { throw IllegalStateException("failed output") }
|
||||
}
|
||||
val out = File(context.cacheDir, "shared-logs")
|
||||
assertTrue(out.listFiles().orEmpty().isEmpty())
|
||||
assertTrue(context.cacheDir.listFiles().orEmpty().none { it.name.startsWith("redacted-") })
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ class SessionScalingPrefsTest {
|
||||
|
||||
@Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
assertEquals("1280x720", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080))
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SteamDownloadMonitorTest {
|
||||
@Test fun reportsOnlyProgressingIncompleteManifests() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
|
||||
try {
|
||||
val steam = File(root, "root/.local/share/Steam")
|
||||
val manifest = manifest(steam, "42", downloaded = 10, total = 100, flags = 6)
|
||||
val monitor = SteamDownloadMonitor()
|
||||
|
||||
assertFalse(monitor.poll(root, now = 1_000L))
|
||||
manifest.writeText(appManifest(downloaded = 20, total = 100, flags = 6))
|
||||
assertTrue(monitor.poll(root, now = 3_000L))
|
||||
assertTrue(monitor.poll(root, now = 9_000L))
|
||||
assertFalse(monitor.poll(root, now = 11_001L))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun findsDownloadsInAdditionalSteamLibraries() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
|
||||
try {
|
||||
val library = File(root, "mnt/droiddeck-sd").apply { mkdirs() }
|
||||
File(root, "root/.local/share/Steam/steamapps").apply { mkdirs() }
|
||||
.resolve("libraryfolders.vdf")
|
||||
.writeText("\"libraryfolders\" { \"1\" { \"path\" \"/mnt/droiddeck-sd\" } }")
|
||||
val manifest = manifest(library, "43", downloaded = 1, total = 2, flags = 6)
|
||||
val monitor = SteamDownloadMonitor()
|
||||
|
||||
assertFalse(monitor.poll(root, now = 1_000L))
|
||||
manifest.writeText(appManifest(downloaded = 2, total = 2, flags = 4))
|
||||
assertFalse(monitor.poll(root, now = 3_000L))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun followsSteamContentActivityWhenManifestProgressIsNotFlushed() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
|
||||
try {
|
||||
val steam = File(root, "root/.local/share/Steam")
|
||||
manifest(steam, "44", downloaded = 0, total = 10_000, flags = 10)
|
||||
val contentLog = File(steam, "logs/content_log.txt").apply {
|
||||
check(parentFile?.mkdirs() != false)
|
||||
writeText("[2026-10-06 14:44:17] AppID 44 App update changed : Running Update,Downloading,Staging,\n")
|
||||
}
|
||||
val monitor = SteamDownloadMonitor()
|
||||
|
||||
assertTrue(monitor.poll(root, now = 1_000L))
|
||||
assertTrue(monitor.poll(root, now = 90_000L))
|
||||
assertFalse(monitor.poll(root, now = 91_001L))
|
||||
|
||||
contentLog.appendText("[2026-10-06 14:45:02] Current download rate: 27.178 Mbps\n")
|
||||
assertTrue(monitor.poll(root, now = 92_000L))
|
||||
|
||||
contentLog.appendText("[2026-10-06 14:45:03] AppID 44 App update changed : None\n")
|
||||
assertFalse(monitor.poll(root, now = 93_000L))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun receivedTrafficBridgesSteamRateLines() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
|
||||
try {
|
||||
val contentLog = File(root, "root/.local/share/Steam/logs/content_log.txt").apply {
|
||||
check(parentFile?.mkdirs() != false)
|
||||
writeText("[2026-10-06 16:06:30] AppID 44 App update changed : Running Update,Downloading,\n")
|
||||
}
|
||||
val monitor = SteamDownloadMonitor()
|
||||
var received = 1_000_000L
|
||||
var now = 1_000L
|
||||
|
||||
assertTrue(monitor.poll(root, now, received))
|
||||
// Five minutes of a download Steam no longer logs, at 4 MB/s.
|
||||
while (now < 300_000L) {
|
||||
now += 2_000L
|
||||
received += 8_000_000L
|
||||
assertTrue(monitor.poll(root, now, received))
|
||||
}
|
||||
// Traffic stops: the session may suspend once the grace runs out.
|
||||
assertTrue(monitor.poll(root, now + 90_000L, received))
|
||||
assertFalse(monitor.poll(root, now + 92_001L, received))
|
||||
|
||||
contentLog.appendText("[2026-10-06 16:12:00] AppID 44 App update changed : None\n")
|
||||
assertFalse(monitor.poll(root, now + 94_000L, received + 8_000_000L))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun trafficAloneIsNotADownload() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
|
||||
try {
|
||||
val monitor = SteamDownloadMonitor()
|
||||
assertFalse(monitor.poll(root, now = 1_000L, receivedBytes = 0L))
|
||||
assertFalse(monitor.poll(root, now = 3_000L, receivedBytes = 50_000_000L))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun newSteamSessionClearsStaleContentActivity() {
|
||||
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
|
||||
try {
|
||||
val log = File(root, "root/.local/share/Steam/logs/content_log.txt").apply {
|
||||
check(parentFile?.mkdirs() != false)
|
||||
writeText("[old] AppID 44 App update changed : Running Update,Downloading,\n")
|
||||
appendText("[new] Client version: 1\n")
|
||||
}
|
||||
|
||||
val monitor = SteamDownloadMonitor()
|
||||
assertFalse(monitor.poll(root, now = 1_000L))
|
||||
|
||||
log.appendText("[new] AppID 44 App update changed : Running Update,Downloading,\n")
|
||||
assertTrue(monitor.poll(root, now = 2_000L))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun manifest(root: File, appId: String, downloaded: Long, total: Long, flags: Int): File {
|
||||
val file = File(root, "steamapps/appmanifest_$appId.acf")
|
||||
check(file.parentFile?.mkdirs() != false)
|
||||
file.writeText(appManifest(downloaded, total, flags))
|
||||
return file
|
||||
}
|
||||
|
||||
private fun appManifest(downloaded: Long, total: Long, flags: Int) =
|
||||
"\"AppState\" { \"StateFlags\" \"$flags\" \"BytesDownloaded\" \"$downloaded\" \"BytesToDownload\" \"$total\" }"
|
||||
}
|
||||
@@ -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"))))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user