mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e71a14a6 | ||
|
|
b0cc2ce5c3 | ||
|
|
c24e32f0d7 | ||
|
|
5952753e23 | ||
|
|
d3a3015f36 | ||
|
|
8817d6e1f1 | ||
|
|
6a08b46c6c | ||
|
|
6e2a235e1b | ||
|
|
341bb13297 | ||
|
|
9310d19857 | ||
|
|
4454a6d39a | ||
|
|
453f6f5180 | ||
|
|
6d0484941e | ||
|
|
cf47910881 | ||
|
|
4f0f0cf5b1 | ||
|
|
a78d5cb600 |
No files matched your search
@@ -1216,8 +1216,70 @@ struct DeckHidraw {
|
||||
FakeInputRingHeader *ring = nullptr;
|
||||
size_t mapping_size = 0;
|
||||
uint8_t pending_feature = 0;
|
||||
bool touch = false; // the Mobile Touch experiment below, not the Deck
|
||||
int touch_index = 0;
|
||||
};
|
||||
|
||||
// ---- EXPERIMENT: Steam's Mobile Touch controller as a local device ----
|
||||
//
|
||||
// docs/development/steam-touch-controller.md. Steam Link hands the host its touch controls as a
|
||||
// remote HID device, 0000:11fb ("Mobile Touch Control"), which Steam's controller code classes as
|
||||
// k_eControllerType_MobileTouch by its ids alone. FAKE_EVDEV_TOUCHCTL=1 offers the client the same
|
||||
// ids as a local hidraw node, to see whether it builds the touch controller with no stream. The
|
||||
// 40-byte input report is read from TOUCHCTL_REPORT_FILE (written by hand while experimenting); every
|
||||
// output and feature report the client sends is logged and appended to TOUCHCTL_OUT_FILE.
|
||||
// FAKE_EVDEV_TOUCHCTL=<n> offers n of them: /dev/hidraw17, 18, ... with serials MT-DROIDDECK0001, 0002,
|
||||
// ..., each reading its own report file (touchctl.report, touchctl1.report, ...).
|
||||
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
|
||||
static constexpr int TOUCH_MAX = 4;
|
||||
static constexpr int TOUCH_REPORT_BYTES = 40;
|
||||
static constexpr int TOUCH_REPORT_INTERVAL_US = 8000;
|
||||
static constexpr const char *TOUCH_NAME = "Mobile Touch Control";
|
||||
static std::string touch_serial(int index) {
|
||||
char serial[32];
|
||||
snprintf(serial, sizeof(serial), "MT-DROIDDECK%04d", index + 1);
|
||||
return serial;
|
||||
}
|
||||
static std::string touch_report_file(int index) {
|
||||
return index == 0 ? std::string("/tmp/touchctl.report") : "/tmp/touchctl" + std::to_string(index) + ".report";
|
||||
}
|
||||
static constexpr const char *TOUCHCTL_OUT_FILE = "/tmp/touchctl.out";
|
||||
// While this file exists the device is unplugged: the open stream ends and new opens fail.
|
||||
static constexpr const char *TOUCHCTL_OFF_FILE = "/tmp/touchctl.off";
|
||||
// Generic Desktop / Game Pad: one 40-byte input report, one 64-byte output report, one 64-byte feature report.
|
||||
static const uint8_t kTouchReportDescriptor[] = {
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
|
||||
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
|
||||
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
|
||||
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0};
|
||||
|
||||
static int touchctl_count() {
|
||||
static int count = -1;
|
||||
if (count < 0) count = getenv("FAKE_EVDEV_TOUCHCTL") ? std::max(0, std::min(TOUCH_MAX, atoi(getenv("FAKE_EVDEV_TOUCHCTL")))) : 0;
|
||||
return count;
|
||||
}
|
||||
static bool fake_touchctl_enabled() { return touchctl_count() > 0; }
|
||||
|
||||
__attribute__((visibility("hidden"))) static void touchctl_note(const char *what, const uint8_t *data, size_t size) {
|
||||
static std::atomic<unsigned> count{0};
|
||||
unsigned n = ++count;
|
||||
char hex[3 * 72 + 1] = {};
|
||||
size_t shown = std::min<size_t>(size, 72);
|
||||
for (size_t i = 0; i < shown; i++) snprintf(hex + 3 * i, 4, "%02x ", data[i]);
|
||||
if (n <= 200 || (n & (n - 1)) == 0) Logger::log("touchctl: %s (%zu bytes): %s\n", what, size, hex);
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
int fd = my_open(TOUCHCTL_OUT_FILE, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
|
||||
if (fd >= 0) {
|
||||
char line[3 * 72 + 64];
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_REALTIME, &now);
|
||||
int length = snprintf(line, sizeof(line), "%ld.%03ld %s %s\n", static_cast<long>(now.tv_sec),
|
||||
now.tv_nsec / 1000000, what, hex);
|
||||
syscall(SYS_write, fd, line, length);
|
||||
syscall(SYS_close, fd);
|
||||
}
|
||||
}
|
||||
|
||||
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
|
||||
// back grips and trackpads (the second screen's Deck controls, DeckControls), already in the
|
||||
// Deck's axes and units, under a seqlock (FakeInputWriter.writeMotion / writeDeckControls).
|
||||
@@ -1307,7 +1369,8 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
|
||||
__attribute__((constructor)) static void log_client_start() {
|
||||
config();
|
||||
if (process_is_steam_client())
|
||||
Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
|
||||
Logger::log("loaded in the Steam client: deck %s, touchctl %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
|
||||
fake_touchctl_enabled() ? "on" : "off",
|
||||
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
|
||||
}
|
||||
|
||||
@@ -1483,6 +1546,172 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg) {
|
||||
auto *holder = static_cast<std::shared_ptr<DeckHidraw> *>(arg);
|
||||
std::shared_ptr<DeckHidraw> self = *holder;
|
||||
delete holder;
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
uint8_t last[TOUCH_REPORT_BYTES] = {};
|
||||
for (;;) {
|
||||
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
|
||||
Logger::log("touchctl: unplugged\n");
|
||||
break;
|
||||
}
|
||||
uint8_t report[TOUCH_REPORT_BYTES] = {};
|
||||
int fd = my_open(touch_report_file(self->touch_index).c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (fd >= 0) {
|
||||
syscall(SYS_read, fd, report, sizeof(report));
|
||||
syscall(SYS_close, fd);
|
||||
}
|
||||
if (memcmp(report, last, sizeof(report))) {
|
||||
touchctl_note(self->touch_index ? "input#1+" : "input", report, sizeof(report));
|
||||
memcpy(last, report, sizeof(report));
|
||||
}
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
Logger::log("touchctl: reader closed\n");
|
||||
syscall(SYS_close, self->peer);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Which of the experiment's sysfs files the client reads, each once: where its scan stops.
|
||||
__attribute__((visibility("hidden"))) static void touchctl_trace(int dirfd, const char *pathname) {
|
||||
if (!fake_touchctl_enabled() || !pathname || !process_is_steam_client()) return;
|
||||
std::string path;
|
||||
if (pathname[0] == '/' || dirfd == AT_FDCWD) {
|
||||
path = pathname;
|
||||
} else {
|
||||
char link[64], dir[PATH_MAX];
|
||||
snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd);
|
||||
ssize_t length = readlink(link, dir, sizeof(dir) - 1);
|
||||
if (length <= 0) return;
|
||||
path.assign(dir, length);
|
||||
path += '/';
|
||||
path += pathname;
|
||||
}
|
||||
if (path.find("usb2") == std::string::npos && path.find("usb3") == std::string::npos && path.find("hidraw17") == std::string::npos &&
|
||||
path.find("11FB") == std::string::npos && path.find("240:17") == std::string::npos)
|
||||
return;
|
||||
static std::mutex mutex;
|
||||
static auto *seen = new std::vector<std::string>();
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
if (seen->size() >= 128 || std::find(seen->begin(), seen->end(), path) != seen->end()) return;
|
||||
seen->push_back(path);
|
||||
}
|
||||
Logger::log("touchctl: client read %s\n", path.c_str());
|
||||
}
|
||||
|
||||
// The index of the experiment's node at pathname, or -1.
|
||||
__attribute__((visibility("hidden"))) static int touch_hidraw_index(const char *pathname) {
|
||||
if (!pathname || !fake_touchctl_enabled() || strncmp(pathname, "/dev/hidraw", 11)) return -1;
|
||||
char *end = nullptr;
|
||||
long minor = strtol(pathname + 11, &end, 10);
|
||||
if (!end || *end || minor < TOUCH_HIDRAW_MINOR || minor >= TOUCH_HIDRAW_MINOR + touchctl_count()) return -1;
|
||||
return static_cast<int>(minor - TOUCH_HIDRAW_MINOR);
|
||||
}
|
||||
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
|
||||
return touch_hidraw_index(pathname) >= 0;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags, int index) {
|
||||
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
|
||||
Logger::log("touchctl: open refused while unplugged\n");
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
if (!process_is_steam_client()) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1;
|
||||
int buffer = TOUCH_REPORT_BYTES * 4;
|
||||
setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer));
|
||||
if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0);
|
||||
if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK);
|
||||
auto touch = std::make_shared<DeckHidraw>();
|
||||
touch->peer = pair[1];
|
||||
touch->touch = true;
|
||||
touch->touch_index = index;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map()[pair[0]] = touch;
|
||||
}
|
||||
pthread_t thread;
|
||||
auto *arg = new std::shared_ptr<DeckHidraw>(touch);
|
||||
if (pthread_create(&thread, nullptr, touch_report_thread, arg) != 0) {
|
||||
delete arg;
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map().erase(pair[0]);
|
||||
syscall(SYS_close, pair[0]);
|
||||
syscall(SYS_close, pair[1]);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
pthread_detach(thread);
|
||||
Logger::log("touchctl: /dev/hidraw%u opened as fd %d\n", TOUCH_HIDRAW_MINOR + index, pair[0]);
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value);
|
||||
|
||||
__attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, ioctl_request_t op, void *argp) {
|
||||
if (_IOC_TYPE(op) != 'H') {
|
||||
errno = ENOTTY;
|
||||
return -1;
|
||||
}
|
||||
size_t size = _IOC_SIZE(op);
|
||||
switch (_IOC_NR(op)) {
|
||||
case 0x01:
|
||||
*static_cast<int *>(argp) = sizeof(kTouchReportDescriptor);
|
||||
return 0;
|
||||
case 0x02: {
|
||||
auto *descriptor = static_cast<uint8_t *>(argp);
|
||||
uint32_t want;
|
||||
memcpy(&want, descriptor, sizeof(want));
|
||||
memcpy(descriptor + 4, kTouchReportDescriptor, std::min<size_t>(want, sizeof(kTouchReportDescriptor)));
|
||||
return 0;
|
||||
}
|
||||
case 0x03: {
|
||||
struct {
|
||||
uint32_t bustype;
|
||||
int16_t vendor;
|
||||
int16_t product;
|
||||
} info = {BUS_USB, 0x0000, static_cast<int16_t>(0x11fb)};
|
||||
memcpy(argp, &info, sizeof(info));
|
||||
return 0;
|
||||
}
|
||||
case 0x04: return copy_ioctl_string(op, argp, TOUCH_NAME);
|
||||
case 0x05: return copy_ioctl_string(op, argp, ("usb-droiddeck-" + std::to_string(2 + touch.touch_index) + "/input0").c_str());
|
||||
case 0x08: return copy_ioctl_string(op, argp, touch_serial(touch.touch_index).c_str());
|
||||
case 0x06: // HIDIOCSFEATURE
|
||||
touchctl_note("set-feature", static_cast<uint8_t *>(argp), size);
|
||||
touch.pending_feature = size > 1 ? static_cast<uint8_t *>(argp)[1] : 0;
|
||||
return static_cast<int>(size);
|
||||
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery in Steam Link's controller
|
||||
auto *buf = static_cast<uint8_t *>(argp);
|
||||
touchctl_note("get-feature", buf, std::min<size_t>(size, 8));
|
||||
uint8_t id = buf[0];
|
||||
memset(buf, 0, size);
|
||||
buf[0] = id;
|
||||
if (size > 2) {
|
||||
buf[1] = 2;
|
||||
buf[2] = 100;
|
||||
}
|
||||
return static_cast<int>(std::min<size_t>(size, 18));
|
||||
}
|
||||
default:
|
||||
Logger::log("touchctl: unhandled hidraw ioctl 0x%02x\n", _IOC_NR(op));
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// libudev (systemd's sd-device) accepts a device only where its directory is on sysfs, which it
|
||||
// checks with fstatfs on the directory it resolved. The Deck's sysfs is the app's own files bound
|
||||
// into /sys, so those paths are said to be sysfs; nothing else is touched.
|
||||
@@ -1823,6 +2052,8 @@ EXPORT int open(const char *pathname, int flags, ...) {
|
||||
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
|
||||
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
|
||||
touchctl_trace(AT_FDCWD, pathname);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1897,6 +2128,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
|
||||
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
|
||||
touchctl_trace(dirfd, pathname);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1957,6 +2190,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR + touch_hidraw_index(pathname));
|
||||
return 0;
|
||||
}
|
||||
if (is_deck_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
@@ -2008,7 +2247,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
if (ret == 0 && deck_map().count(fd)) {
|
||||
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
|
||||
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
|
||||
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR + deck_map()[fd]->touch_index : DECK_HIDRAW_MINOR);
|
||||
return ret;
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -2054,6 +2293,7 @@ EXPORT int access(const char *pathname, int mode) {
|
||||
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2090,6 +2330,7 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
|
||||
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(dlsym(RTLD_NEXT, "faccessat"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2157,6 +2398,8 @@ EXPORT int inotify_add_watch(int fd, const char *pathname, uint32_t mask) {
|
||||
}
|
||||
|
||||
int ret = my_inotify_add_watch(fd, pathname, mask);
|
||||
if (fake_touchctl_enabled() && process_is_steam_client())
|
||||
Logger::log("touchctl: client watches %s (mask 0x%x) -> %d\n", pathname ? pathname : "(null)", mask, ret);
|
||||
if (fake_path)
|
||||
free(fake_path);
|
||||
return ret;
|
||||
@@ -2211,7 +2454,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
auto maker = uinput_map().find(fd);
|
||||
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
|
||||
auto deck = deck_map().find(fd);
|
||||
if (deck != deck_map().end()) return ioctl_deck(*deck->second, op, argp);
|
||||
if (deck != deck_map().end())
|
||||
return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp);
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller == controller_map().end()) {
|
||||
guard.unlock();
|
||||
@@ -2537,7 +2781,10 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
auto made = uinput_map().find(fd);
|
||||
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
|
||||
if (deck_map().count(fd)) return static_cast<ssize_t>(count);
|
||||
if (deck_map().count(fd)) {
|
||||
if (deck_map()[fd]->touch) touchctl_note("output", static_cast<const uint8_t *>(buf), count);
|
||||
return static_cast<ssize_t>(count);
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller != controller_map().end()) {
|
||||
if (fake_fd_is_stale(fd)) {
|
||||
@@ -2562,7 +2809,11 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
if (deck_map().count(fd)) {
|
||||
ssize_t total = 0;
|
||||
for (int i = 0; i < iovcnt; i++) total += static_cast<ssize_t>(iov[i].iov_len);
|
||||
bool touch = deck_map()[fd]->touch;
|
||||
for (int i = 0; i < iovcnt; i++) {
|
||||
if (touch) touchctl_note("output", static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
|
||||
total += static_cast<ssize_t>(iov[i].iov_len);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
auto made = uinput_map().find(fd);
|
||||
|
||||
@@ -175,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()
|
||||
@@ -927,6 +937,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()
|
||||
}
|
||||
@@ -938,6 +953,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
|
||||
@@ -1349,7 +1366,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() {
|
||||
@@ -1625,6 +1642,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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -42,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
|
||||
@@ -430,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 ──
|
||||
|
||||
@@ -690,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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,6 +1012,8 @@ 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))) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,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. */
|
||||
|
||||
@@ -426,7 +426,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(
|
||||
@@ -777,6 +777,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")
|
||||
@@ -897,6 +899,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
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// EXPERIMENT (docs/development/steam-touch-controller.md): Steam's Mobile Touch controller,
|
||||
// 0000:11fb, as a second local device. libfakeinput serves the node only with
|
||||
// FAKE_EVDEV_TOUCHCTL=1 (fakeinput_steam.cpp: TOUCH_HIDRAW_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
private const val TOUCH_DEVICES = 2
|
||||
|
||||
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
|
||||
private val REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
|
||||
@@ -51,6 +57,14 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Must match libfakeinput's kTouchReportDescriptor. */
|
||||
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
|
||||
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
|
||||
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
|
||||
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
@@ -107,6 +121,42 @@ object SteamDeckPad {
|
||||
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
|
||||
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
|
||||
// EXPERIMENT: Mobile Touch controllers beside the Deck, two of them (hidraw17, 18).
|
||||
for (index in 0 until TOUCH_DEVICES) {
|
||||
val minor = TOUCH_MINOR + index
|
||||
val node = "hidraw$minor"
|
||||
val bus = 2 + index
|
||||
val usb = "$GUEST_DEVICES/usb$bus"
|
||||
val iface = "$usb/$bus-1:1.0"
|
||||
val hid = "$iface/0003:0000:11FB.000${2 + index}"
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
val serial = "MT-DROIDDECK%04d".format(index + 1)
|
||||
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
|
||||
write(usb, "idVendor", "0000\n")
|
||||
write(usb, "idProduct", "11fb\n")
|
||||
write(usb, "bcdDevice", "0000\n")
|
||||
write(usb, "manufacturer", "Valve Software\n")
|
||||
write(usb, "product", "Mobile Touch Control\n")
|
||||
write(usb, "serial", "$serial\n")
|
||||
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
|
||||
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
|
||||
write(iface, "bInterfaceNumber", "00\n")
|
||||
write(iface, "bInterfaceClass", "03\n")
|
||||
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
|
||||
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-$bus/input0\n" +
|
||||
"HID_UNIQ=$serial\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
link(File(hidrawClass, node), hidraw)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
}
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -353,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>
|
||||
@@ -1401,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>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<string name="hud_fg_failed">%1$s : échec du moteur</string>
|
||||
<string name="hud_fg_needs_lossless">%1$s : nécessite Lossless Scaling</string>
|
||||
<string name="hud_fg_starting">%1$s %2$s · Démarrage en cours</string>
|
||||
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam.</string>
|
||||
<string name="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>
|
||||
@@ -300,8 +300,8 @@
|
||||
<string name="mode_wifi_explain_title">Activer la découverte Wi-Fi ?</string>
|
||||
<string name="mode_wifi_explain_text">Android a besoin de l\'autorisation de localisation pour lire les noms Wi-Fi et découvrir les réseaux à proximité. DroidDeck utilise cette autorisation pour afficher ces réseaux dans Steam. Nous ne suivons pas votre position. L\'accès à Internet reste possible sans cette autorisation.</string>
|
||||
<string name="mode_storage">Stockage des jeux</string>
|
||||
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible</string>
|
||||
<string name="mode_storage_auto_none">Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement)</string>
|
||||
<string name="mode_storage_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>
|
||||
@@ -947,4 +947,5 @@
|
||||
<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>
|
||||
@@ -353,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>
|
||||
@@ -1377,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -353,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>
|
||||
@@ -1377,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -300,8 +300,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-карту при наличии</string>
|
||||
<string name="mode_storage_auto_none">Авто - Использовать SD-карту при наличии (сейчас нет SD-карты)</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>
|
||||
@@ -955,4 +955,5 @@
|
||||
<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>
|
||||
@@ -353,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>
|
||||
@@ -1377,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -353,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>
|
||||
@@ -1377,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -353,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>
|
||||
@@ -1377,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>
|
||||
|
||||
@@ -43,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>
|
||||
@@ -357,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>
|
||||
@@ -1345,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>
|
||||
@@ -1850,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 -->
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,89 @@
|
||||
# Steam session focus: how it works and how to debug it
|
||||
|
||||
Written 2026-10-07 after the "game opens behind Steam" reports (Skyrim SE, A Plague Tale:
|
||||
Innocence, Burnout Paradise Remastered), fixed by gamescope patch 0114 (#401). It records what a
|
||||
game needs to get focus in a Steam session and how to inspect a live session on a device, for the
|
||||
next report that looks like this.
|
||||
|
||||
## How a game gets focus
|
||||
|
||||
The Steam session runs two Xwayland servers, as SteamOS does: the client on `:0`, games on `:1`
|
||||
(`--xwayland-count 2`, `STEAM_MULTIPLE_XWAYLANDS=1` in `droiddeck-session`). Focus is decided in
|
||||
three steps, and a window can drop out at each one:
|
||||
|
||||
1. **gamescope lists candidates.** For each server it collects windows that are mapped, of class
|
||||
InputOutput, not fully transparent (`_NET_WM_WINDOW_OPACITY` above 0), not an overlay, and
|
||||
carry an app id (`STEAM_GAME`). It publishes them on `:0`'s root as `GAMESCOPE_FOCUSABLE_APPS`
|
||||
and `GAMESCOPE_FOCUSABLE_WINDOWS` (window, app id, pid triples). It leaves out of that published
|
||||
list any window it believes is override-redirect, 1x1, or skip-taskbar-and-pager without
|
||||
fullscreen.
|
||||
2. **Steam picks an order.** The client reads those lists and writes
|
||||
`GAMESCOPECTRL_BASELAYER_APPID`, for example `413091, 489830, 769`. When the running game has
|
||||
no window in the focusable list, Steam puts itself (769) ahead of it.
|
||||
3. **gamescope follows Steam.** The virtual connector strategy in a Steam session is
|
||||
SteamControlled: the first app id in the base layer with a candidate window wins.
|
||||
|
||||
gamescope works from its own cached copy of each window's attributes, not from X. A window that X
|
||||
shows mapped and tagged can still be missing in step 1 if that cache is stale.
|
||||
|
||||
## What the bug looked like
|
||||
|
||||
- On screen: Steam's black loading screen with only **ABORT GAME**, the game audible underneath.
|
||||
Resume from the Steam menu does not help.
|
||||
- `GAMESCOPE_FOCUSED_APP` stays `769`; `GAMESCOPECTRL_BASELAYER_APPID` has `769` ahead of the
|
||||
game's app id; the game's window is missing from `GAMESCOPE_FOCUSABLE_WINDOWS`.
|
||||
- On `:1` the game window is mapped, `WM_STATE` Normal, `STEAM_GAME` set, with no opacity,
|
||||
`_NET_WM_STATE` or overlay properties.
|
||||
- gamescope's `focus_info` shows server 1's own focus on the game while the global focus is Steam.
|
||||
|
||||
Cause: wine creates the game's window override-redirect and makes it managed before mapping it,
|
||||
which sends no X event. gamescope read the attributes when the window was created and refreshed
|
||||
`override_redirect` only from ConfigureNotify, so when its read came before wine's change it kept
|
||||
the window as override-redirect and left it out of the list Steam reads. It is a race: Skyrim
|
||||
failed about once in six launches on the Thor, on the first launch after a session start. Upstream
|
||||
fixed it by taking the flag from MapNotify (ValveSoftware/gamescope 3829340, our patch 0114).
|
||||
|
||||
The launcher and the game both had window id `0x1c00003` on `:1`: with few clients on that server
|
||||
the X server hands the game the launcher's freed id. That was not the cause (the game window came
|
||||
7 s after the launcher's was destroyed), but it makes logs easy to misread.
|
||||
|
||||
## Inspecting a live session from adb
|
||||
|
||||
Everything below runs on the device as the app's uid (`su 10195` on the Thor, `u0_a195`), using
|
||||
the rootfs's own binaries through its loader, so nothing has to enter proot:
|
||||
|
||||
```sh
|
||||
R=/data/user/0/com.droiddeck.launcher/files/linuxfs
|
||||
$R/usr/lib/ld-linux-aarch64.so.1 --library-path $R/usr/lib $R/usr/bin/xprop -display :0 -root \
|
||||
GAMESCOPE_FOCUSED_APP GAMESCOPE_FOCUSABLE_WINDOWS GAMESCOPECTRL_BASELAYER_APPID
|
||||
```
|
||||
|
||||
- The X servers answer on their **abstract** sockets (`\0/tmp/.X11-unix/X0`, `X1`); xprop finds
|
||||
them by itself. The socket files under `linuxfs/tmp/.X11-unix` can be stale. Nothing answers
|
||||
while the session is in the background or the screen is off (the session is stopped).
|
||||
- The `VAR=… cmd` prefix does not survive `timeout` or `nohup`; put the command in a small
|
||||
`/system/bin/sh` script in `/data/local/tmp` and run that.
|
||||
- gamescope's focus state, written to the session's `session.log` (in
|
||||
`Download/DroidDeck/<date>-NN-steam/`):
|
||||
|
||||
```sh
|
||||
export XDG_RUNTIME_DIR=/data/user/0/com.droiddeck.launcher/files/.wayland-rt WAYLAND_DISPLAY=gamescope-0
|
||||
G="$R/usr/lib/ld-linux-aarch64.so.1 --library-path $R/usr/lib $R/usr/bin/gamescopectl"
|
||||
$G log_focus debug; $G focus_info
|
||||
```
|
||||
|
||||
It prints the global focus, input, keyboard and override windows, the connector strategy, and
|
||||
each server's own focus and keyboard focus. Disagreement between a server's focus and the
|
||||
global one points at step 1 or 2 above.
|
||||
- To tell a stale gamescope cache from a real X problem, make gamescope re-read the window: move it
|
||||
by 1 px and back (ConfigureNotify), or unmap and map it (MapNotify). If the game comes forward,
|
||||
gamescope's cache was wrong. A restack of a window already on top sends no event and proves
|
||||
nothing.
|
||||
- To test gamescope with a candidate binary without rebuilding the apk, put it in the rootfs (for
|
||||
example `/opt/gstest/gamescope`, owned by the app's uid) and add
|
||||
`PATH=/opt/gstest:/usr/local/bin:/usr/bin:/bin` to `Download/droiddeck-env`. The session log's
|
||||
first lines name the gamescope that ran. Remove the line afterwards: the app stages its own
|
||||
gamescope over `/usr/local/bin` at every session start.
|
||||
- Never `pkill -f <pattern>` inside `su -c '…'`: the pattern matches that shell's own command line.
|
||||
Killing `SkyrimSE.exe` that way took the whole session down. Kill by pid.
|
||||
- Skyrim's menus take adb key events (`input keyevent 20` down, `66` enter); taps do nothing.
|
||||
@@ -0,0 +1,402 @@
|
||||
# Steam's touch controller in a DroidDeck session
|
||||
|
||||
Research notes, started 2026-10-07. The question: instead of DroidDeck drawing its own on-screen
|
||||
pad and keeping its own profiles, can the session use the touch controller Steam already has for
|
||||
the Steam Link app, so that touch layouts are per game, synced to the Steam account and shared
|
||||
through Steam's community configs?
|
||||
|
||||
This page records what the Steam client contains, the candidate approaches, and the experiments
|
||||
run on the Thor to decide between them; details are in the [Experiment log](#experiment-log).
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Yes, and without streaming anything.** Steam's touch controller is a Steam Input controller type
|
||||
(Mobile Touch, `controller_mobile_touch`) that Steam builds for any HID device with the ids
|
||||
0000:11fb. Steam Link only supplies that device remotely; libfakeinput can supply it locally, the
|
||||
way it already supplies the Deck pad. Shown on the Thor with an experimental build:
|
||||
|
||||
- Steam opens a local `/dev/hidraw17` 0000:11fb, builds the touch controller (type 43, the same
|
||||
attributes as Steam Link's), loads the game's touch config for it (Valve's touch templates,
|
||||
official per-game touch configs, the user's own synced ones, community ones), and games get
|
||||
its input through Steam Input's virtual pad. A touch-controller stick report flew the ship in
|
||||
Geometry Wars.
|
||||
- Steam tells the device which app and action set are active (output report 4) and when action
|
||||
set layers come and go (5, 6), live, plus rumble (1). That is what decides which layout to show.
|
||||
- Big Picture treats it as a touch controller: the in-game menu shows the touch layout preview,
|
||||
bindings are edited in Steam's own configurator.
|
||||
- It can be plugged and unplugged mid-session (injected udev events), so it can step aside when
|
||||
a physical pad is used, as Steam Link does.
|
||||
- The layout is part of the config file (`touch_layout`, a `CVirtualControllerLayouts` protobuf),
|
||||
so it syncs and is shared with the config. Steam loads a touch config written to disk.
|
||||
|
||||
**Recommended: approach D below.** DroidDeck draws the controls (from Steam's layout) and sends
|
||||
the device's 40-byte report; Steam does everything else. It needs no stream, no pairing, no video
|
||||
and no patching of Steam, and the parts it depends on are the ones Valve has to keep stable for
|
||||
the Steam Link apps already installed on phones and TVs: the device's ids and report format.
|
||||
|
||||
Not settled yet: saving an edited layout goes through a file write rather than a Steam call (Steam
|
||||
loads the written file and queues it for Steam Cloud like its own autosaves); the overlay must
|
||||
reproduce Steam Link's default layout and its hiding of unbound controls; touch menus and gyro
|
||||
were not exercised.
|
||||
|
||||
## How Steam Link's touch controller works
|
||||
|
||||
Sources: the arm64 client DroidDeck installs (`steam_client_steamdeck_publicbeta_linuxarm64`,
|
||||
manifest version 1791249696), the protobuf definitions in
|
||||
[SteamDatabase/Protobufs](https://github.com/SteamDatabase/Protobufs)
|
||||
(`steam/steammessages_remoteplay.proto`, `steam/steammessages_virtualcontroller.proto`) and Valve's
|
||||
[touch controller guide](https://steamcommunity.com/app/353380/discussions/4/1735462352489233412).
|
||||
|
||||
The work is split between the streaming client (what runs on the phone) and the Steam client being
|
||||
streamed from (the host):
|
||||
|
||||
- **Client: drawing and editing.** `streaming_client` holds `CVirtualController`, built from
|
||||
`streaming/client/virtualcontroller.cpp`: it draws the controls over the video, runs the layout
|
||||
editor (tray, colour picker, move/scale), and sends raw touches to the host as
|
||||
`CInputTouchFingerDownMsg` / `Motion` / `Up` (finger id, x and y normalised to the video).
|
||||
- **Host: the controller.** `steamui.so` holds the stream server (`CStreamServer`,
|
||||
`IStreamServerInputDelegate`). `steamclient.so` holds `CMobileTouchControllerAbstraction`, a
|
||||
Steam Input controller backend next to `CSteamOSHandheldAbstraction` (the Deck),
|
||||
`CSteamControllerAbstraction` and `CGenericGamepadControllerAbstraction`. Its display name is
|
||||
"Steam Link Touch Controller" and its config type is `controller_mobile_touch`.
|
||||
- **Host: the config.** The host owns the touch config and pushes it to the client:
|
||||
`k_EStreamControlTouchConfigActive` (appid, revision, creator),
|
||||
`GetTouchConfigData`/`SetTouchConfigData` (config data and layout blobs),
|
||||
`SaveTouchConfigLayout` (the client sends back an edited layout), `Get`/`SetTouchIconData`,
|
||||
`TouchActionSetActive` and `TouchActionSetLayerAdded`/`Removed`. Touch configs are Steam Input
|
||||
configs like any other: per game, saved to the account, publishable. SteamInputDB counted
|
||||
10,540 community configs for Mobile Touch in March 2026.
|
||||
|
||||
Every piece of this is in the arm64 package DroidDeck already installs: the host side in
|
||||
`steamrtarm64/steamclient.so` and `steamui.so`, and the full renderer and editor in
|
||||
`steamrtarm64/streaming_client`. The package also carries an `androidarm64/libsteamclient.so`
|
||||
with the same touch strings.
|
||||
|
||||
## Where DroidDeck is today
|
||||
|
||||
The session's pad is presented to the Steam client as a Steam Deck controller (28de:1205 through a
|
||||
served `hidraw` node and a written sysfs, see `SteamDeckPad.kt` and `fakeinput_steam.cpp`), so Deck
|
||||
configs, per-game bindings and Deck community configs already apply. What Steam's touch controller
|
||||
would add: control placement, icons, touch menus and on-device layout editing defined by Steam, and
|
||||
the touch-specific community configs.
|
||||
|
||||
## Candidate approaches
|
||||
|
||||
| | How | Verdict |
|
||||
|---|---|---|
|
||||
| **A. Local Remote Play** | DroidDeck streams from its own Steam client the way the Steam Link app does (pairing, start stream, touch input negotiated). | **Works, not worth it.** The official Steam Link app on the Thor pairs with the session's Steam over loopback and its touch controls drive the session (experiment 2). But the host captures and software-encodes video (libx264, 72-85 % CPU, frames froze), the Steam Link app cannot turn video off, and an input-only client of our own means reimplementing the Remote Play transport and crypto. The protocol does have `enable_video_streaming` in the streaming request. Kept as a fallback only. |
|
||||
| **B. In-process injection** | Feed touches into the path between the stream server (`steamui.so`) and `CMobileTouchControllerAbstraction` (`steamclient.so`). | **Not needed.** That path is a HID device; D supplies the device instead of patching Steam. |
|
||||
| **C. Data only** | Read Steam's touch configs and layouts, draw them ourselves, keep feeding the Deck pad. | **Rejected.** Touch configs bind straight to keys, mouse and actions; honouring them would mean reimplementing Steam Input. (D still reads the layout from the config, but Steam does the bindings.) |
|
||||
| **D. Local Mobile Touch device** | libfakeinput serves 0000:11fb as a local hidraw node; DroidDeck draws Steam's layout and writes the 40-byte report. | **Works (experiments 3, 4). Recommended.** |
|
||||
|
||||
## Experiments
|
||||
|
||||
1. **Host side alive?** Yes: discovery answers on loopback and Steam Link pairs.
|
||||
2. **Touch controller over a stream?** Yes, end to end, with Steam Link on the same device.
|
||||
3. **Loopback cost.** Too high: software video encode (part of 2).
|
||||
4. **Input without video.** Not run: needs our own Remote Play client; superseded by D.
|
||||
5. **Valve's renderer as an overlay.** Not run: `streaming_client` draws over decoded video and
|
||||
lives behind the stream; superseded by D.
|
||||
6. **Injection points.** The touch controller enters Steam as a HID device (0000:11fb), which is
|
||||
approach D.
|
||||
7. **Local 0000:11fb device.** Works: controller built, config loaded, games driven (section 3).
|
||||
8. **Hotplug.** Works with injected udev events (section 4).
|
||||
9. **Layout written to disk.** Steam loads it (section 5).
|
||||
|
||||
## Experiment log
|
||||
|
||||
### 1. Host side alive: yes (2026-10-07, Thor, Steam 1791249696)
|
||||
|
||||
- With a Steam session READY, the client listens on TCP 27036 and UDP 27036 (`/proc/net`, app
|
||||
uid). A discovery probe built from `steammessages_remoteclient_discovery.proto` sent to
|
||||
`127.0.0.1:27036` from the device gets a `CMsgRemoteClientBroadcastStatus` back: hostname
|
||||
`DroidDeck`, `enabled_services` 98 (game streaming on), the signed-in user, `steam_deck`
|
||||
unset, `gaming_device_type` 541.
|
||||
- The official Steam Link app (Play Store, on the Thor's bottom screen while the session runs on
|
||||
the top one) finds "DroidDeck" by itself, shows a PIN, and the session's Big Picture shows
|
||||
**Authorize Device**. After the PIN the app runs its network test and lists DroidDeck as
|
||||
paired. Pairing to the session's own client over loopback works with no changes.
|
||||
- Launching Steam Link on display 0 instead puts DroidDeck's session activity into PiP; use
|
||||
`am start --display 4`. On first start Steam Link sits on its splash until BLUETOOTH_CONNECT
|
||||
is answered, and the permission dialog does not render on the second screen (granted with
|
||||
`pm grant`).
|
||||
|
||||
### 2. Touch controller over the stream: works end to end
|
||||
|
||||
Starting a stream (`streaming_log.txt`, `console_log.txt` in the session's Steam `logs/`):
|
||||
|
||||
```
|
||||
CLIENT: Sending HID device 2020/0112/-1 Xbox Wireless Controller at sdl://3
|
||||
CLIENT: Sending HID device 0000/11fb/-1 Mobile Touch Control at touch://0
|
||||
...
|
||||
Remote Device Found type: 0000 11fb path: touch://0 Product: Mobile Touch Control
|
||||
!! Steam controller device opened for index 1.
|
||||
Controller device closed after hid_read failure
|
||||
Controller 1 disconnected
|
||||
```
|
||||
|
||||
- **The touch controller reaches the host as a remote HID device, 0000:11fb**, opened by the
|
||||
same controller code that opens the Deck pad, and turned into a virtual controller. The
|
||||
streaming client is what makes the reports; Steam on the host only sees a HID device.
|
||||
- Video fails on the host: `PipeWire: Could not connect PipeWire context` (the session runs no
|
||||
PipeWire), so the app gets audio but a black screen and keeps its loading spinner.
|
||||
- The app then re-sends only the Thor's built-in pad and drops the touch device; it seems to
|
||||
withdraw touch controls when a physical controller is present, or until video arrives.
|
||||
|
||||
With video fixed (below), **Steam's touch controller runs against the session's own client**:
|
||||
|
||||
- Steam Link withholds its touch controls while a physical pad is attached (the Thor's own pad
|
||||
shows up as "Xbox Wireless Controller"). A four-finger tap opens its stream menu, which has
|
||||
**Enable Touch Controls** (injected with `sendevent` on `/dev/input/event5`, the bottom panel).
|
||||
- The client then re-sends `touch://0`; the host opens it as controller type 43, product 0x11fb,
|
||||
serial `MT-<Steam Link device id>`, capabilities `0000007f83045bff`, creates a virtual controller
|
||||
for it, and sends the client `TouchConfigActive`, `TouchActionSetActive` and
|
||||
`SetTouchConfigData`. The app draws Big Picture's touch layout (d-pad, ABXY, Steam button,
|
||||
keyboard, `...`).
|
||||
- Touching the controls drives the session: the touch Steam button opens Big Picture's main
|
||||
menu on the Thor's top screen, touch B closes it, and the session's glyphs switch to the touch
|
||||
controller's. (D-pad taps from `input tap` did not register; not chased.)
|
||||
- Cost: the host encodes with libx264 in software (`/dev/video-enc0` missing), 4 threads,
|
||||
1240x698. CPU sat at 72-85 % in the overlay and `k_EStreamControlVideoOverflow` flooded the log;
|
||||
the app's picture froze within a minute while input kept working. **Video loopback is not a
|
||||
shippable path**; only the input half of the stream is useful.
|
||||
|
||||
Video fix used for this test only: PipeWire and WirePlumber are in the rootfs and gamescope is
|
||||
built with PipeWire, so a `gamescope` wrapper on `PATH` (via `Download/droiddeck-env`) that starts
|
||||
`pipewire` and `wireplumber` first gives gamescope a capture node (`stream available on node ID`)
|
||||
and the client a picture. (Editing `droiddeck-session` on the device does not stick: the app
|
||||
re-syncs the overlay each session.)
|
||||
|
||||
### Where Steam keeps touch configs
|
||||
|
||||
In the session's Steam, `steamapps/common/Steam Controller Configs/<account id>/config/`:
|
||||
|
||||
- `<appid>/controller_mobile_touch.vdf` for each game with a saved touch config (dozens already
|
||||
there for this account, synced down from Steam Cloud), plus named copies like
|
||||
`<appid>/default touch_0.vdf`;
|
||||
- `configset_controller_mobile_touch.vdf`: which config each app uses (`autosave` = the per-app
|
||||
file above, otherwise a template or workshop id);
|
||||
- `configset_MT-<serial>.vdf`, `preferences_MT-<serial>.vdf` and `config/MT-<serial>_gyro.vdf`:
|
||||
per touch device.
|
||||
|
||||
A touch config is an ordinary `controller_mappings` VDF (`controller_type` `controller_mobile_touch`,
|
||||
bindings per action set and layer) with one extra key, **`touch_layout`: the on-screen layout as
|
||||
a hex-encoded `CVirtualControllerLayouts` protobuf**: one layout per action set, each a list of
|
||||
elements (`EControllerElementType`, visible, x/y position normalised to the screen, x/y scale)
|
||||
and a colour, plus input mode, mouse mode and pinch-zoom settings. Bindings and layout travel
|
||||
together in the one file that syncs and is shared as a community config.
|
||||
|
||||
### The touch device's HID protocol
|
||||
|
||||
From Steam Link for Android (`libmain.so`, exported symbols; `CVirtualController` is both the
|
||||
renderer and the HID device behind `touch://0`):
|
||||
|
||||
- **Input report: 40 bytes** (`CVirtualController::Read`), kept at `this+200` and marked dirty
|
||||
at `this+240` by every setter. Bytes 0-7: button/touch bits (e.g. bit 27, 19, 20: trackpads
|
||||
0, 1, 2 touched). Bytes 16, 20, 24: trackpads 0-2 as int16 x (centred, `x*65535 ^ 0x8000`) and
|
||||
y (inverted). Remaining fields (sticks, triggers, gyro) not yet mapped.
|
||||
`CHIDDeviceReportGenerator::BInjectGamepadStateMobileTouch` is a stub; the report is the
|
||||
controller's own format, not the generic SDL gamepad state.
|
||||
- **Output reports from Steam** (`Write` → `QueueFeatureReport` → `HandleFeatureReports`),
|
||||
at least 17 bytes, first byte the type:
|
||||
- 1: rumble (u16 low, u16 high, u32 duration);
|
||||
- 3: setting `0x30` with a u16 value (a flag at `this+1488`, likely gyro);
|
||||
- **4: action set change** (u32 appid, u32 action set id): switches the layout shown;
|
||||
- **5 / 6: action set layer added / removed** (u32 appid, u32 layer id).
|
||||
- **Feature report 2**: battery (level, from `SDL_GetPowerInfo`), 17 bytes.
|
||||
|
||||
So Steam drives action sets, layers and rumble over HID. The only things that come solely over
|
||||
the stream are the config and layout blobs (`SetTouchConfigData`), the active config's
|
||||
appid/revision/creator (`TouchConfigActive`) and custom icons (`SetTouchIconData`); the first two
|
||||
are on disk in the VDF above.
|
||||
|
||||
### 3. Without a stream: Steam takes a local 0000:11fb device (works)
|
||||
|
||||
Experimental build on this branch: `FAKE_EVDEV_TOUCHCTL=1` (in `Download/droiddeck-env`) makes
|
||||
libfakeinput serve `/dev/hidraw17` as 0000:11fb "Mobile Touch Control" next to the Deck pad, with
|
||||
its sysfs written by `SteamDeckPad.prepare` (a second USB device, `usb2`). The 40-byte input report
|
||||
is read from `/tmp/touchctl.report` in the session; every output and feature report the client
|
||||
sends is logged to `pad.log` and appended to `/tmp/touchctl.out`.
|
||||
|
||||
- With a vendor-page report descriptor (`06 ff ff 09 01`, as the Deck's) the client walks the
|
||||
whole sysfs tree, reads the descriptor and never opens the node. **With Generic Desktop / Game
|
||||
Pad (`05 01 09 05`) it opens it**: for a non-Valve vendor id the client wants a gamepad usage.
|
||||
- Steam then builds the touch controller exactly as it does for Steam Link:
|
||||
|
||||
```
|
||||
Local Device Found type: 0000 11fb path: /dev/hidraw17 serial_number: MT-DROIDDECK0001
|
||||
!! Controller 0 attributes: Type: 43 ProductID: 4603 Serial: MT-MT-DROIDDECK0001
|
||||
Capabilities: 0000007f83045bff
|
||||
```
|
||||
|
||||
and speaks the touch protocol to it: `get-feature 02` (battery), output `03 30 ..` (the setting
|
||||
report), and **output `04 01 03 00 00 01 00 00 00`: action set 1 of app 769** (Big Picture).
|
||||
- Writing button bits into the report drives the client: Steam (`0x2000`) opens Big Picture's
|
||||
main menu, B (`0x20`) closes it. Opening the menu made Steam send `04` with app 443510 (the
|
||||
Steam menu's own touch config; it is one of the app folders in the config directory) and
|
||||
closing it `04` with 769 / 1 again: **the active app and action set arrive over HID, live.**
|
||||
|
||||
Button bits (Steam Link's `CVirtualController` table at `0x463e70`, indexed by
|
||||
`EControllerElementType - 1`), the same layout as the Deck report's low word:
|
||||
|
||||
| Element | Bit | | Element | Bit |
|
||||
|---|---|---|---|---|
|
||||
| A | 7 | | Select | 12 |
|
||||
| B | 5 | | Steam | 13 |
|
||||
| X | 6 | | Start | 14 |
|
||||
| Y | 4 | | Left stick click | 22 |
|
||||
| Left bumper | 3 | | Right stick click | 26 |
|
||||
| Right bumper | 2 | | Macro 0-7 | 32-39 |
|
||||
| Left trigger | 1 | | 1-finger / 2-finger macro | 48, 49 |
|
||||
| Right trigger | 0 | | Trackpad 0 / 1 / 2 touched | 27 / 19 / 20 |
|
||||
|
||||
Report layout, all little-endian (Steam Link `CVirtualController`: `TouchControl_JoyButton`,
|
||||
`TouchControl_JoyStick`, `SetTrackpad`, `UpdateControllerState`):
|
||||
|
||||
| Bytes | Field |
|
||||
|---|---|
|
||||
| 0-7 | u64 buttons and touch flags (table above; d-pad as the Deck's: up 8, right 9, left 10, down 11; left / right stick touched 46 / 47) |
|
||||
| 8-15 | int16 left X, left Y, right X, right Y: `x * 32767`, Y stored inverted (`~(y * 32767)`) |
|
||||
| 16-27 | three trackpads, int16 x (`x * 65535 ^ 0x8000`) and y (`0x7fff - y * 65535`) |
|
||||
| 28-33 | accelerometer xyz, int16, ±2 g (19.61 m/s²) to ±32767 |
|
||||
| 34-39 | gyro xyz, int16 |
|
||||
|
||||
**Games, through Steam Input, with no stream.** With the session started on Geometry Wars:
|
||||
Retro Evolved (8400) and the game launched by pressing A twice on the local device, the client
|
||||
loaded `controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf` for app 8400 on the
|
||||
touch controller (Valve's stock touch template), told the device `04 d0 20 00 00 ...` (app 8400),
|
||||
and a left-stick report flew the ship across the screen. Steam's own path end to end:
|
||||
DroidDeck → `/dev/hidraw17` → Mobile Touch controller → the game's touch config → Steam Input's
|
||||
virtual Xbox pad → the game. D-pad bits navigated the Steam menu the same way.
|
||||
|
||||
What the stream gives that the local device does not:
|
||||
|
||||
- **The active config and its layout.** Steam Link asks for it (`GetTouchConfigData`) and the host
|
||||
answers with the config and the layout. Locally the device is told only the app id and action
|
||||
set (report 4), so the overlay has to resolve the config itself: `configset_controller_mobile_touch.vdf`
|
||||
per app (`autosave` = `<appid>/controller_mobile_touch.vdf`, otherwise a template or workshop
|
||||
config), and the console log line `Loaded Config for ... App ID <n>, Controller <i>: <path>`
|
||||
names the file Steam actually loaded. Stock templates carry no `touch_layout`; Steam Link draws
|
||||
its built-in default layout for them (`CVirtualController::ResetLayoutToDefault`), hiding
|
||||
elements the config leaves unbound (`UpdateElementAvailability`).
|
||||
- **Saving an edited layout.** Steam Link sends `SaveTouchConfigLayout`; the host writes it into
|
||||
the app's autosave config, which is what syncs. The local device has no such message. Big
|
||||
Picture's `SteamClient.Input` API (seen in `steamui/sp.js`) has the config editing calls
|
||||
(`StartEditingControllerConfigurationForAppIDAndControllerIndex`,
|
||||
`SetEditingControllerConfigurationMiscSetting`, `SaveEditingControllerConfiguration`,
|
||||
`GetConfigForAppAndController`, `QueryControllerConfigsForApp`, `SetSelectedConfigForApp`,
|
||||
`GetTouchMenuIconsForApp`, `RegisterForTouchMenuMessages`) but no layout call; whether
|
||||
`touch_layout` goes through the misc-setting call is untested. The session's devtools port only
|
||||
admits guest processes (`BL_CDP_GUARD`), so this needs the agent bridge's guest commands rather
|
||||
than a probe from adb.
|
||||
- **Custom icons** (`SetTouchIconData`).
|
||||
|
||||
Everything else (bindings, action sets, layers, touch menus, rumble, the configurator, community
|
||||
configs, cloud sync) is Steam's own and works with the local device.
|
||||
|
||||
### 4. Two controllers, and hotplug
|
||||
|
||||
With the touch device present beside the Deck pad, Steam creates a virtual pad for each
|
||||
(`Created virtual controller at slot 0 for controller 0` / `slot 1 for controller 1`): a game
|
||||
sees two players. Steam Link avoids this by withdrawing its touch controls while a physical pad is
|
||||
in use, and a local device has to do the same, which needs hotplug.
|
||||
|
||||
- **Unplug works as is.** Ending the device's report stream (`/tmp/touchctl.off` in the experiment)
|
||||
gives `Controller device closed after hid_read failure`, `Controller 0 disconnected`, and the
|
||||
virtual pad is destroyed. Steam re-enumerates at once, and an open refused then keeps it off.
|
||||
- **Plugging back in needs an event.** The client watches only `/dev/input` with inotify (already
|
||||
redirected by libfakeinput) and never rescans hidraw on its own; an inotify event in
|
||||
`/dev/input` did not trigger a HID rescan. Its HID discovery is SDL's, driven by a udev monitor,
|
||||
and the session's monitor is `udevmon.c`'s silent stand-in (the sandbox refuses netlink).
|
||||
- **Injected udev events work.** The experiment extends the stand-in: a file in `/tmp/udev-inject`
|
||||
(`ACTION`, `DEVPATH`, `SUBSYSTEM=hidraw`, `DEVNAME`, `MAJOR`, `MINOR`) is sent by every process
|
||||
with a stand-in as udevd's message format (`libudev` header, magic `0xfeedcafe`, properties plus
|
||||
`SEQNUM`), and the stand-in's `recvmsg` (through `ntsync.c`, which owns the symbol) reports the
|
||||
udev multicast group as the sender and root credentials, as libudev requires. An injected `add`
|
||||
for `hidraw17` made the client open the node again within a second and rebuild the touch
|
||||
controller (type 43) and its virtual pad, mid-session. (The sender address has to be written
|
||||
from the room `msg_namelen` had before the call; a unix socket sets it to 0.)
|
||||
|
||||
So DroidDeck can show and hide Steam's touch controller the way Steam Link does: plug the device
|
||||
in when the on-screen controls are shown, unplug it when a physical pad takes over.
|
||||
|
||||
### 5. Configs and layouts written by DroidDeck
|
||||
|
||||
With the session stopped, a `config/8400/controller_mobile_touch.vdf` was written (Valve's gamepad
|
||||
template plus a `touch_layout` taken from another config) and `"8400" { "autosave" "1" }` added to
|
||||
`configset_controller_mobile_touch.vdf`. Launching the game:
|
||||
|
||||
```
|
||||
Loaded Config for Local Selection Path for App ID 8400, Controller 0:
|
||||
.../Steam Controller Configs/392297941/config/8400/controller_mobile_touch.vdf
|
||||
```
|
||||
|
||||
Steam used it as the game's touch config and left the file (and its `touch_layout`) as written.
|
||||
**Steam Cloud picked it up too**: Steam Controller Configs sync as app 241100's cloud files, and
|
||||
`userdata/<account>/241100/remotecache.vdf` gained an entry for
|
||||
`392297941/config/8400/controller_mobile_touch.vdf` (`syncstate` 3, `remotetime` 0: queued for
|
||||
upload), the same bookkeeping as the account's other touch configs. Both files were put back
|
||||
afterwards.
|
||||
|
||||
Also seen: pressing A on the touch device logged `Seating controller 1 in slot 0`. Steam moves the
|
||||
controller in use to the first player slot, which softens the two-controller problem even before
|
||||
the device is unplugged.
|
||||
|
||||
### 6. Steam Link's default layout
|
||||
|
||||
Stock templates carry no `touch_layout`. Steam Link then places each control from a built-in table
|
||||
(`CVirtualController::BInitializeDefaultElement`, table at `0x463d48`, positions on a 1280x720
|
||||
reference):
|
||||
|
||||
| Element | Position (of 1280x720) |
|
||||
|---|---|
|
||||
| Thumb (menu) | 75, 75 |
|
||||
| Select / Steam / Start | 516, 75 / 636, 75 / 756, 75 |
|
||||
| Paste / Keyboard | 1115, 75 / 1205, 75 |
|
||||
| Magnifying glass / record / playback | 1205, 165 / 255 / 345 |
|
||||
| D-pad | 200, 525 |
|
||||
| X / Y / B / A | 1003, 527 / 1083, 447 / 1163, 527 / 1083, 607 |
|
||||
|
||||
Sticks, triggers, bumpers and macros are not in the table; they appear when the config binds them
|
||||
(`UpdateElementAvailability`), and the overlay has to reproduce that rule.
|
||||
|
||||
## What building D would take
|
||||
|
||||
- **libfakeinput:** the 0000:11fb node as in the experiment (Generic Desktop / Game Pad report
|
||||
descriptor, 40-byte input reports, output reports 1, 3-6 and feature report 2), fed from a
|
||||
ring the app writes, like the Deck pad's.
|
||||
- **SteamDeckPad.kt:** the second device's sysfs, as in the experiment.
|
||||
- **udevmon.c:** event injection for plug and unplug, with a proper channel from the app instead of
|
||||
the experiment's file drop.
|
||||
- **The overlay (Kotlin):** read the active config (configset per app, else the template Steam
|
||||
logged loading), decode `touch_layout` (or the default table above), show the elements of the
|
||||
action set Steam names in report 4 and the layers in 5/6, hit-test touches into the report
|
||||
(buttons, sticks, trackpads; motion from PadMotion into the gyro fields), rumble from report 1.
|
||||
Hide the unbound controls as Steam Link does.
|
||||
- **Editing:** move/scale in the overlay, saved by writing `touch_layout` into the app's autosave
|
||||
config, as Steam does when Steam Link saves a layout.
|
||||
- **Policy:** plug the device in while the on-screen controls are up and no physical pad is in use;
|
||||
unplug it otherwise.
|
||||
|
||||
The experimental pieces on this branch (`FAKE_EVDEV_TOUCHCTL`, `/tmp/touchctl.*`,
|
||||
`/tmp/udev-inject`) are research scaffolding to be replaced, not shipped.
|
||||
|
||||
### 7. Several controllers
|
||||
|
||||
`FAKE_EVDEV_TOUCHCTL=2` serves two touch devices (`/dev/hidraw17` and `18`, serials
|
||||
`MT-DROIDDECK0001` and `0002`, each with its own sysfs `usb2`/`usb3` and report file). With the
|
||||
Deck pad that is three controllers, and Steam takes all three:
|
||||
|
||||
```
|
||||
Controller 15 Type 4 (Deck pad, /dev/hidraw16)
|
||||
Controller 0 Type 43 Serial MT-MT-DROIDDECK0001
|
||||
Controller 1 Type 43 Serial MT-MT-DROIDDECK0002
|
||||
Created virtual controller at slot 0 / 1 / 2
|
||||
uinput: "Microsoft X-Box 360 pad 0" / "pad 1" / "pad 2" is /dev/input/event16 / 17 / 18
|
||||
```
|
||||
|
||||
Each gets its own Steam Input config and its own virtual pad, so a game sees three players; a
|
||||
Steam press on the second device alone opened the Steam menu (Steam answered it with report 4 for
|
||||
the menu's action set). Several touch controllers would allow, say, a second player on the Thor's
|
||||
bottom screen. The other physical pads DroidDeck passes through (ring slots 1-3 as evdev pads) sit
|
||||
beside these as ordinary gamepads; no external pad was connected for this test.
|
||||
@@ -38,6 +38,12 @@ library list, before anything is published.
|
||||
iconify request is remembered and the window goes back to NormalState before input returns to it,
|
||||
then focus is handed over again. `GAMESCOPE_RESTORE_FOCUS_WINDOW` on the root window asks for the
|
||||
same restore from outside (the session script's resume watcher).
|
||||
- `0114-take-override-redirect-from-mapnotify.patch` - upstream (ValveSoftware/gamescope 3829340),
|
||||
verbatim; drop it once the runtime's gamescope includes it. Wine creates a game's window
|
||||
override-redirect and makes it managed before mapping it, which sends no X event. When gamescope
|
||||
read the window's attributes first, it kept the window as override-redirect and left it out of
|
||||
`GAMESCOPE_FOCUSABLE_WINDOWS`, so the client kept its loading screen over the running game
|
||||
(Skyrim SE, A Plague Tale: Innocence, on the games' own Xwayland). MapNotify now refreshes the flag.
|
||||
|
||||
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
|
||||
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
|
||||
@@ -0,0 +1,35 @@
|
||||
From: Matthew Schwartz <[email protected]>
|
||||
Subject: [PATCH] steamcompmgr: take override-redirect from MapNotify
|
||||
|
||||
Wine creates a toplevel as override-redirect and flips it to managed with
|
||||
XChangeWindowAttributes while it is unmapped, which generates no X event.
|
||||
When add_win reads the attributes before the flip, the window stays cached
|
||||
as override-redirect and is left out of GAMESCOPE_FOCUSABLE_APPS. Steam
|
||||
then keeps its loading screen over the game, as with A Plague Tale:
|
||||
Innocence (752590). Before 2238420d, the focus nudge's move usually
|
||||
refreshed the flag.
|
||||
|
||||
MapNotify carries the current override-redirect state, so refresh the
|
||||
cached flag there rather than re-reading the attributes.
|
||||
|
||||
Closes: #2457
|
||||
---
|
||||
src/steamcompmgr.cpp | 4 ++++
|
||||
1 file changed, 4 insertions(+)
|
||||
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index f00b62c..cb3b9c6 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -8778,7 +8778,11 @@ void xwayland_ctx_t::Dispatch()
|
||||
steamcompmgr_win_t * w = find_win(ctx, ev.xmap.window);
|
||||
|
||||
if (w && w->xwayland().id == ev.xmap.window)
|
||||
+ {
|
||||
+ // Wine flips override-redirect while unmapped and the flip itself sends no event.
|
||||
+ w->xwayland().a.override_redirect = ev.xmap.override_redirect;
|
||||
map_win(ctx, ev.xmap.window, ev.xmap.serial);
|
||||
+ }
|
||||
break;
|
||||
}
|
||||
case UnmapNotify:
|
||||
@@ -1,3 +1,3 @@
|
||||
GAMESCOPE_REPO=Droid-Deck/DroidDeck-Components
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p8
|
||||
GAMESCOPE_SHA256=65fcc17af1aee4944a42ce1e688db61ceb10db5f299f0de75e338f56646e2953
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p9
|
||||
GAMESCOPE_SHA256=7123a37bdbcd0d76d4bd1d7490e5d2017221e036395fdd48c1e92458594ca769
|
||||
@@ -73,11 +73,19 @@ def client_entries():
|
||||
return found
|
||||
|
||||
|
||||
def registered():
|
||||
try:
|
||||
with open(os.path.join(STEAM_ROOT, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
|
||||
return [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def write_entries():
|
||||
os.makedirs(APPS_DIR, exist_ok=True)
|
||||
wanted = {}
|
||||
theirs = client_entries()
|
||||
for library, _ in LIBRARIES:
|
||||
for library in dict.fromkeys([library for library, _ in LIBRARIES] + registered()):
|
||||
for appid, name in games(library):
|
||||
if appid not in theirs:
|
||||
wanted.setdefault(appid, name)
|
||||
|
||||
@@ -954,8 +954,19 @@ def choose(root, command, env, tool, stock):
|
||||
return command, env
|
||||
|
||||
|
||||
def libraries(steam_root):
|
||||
found = [steam_root]
|
||||
try:
|
||||
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
|
||||
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
|
||||
except OSError:
|
||||
pass
|
||||
found.append(SD_LIBRARY)
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def find_tools(steam_root):
|
||||
candidates = [os.path.join(library, "steamapps", "common", name) for library in (steam_root, SD_LIBRARY) for name in SOURCES]
|
||||
candidates = [os.path.join(library, "steamapps", "common", name) for library in libraries(steam_root) for name in SOURCES]
|
||||
folder = os.path.join(steam_root, "compatibilitytools.d")
|
||||
try:
|
||||
names = sorted(os.listdir(folder))
|
||||
|
||||
@@ -72,6 +72,7 @@ cache="$steam_root/.droiddeck-game-scripts"
|
||||
libraries="$steam_root"
|
||||
for library in $LIBRARIES; do libraries="$libraries
|
||||
$library"; done
|
||||
libraries=$(printf '%s\n%s\n' "$libraries" "$(sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)" | awk 'NF && !seen[$0]++')
|
||||
sig=$(while IFS= read -r library; do ls -l --time-style=+%s "$library"/steamapps/appmanifest_*.acf 2>/dev/null; done <<<"$libraries" | cksum)
|
||||
if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then
|
||||
{
|
||||
@@ -133,9 +134,12 @@ $(vcrt "$(date +%s)")"
|
||||
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
|
||||
# changed one as a new template and upgrades every prefix made from the old.
|
||||
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
/mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
/root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
|
||||
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
|
||||
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
|
||||
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
|
||||
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg \
|
||||
/root/Games/*/steamapps/compatdata/*/pfx/system.reg; do
|
||||
[ -f "$reg" ] || continue
|
||||
seeded=$(dirname "$reg")/.droiddeck-redists
|
||||
legacy=$(dirname "$reg")/.bannerlator-redists
|
||||
|
||||
@@ -568,7 +568,7 @@ case $mode in
|
||||
local d
|
||||
[ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0
|
||||
for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \
|
||||
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do
|
||||
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps /root/Games/*/steamapps; do
|
||||
[ "$d" -nt "$compat_stamp" ] && return 0
|
||||
done
|
||||
return 1
|
||||
@@ -839,13 +839,13 @@ BUSCONF
|
||||
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
|
||||
rc=$?
|
||||
echo "== steam exited rc=$rc"
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
|
||||
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
collect_steam_logs
|
||||
if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then
|
||||
/usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
|
||||
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
if [ -f "$HOME/.bl-stop-requested" ]; then
|
||||
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
|
||||
break
|
||||
|
||||
@@ -16,6 +16,7 @@ STATE = ".droiddeck-games.json"
|
||||
WINNATIVE = ".winnative-steam/steamapps"
|
||||
LIBRARY = "/mnt/droiddeck-sd"
|
||||
LEGACY_LIBRARY = "/mnt/bannerlator-sd"
|
||||
PARKED = ".droiddeck-parked"
|
||||
BASE_ID = 76561197960265728
|
||||
|
||||
|
||||
@@ -282,6 +283,42 @@ def update_not_started(text, steamapps, appid, installdir):
|
||||
and not (steamapps / "downloading" / str(appid)).exists())
|
||||
|
||||
|
||||
def libraries(steam_root):
|
||||
try:
|
||||
text = (Path(steam_root) / "steamapps/libraryfolders.vdf").read_text(errors="replace")
|
||||
except OSError:
|
||||
text = ""
|
||||
paths = [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)]
|
||||
return list(dict.fromkeys([Path(steam_root), Path(LIBRARY)] + [Path(path) for path in paths]))
|
||||
|
||||
|
||||
def ours(manifest, appid):
|
||||
try:
|
||||
return re.search(rf'"installdir"\s*"DroidDeck-{appid}"', manifest.read_text(errors="replace")) is not None
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def unpark(steamapps):
|
||||
for manifest in sorted((steamapps / PARKED).glob("appmanifest_*.acf")):
|
||||
appid = manifest.stem.removeprefix("appmanifest_")
|
||||
live = steamapps / manifest.name
|
||||
if os.path.lexists(live):
|
||||
manifest.unlink(missing_ok=True)
|
||||
elif (steamapps / "common" / f"DroidDeck-{appid}").is_dir():
|
||||
os.replace(manifest, live)
|
||||
|
||||
|
||||
def park(steamapps):
|
||||
for target in sorted((steamapps / "common").glob("DroidDeck-*")):
|
||||
appid = target.name.removeprefix("DroidDeck-")
|
||||
manifest = steamapps / f"appmanifest_{appid}.acf"
|
||||
if appid.isdigit() and target.is_symlink() and not target.exists() and manifest.is_file() and ours(manifest, appid):
|
||||
(steamapps / PARKED).mkdir(exist_ok=True)
|
||||
os.replace(manifest, steamapps / PARKED / manifest.name)
|
||||
print(f"droiddeck-steam-games: {appid} is not here this session; its install is set aside until it is")
|
||||
|
||||
|
||||
def seed_install(steam_root, game, appid):
|
||||
steamapps = Path(steam_root) / "steamapps"
|
||||
common = steamapps / "common"
|
||||
@@ -305,7 +342,7 @@ def seed_install(steam_root, game, appid):
|
||||
relink.unlink(missing_ok=True)
|
||||
relink.symlink_to(source, target_is_directory=True)
|
||||
os.replace(relink, target)
|
||||
for root in [Path(steam_root), Path(LIBRARY)]:
|
||||
for root in libraries(steam_root):
|
||||
existing = root / "steamapps" / manifest.name
|
||||
if existing.is_file():
|
||||
if (existing == manifest and source.is_dir() and target.is_symlink()
|
||||
@@ -343,6 +380,11 @@ def route(steam_root, acct, games):
|
||||
if not all(isinstance(value, dict) for value in (owned, candidates, sources)):
|
||||
owned, candidates, sources = {}, {}, {}
|
||||
shortcuts, routes = [], {}
|
||||
steamapps = Path(steam_root) / "steamapps"
|
||||
try:
|
||||
unpark(steamapps)
|
||||
except OSError as error:
|
||||
print(f"droiddeck-steam-games: set-aside installs not restored ({error})")
|
||||
for game in games:
|
||||
folder = game.get("folder", "")
|
||||
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
|
||||
@@ -359,6 +401,10 @@ def route(steam_root, acct, games):
|
||||
except OSError as error:
|
||||
print(f"droiddeck-steam-games: import unavailable ({error})")
|
||||
shortcuts.append(game)
|
||||
try:
|
||||
park(steamapps)
|
||||
except OSError as error:
|
||||
print(f"droiddeck-steam-games: missing installs not set aside ({error})")
|
||||
save_json(Path(acct) / "config/.droiddeck-routes.json", routes)
|
||||
return shortcuts, routes
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# droiddeck-steam-library <steam root>
|
||||
#
|
||||
# Ported from WinNative's gamescope runtime (GPL-3.0).
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -168,6 +169,29 @@ def relabel(steam_root, target, label):
|
||||
print("droiddeck-steam-library: labelled %s in its own marker too" % target)
|
||||
|
||||
|
||||
GAMES_DIR = "/root/Games/"
|
||||
|
||||
|
||||
def wanted():
|
||||
listing = os.environ.get("BL_STEAM_LIBRARIES")
|
||||
if not listing:
|
||||
return None
|
||||
try:
|
||||
with open(listing, encoding="utf-8") as f:
|
||||
entries = json.load(f)
|
||||
return [(str(e["path"]), str(e["label"]).replace('"', " ").replace("\\", " ")) for e in entries]
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def registered(steam_root):
|
||||
try:
|
||||
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
|
||||
return re.findall(r'"path"\s+"([^"]*)"', f.read())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
steam_root = sys.argv[1]
|
||||
for library in RETIRED:
|
||||
@@ -175,12 +199,19 @@ def main():
|
||||
for old, new in RENAMED:
|
||||
rename(steam_root, old, new)
|
||||
relabel(steam_root, steam_root, MAIN_LABEL)
|
||||
for library, label in LIBRARIES:
|
||||
libraries = wanted()
|
||||
managed = libraries is not None
|
||||
for library, label in libraries if managed else LIBRARIES:
|
||||
if os.path.isdir(os.path.join(library, "steamapps")):
|
||||
register(steam_root, library, label)
|
||||
relabel(steam_root, library, label)
|
||||
else:
|
||||
forget(steam_root, library)
|
||||
if managed:
|
||||
keep = {library for library, _ in libraries}
|
||||
for library in registered(steam_root):
|
||||
if library not in keep and (library == LIBRARIES[0][0] or library.startswith(GAMES_DIR)):
|
||||
forget(steam_root, library)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -120,6 +120,114 @@ def shortcut(game, launch_options=""):
|
||||
}
|
||||
|
||||
|
||||
RECORD = ".droiddeck-shortcuts.json"
|
||||
AWAY_LIMIT = 256
|
||||
FIELDS = ("AppName", "Exe", "StartDir", "icon")
|
||||
|
||||
|
||||
def unquoted(value):
|
||||
return value.strip().strip('"') if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def read_record(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
record = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
record = {}
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
games, away = record.get("games"), record.get("away")
|
||||
return {
|
||||
"games": {k: v for k, v in games.items() if isinstance(v, dict)} if isinstance(games, dict) else {},
|
||||
"away": {k: v for k, v in away.items() if isinstance(v, dict) and isinstance(v.get("entry"), dict)} if isinstance(away, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def merge(fresh, current, state, seen):
|
||||
state = state if isinstance(state, dict) else {}
|
||||
rev = state.get("rev") if isinstance(state.get("rev"), int) else 0
|
||||
if current is None:
|
||||
return fresh, rev
|
||||
base = state.get("app") if isinstance(state.get("app"), dict) else {field: current.get(field) for field in FIELDS}
|
||||
before = state.get("steam") if isinstance(state.get("steam"), dict) else {"Exe": current.get("Exe")}
|
||||
if unquoted(current.get("Exe")) != unquoted(before.get("Exe")):
|
||||
rev += 1
|
||||
entry = dict(current)
|
||||
entry["appid"] = fresh["appid"]
|
||||
exe = unquoted(fresh["Exe"])
|
||||
if exe != unquoted(current.get("Exe")) and (exe != unquoted(base.get("Exe")) or seen == rev):
|
||||
entry["Exe"], entry["StartDir"] = fresh["Exe"], fresh["StartDir"]
|
||||
for field in ("AppName", "icon"):
|
||||
if current.get(field) == base.get(field):
|
||||
entry[field] = fresh[field]
|
||||
tags = dict(current["tags"]) if isinstance(current.get("tags"), dict) else {}
|
||||
if TAG not in tags.values():
|
||||
slot = 0
|
||||
while str(slot) in tags:
|
||||
slot += 1
|
||||
tags[str(slot)] = TAG
|
||||
entry["tags"] = tags
|
||||
for key, value in fresh.items():
|
||||
entry.setdefault(key, value)
|
||||
return entry, rev
|
||||
|
||||
|
||||
def reconcile(existing, wanted, seen, record):
|
||||
games, away = record["games"], dict(record["away"])
|
||||
kept, ours, placed = [], [], {}
|
||||
for value in existing.values():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
tags = value.get("tags")
|
||||
if not isinstance(tags, dict) or TAG not in tags.values():
|
||||
kept.append(value)
|
||||
continue
|
||||
appid = value.get("appid")
|
||||
if not isinstance(appid, int) or appid in placed:
|
||||
continue
|
||||
key = str(appid & 0xFFFFFFFF)
|
||||
fresh = wanted.get(appid)
|
||||
if fresh is None:
|
||||
away.pop(key, None)
|
||||
away[key] = {"entry": value, "state": games.get(key)}
|
||||
continue
|
||||
entry, rev = merge(fresh, value, games.get(key), seen.get(appid, 0))
|
||||
placed[appid] = (len(ours), rev)
|
||||
ours.append(entry)
|
||||
for appid, fresh in wanted.items():
|
||||
if appid in placed:
|
||||
continue
|
||||
key = str(appid & 0xFFFFFFFF)
|
||||
stashed = away.get(key)
|
||||
if stashed:
|
||||
entry, rev = merge(fresh, stashed["entry"], stashed.get("state"), seen.get(appid, 0))
|
||||
else:
|
||||
entry, rev = merge(fresh, None, games.get(key), seen.get(appid, 0))
|
||||
placed[appid] = (len(ours), rev)
|
||||
ours.append(entry)
|
||||
for appid in placed:
|
||||
away.pop(str(appid & 0xFFFFFFFF), None)
|
||||
while len(away) > AWAY_LIMIT:
|
||||
away.pop(next(iter(away)))
|
||||
state = {}
|
||||
for appid, (index, rev) in placed.items():
|
||||
entry = ours[index]
|
||||
state[str(appid & 0xFFFFFFFF)] = {
|
||||
"app": {field: wanted[appid][field] for field in FIELDS},
|
||||
"steam": {"Exe": entry.get("Exe", ""), "StartDir": entry.get("StartDir", "")},
|
||||
"rev": rev,
|
||||
}
|
||||
return kept, ours, {"games": state, "away": away}
|
||||
|
||||
|
||||
def save_record(path, record):
|
||||
tmp = path + ".droiddeck"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(record, f)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
ART_MANIFEST = ".droiddeck-art.json"
|
||||
# listing key -> the client's grid file suffix: <appid>p = portrait capsule, <appid> = header,
|
||||
# <appid>_hero, <appid>_logo.
|
||||
@@ -208,27 +316,21 @@ def main():
|
||||
routing = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(routing)
|
||||
games, routes = routing.route(steam_root, acct, games)
|
||||
# Steam saves Big Picture's per-shortcut launch options in shortcuts.vdf. Keep them when
|
||||
# refreshing our entries, keyed by the appid that stays stable across session starts.
|
||||
managed = {
|
||||
v.get("appid"): v for v in existing.values()
|
||||
if isinstance(v, dict) and TAG in (v.get("tags") or {}).values()
|
||||
}
|
||||
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
|
||||
ours = []
|
||||
wanted, seen = {}, {}
|
||||
for game in games:
|
||||
if not game.get("exe") or not game.get("name"):
|
||||
continue
|
||||
entry = shortcut(game)
|
||||
previous = managed.get(entry["appid"])
|
||||
if previous and isinstance(previous.get("LaunchOptions"), str):
|
||||
entry["LaunchOptions"] = previous["LaunchOptions"]
|
||||
ours.append(entry)
|
||||
if game.get("exe") and game.get("name"):
|
||||
entry = shortcut(game)
|
||||
if entry["appid"] not in wanted:
|
||||
wanted[entry["appid"]] = entry
|
||||
seen[entry["appid"]] = game["seen"] if isinstance(game.get("seen"), int) else 0
|
||||
record_path = os.path.join(config, RECORD)
|
||||
kept, ours, record = reconcile(existing, wanted, seen, read_record(record_path))
|
||||
entries = {str(i): v for i, v in enumerate(kept + ours)}
|
||||
tmp = path + ".droiddeck"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(write({"shortcuts": entries}))
|
||||
os.replace(tmp, path)
|
||||
save_record(record_path, record)
|
||||
print("droiddeck-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept)))
|
||||
install_art(acct, [g for g in games if g.get("exe") and g.get("name")])
|
||||
# Each added game runs under the ARM64 Proton: the compat registrar's own mapping writer.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/usr/bin/python3
|
||||
"""Initialize Steam's own Gamescope UI scale once; subsequent choices belong to Steam."""
|
||||
"""Initialize Steam's own Gamescope display settings once; subsequent choices belong to Steam.
|
||||
|
||||
The UI scale, and native resolution: the client treats our gamescope as an external display and,
|
||||
unless "Enable Native Resolution on External Displays" is on, draws itself at 1920x1080 on any
|
||||
larger output - a 2340x1080 session came up as 16:9 with bars. The session's size is already the
|
||||
user's Resolution choice, so the client should use all of it."""
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -9,6 +14,7 @@ import tempfile
|
||||
|
||||
|
||||
DISPLAY = "External: gamescope|||Windowed"
|
||||
NATIVE_RESOLUTION = "EnableSteamNativeExternalResolution"
|
||||
TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]')
|
||||
|
||||
|
||||
@@ -102,6 +108,25 @@ def seed_text(text, scale):
|
||||
return text[:at] + insertion + text[at:], True
|
||||
|
||||
|
||||
def seed_native_resolution(text):
|
||||
root = parse(text).get('InstallConfigStore')
|
||||
if not isinstance(root, Block):
|
||||
raise ValueError("missing InstallConfigStore")
|
||||
gamescope = root.get('Gamescope')
|
||||
if gamescope is not None and not isinstance(gamescope, Block):
|
||||
raise ValueError("invalid Gamescope settings")
|
||||
if gamescope is not None and gamescope.get(NATIVE_RESOLUTION) is not None:
|
||||
return text, False
|
||||
entry = f'"{NATIVE_RESOLUTION}" "1"'
|
||||
if gamescope is not None:
|
||||
at, indent = gamescope.end, 2
|
||||
else:
|
||||
entry = '"Gamescope"\n{\n' + entry + '\n}'
|
||||
at, indent = root.end, 1
|
||||
insertion = '\n' + '\n'.join('\t' * indent + line for line in entry.splitlines()) + '\n'
|
||||
return text[:at] + insertion + text[at:], True
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
||||
@@ -128,9 +153,30 @@ def initialize(steam_root, width, height):
|
||||
return changed
|
||||
|
||||
|
||||
def initialize_native_resolution(steam_root):
|
||||
config = Path(steam_root) / 'config/config.vdf'
|
||||
marker = config.with_name('droiddeck-native-resolution-initialized')
|
||||
if marker.exists():
|
||||
return False
|
||||
text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n'
|
||||
updated, changed = seed_native_resolution(text)
|
||||
if changed:
|
||||
atomic_write(config, updated)
|
||||
print('== steam: native resolution on the session display')
|
||||
atomic_write(marker, '1\n')
|
||||
return changed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
status = 0
|
||||
try:
|
||||
initialize(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
print('== steam: left UI scale unchanged: ' + str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
status = 1
|
||||
try:
|
||||
initialize_native_resolution(sys.argv[1])
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
print('== steam: left native resolution unchanged: ' + str(error), file=sys.stderr)
|
||||
status = 1
|
||||
sys.exit(status)
|
||||
@@ -277,9 +277,10 @@ done
|
||||
# The depot this tool is named after, newest across the libraries that hold it; the others in the
|
||||
# list stand in only when it is missing.
|
||||
depot=
|
||||
libraries=$(printf '%%s\\n' "$steam_root" /mnt/droiddeck-sd; sed -n 's/^[[:space:]]*"path"[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)
|
||||
for name in %s; do
|
||||
best=0
|
||||
for library in "$steam_root" /mnt/droiddeck-sd; do
|
||||
while IFS= read -r library; do
|
||||
candidate="$library/steamapps/common/$name"
|
||||
[ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue
|
||||
build=0
|
||||
@@ -289,7 +290,7 @@ for name in %s; do
|
||||
depot=$candidate
|
||||
best=$build
|
||||
fi
|
||||
done
|
||||
done <<< "$libraries"
|
||||
[ -n "$depot" ] && break
|
||||
done
|
||||
if [ -n "$depot" ]; then
|
||||
@@ -615,10 +616,21 @@ NOT_GAMES = frozenset((
|
||||
))
|
||||
|
||||
|
||||
def libraries(steam_root):
|
||||
found = [steam_root]
|
||||
try:
|
||||
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
|
||||
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
|
||||
except OSError:
|
||||
pass
|
||||
found.append(LIBRARY + "-sd")
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def installed_apps(steam_root):
|
||||
"""App ids of the titles the client has installed, tools and runtimes excluded."""
|
||||
apps = set()
|
||||
for library in (steam_root, LIBRARY + "-sd"):
|
||||
for library in libraries(steam_root):
|
||||
steamapps = os.path.join(library, "steamapps")
|
||||
try:
|
||||
names = os.listdir(steamapps)
|
||||
@@ -778,7 +790,7 @@ def fex_missing(steam_root):
|
||||
missing = []
|
||||
for app in FEX_DEPENDENCIES:
|
||||
if not any(os.path.isfile(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app))
|
||||
for library in (steam_root, LIBRARY + "-sd")):
|
||||
for library in libraries(steam_root)):
|
||||
missing.append(app)
|
||||
return missing
|
||||
|
||||
@@ -1065,7 +1077,7 @@ def depot_build(depot):
|
||||
def find_source(steam_root, sources=SOURCES):
|
||||
"""The first of `sources` the client has downloaded, newest across the libraries holding it."""
|
||||
for name in sources:
|
||||
found = [os.path.join(root, "steamapps", "common", name) for root in (steam_root, "/mnt/droiddeck-sd")
|
||||
found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root)
|
||||
if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))]
|
||||
if found:
|
||||
return max(found, key=depot_build)
|
||||
|
||||
@@ -1237,9 +1237,14 @@ static void ns_received(struct msghdr *msg) {
|
||||
}
|
||||
}
|
||||
|
||||
int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags, ssize_t (*real_recvmsg)(int, struct msghdr *, int),
|
||||
ssize_t *result) __attribute__((visibility("hidden")));
|
||||
|
||||
ssize_t recvmsg(int fd, struct msghdr *msg, int flags) {
|
||||
static ssize_t (*real)(int, struct msghdr *, int);
|
||||
if (!real) real = (ssize_t (*)(int, struct msghdr *, int))dlsym(RTLD_NEXT, "recvmsg");
|
||||
ssize_t answered;
|
||||
if (msg && bl_udevmon_recvmsg(fd, msg, flags, real, &answered)) return answered;
|
||||
ssize_t r = real(fd, msg, flags);
|
||||
if (r >= 0 && msg && msg->msg_control && msg->msg_controllen && ns_on()) {
|
||||
int saved = errno;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/*
|
||||
* Opt-in observations for file allocation and writes to the selected Steam library.
|
||||
*
|
||||
* Steam's allocation failure and any later fallback are left entirely to the caller. These
|
||||
* wrappers only forward the same arguments, record the result and restore errno after logging.
|
||||
* The session enables them only when storage diagnostics are turned on. Counters are local to
|
||||
* each process, allocation details are capped, writes are sampled, and storage.log has a global
|
||||
* size limit because every Steam child shares it.
|
||||
@@ -154,19 +152,56 @@ static void allocation_line(const char *api, int result, int error, int flags,
|
||||
if (n > 0 && (size_t)n < sizeof(line)) append_line(line, (size_t)n);
|
||||
}
|
||||
|
||||
static int steam_client(void) {
|
||||
static int cached = -1;
|
||||
if (cached < 0)
|
||||
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
|
||||
strstr(program_invocation_name, "steamrtarm64") != NULL;
|
||||
return cached;
|
||||
}
|
||||
|
||||
static int staging_file(int fd) {
|
||||
char link[32], path[PATH_MAX];
|
||||
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
|
||||
long n = syscall(SYS_readlinkat, (long)AT_FDCWD, (long)link, (long)path, (long)sizeof(path) - 1);
|
||||
if (n <= 0) return 0;
|
||||
path[n] = '\0';
|
||||
return strstr(path, "/steamapps/downloading/") != NULL;
|
||||
}
|
||||
|
||||
static long long unreserved[16];
|
||||
static unsigned int unreserved_count;
|
||||
|
||||
static int cannot_reserve(long long device) {
|
||||
unsigned int n = __atomic_load_n(&unreserved_count, __ATOMIC_ACQUIRE);
|
||||
for (unsigned int i = 0; i < n && i < 16; i++)
|
||||
if (__atomic_load_n(&unreserved[i], __ATOMIC_RELAXED) == device) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void note_cannot_reserve(int fd) {
|
||||
struct stat st;
|
||||
if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat");
|
||||
if (next_fstat == NULL || next_fstat(fd, &st) != 0 || cannot_reserve((long long)st.st_dev)) return;
|
||||
unsigned int slot = __atomic_load_n(&unreserved_count, __ATOMIC_RELAXED);
|
||||
while (slot < 16 && !__atomic_compare_exchange_n(&unreserved_count, &slot, slot + 1, 0, __ATOMIC_ACQ_REL,
|
||||
__ATOMIC_RELAXED)) {
|
||||
}
|
||||
if (slot < 16) __atomic_store_n(&unreserved[slot], (long long)st.st_dev, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
int fallocate(int fd, int mode, off_t offset, off_t length) {
|
||||
int entry_errno = errno;
|
||||
if (next_fallocate == NULL) next_fallocate = dlsym(RTLD_NEXT, "fallocate");
|
||||
if (next_fallocate == NULL) { errno = ENOSYS; return -1; }
|
||||
int selected = on_selected_device(fd, NULL);
|
||||
errno = entry_errno;
|
||||
if (!selected) return next_fallocate(fd, mode, offset, length);
|
||||
uint64_t start = now_ns();
|
||||
uint64_t start = selected ? now_ns() : 0;
|
||||
errno = entry_errno;
|
||||
int result = next_fallocate(fd, mode, offset, length);
|
||||
int call_errno = errno;
|
||||
uint64_t elapsed = now_ns() - start;
|
||||
allocation_line("fallocate", result, call_errno, mode, offset, length, elapsed);
|
||||
if (result != 0 && (call_errno == EOPNOTSUPP || call_errno == ENOSYS) && steam_client()) note_cannot_reserve(fd);
|
||||
if (selected) allocation_line("fallocate", result, call_errno, mode, offset, length, now_ns() - start);
|
||||
errno = call_errno;
|
||||
return result;
|
||||
}
|
||||
@@ -222,12 +257,28 @@ int ftruncate(int fd, off_t length) {
|
||||
if (next_ftruncate == NULL) next_ftruncate = dlsym(RTLD_NEXT, "ftruncate");
|
||||
if (next_ftruncate == NULL) { errno = ENOSYS; return -1; }
|
||||
int selected = on_selected_device(fd, NULL);
|
||||
errno = entry_errno;
|
||||
if (!selected) return next_ftruncate(fd, length);
|
||||
uint64_t start = now_ns();
|
||||
errno = entry_errno;
|
||||
int result = next_ftruncate(fd, length);
|
||||
int call_errno = errno;
|
||||
uint64_t start = selected ? now_ns() : 0;
|
||||
struct stat st;
|
||||
int known = 0;
|
||||
if (steam_client()) {
|
||||
if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat");
|
||||
known = next_fstat != NULL && next_fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && staging_file(fd);
|
||||
}
|
||||
int result = 0, call_errno = entry_errno;
|
||||
if (!known || length <= st.st_size || !cannot_reserve((long long)st.st_dev)) {
|
||||
errno = entry_errno;
|
||||
result = next_ftruncate(fd, length);
|
||||
call_errno = errno;
|
||||
if (result != 0 && known && length >= st.st_size && (call_errno == EPERM || call_errno == EOPNOTSUPP)) {
|
||||
if (length > st.st_size) note_cannot_reserve(fd);
|
||||
result = 0;
|
||||
call_errno = entry_errno;
|
||||
}
|
||||
}
|
||||
if (!selected) {
|
||||
errno = call_errno;
|
||||
return result;
|
||||
}
|
||||
uint64_t elapsed = now_ns() - start;
|
||||
add(&stats.ftruncate_calls, 1);
|
||||
if (result != 0) add(&stats.ftruncate_failures, 1);
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <endian.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
struct stand_in {
|
||||
int fd; /* the descriptor handed out, -1 when the slot is free */
|
||||
@@ -95,8 +99,11 @@ static int is_wine_process(void) {
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
static void start_injector(void);
|
||||
|
||||
static int make_stand_in(int type) {
|
||||
int sv[2];
|
||||
start_injector();
|
||||
int saved = errno;
|
||||
int flags = SOCK_DGRAM | (type & (SOCK_CLOEXEC | SOCK_NONBLOCK));
|
||||
if (socketpair(AF_UNIX, flags, 0, sv) != 0) {
|
||||
@@ -189,3 +196,180 @@ int setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt
|
||||
}
|
||||
return real_setsockopt(fd, level, optname, optval, optlen);
|
||||
}
|
||||
|
||||
/* EXPERIMENT (docs/development/steam-touch-controller.md): hotplug through the stand-in.
|
||||
*
|
||||
* A file dropped in INJECT_DIR is a udev event: KEY=VALUE lines (ACTION, DEVPATH, SUBSYSTEM,
|
||||
* DEVNAME, MAJOR, MINOR...). It is sent to every stand-in as udevd would send it to the "udev"
|
||||
* group, and recvmsg below dresses it as udevd's: the udev multicast group as the sender address
|
||||
* and root credentials, without which libudev drops a message. SDL's HID discovery counts an
|
||||
* add or remove and enumerates again, so a hidraw node can come and go while the client runs. */
|
||||
#define INJECT_DIR "/tmp/udev-inject"
|
||||
|
||||
struct monitor_netlink_header {
|
||||
char prefix[8];
|
||||
unsigned magic;
|
||||
unsigned header_size;
|
||||
unsigned properties_off;
|
||||
unsigned properties_len;
|
||||
unsigned filter_subsystem_hash;
|
||||
unsigned filter_devtype_hash;
|
||||
unsigned filter_tag_bloom_hi;
|
||||
unsigned filter_tag_bloom_lo;
|
||||
};
|
||||
|
||||
static unsigned murmur_hash2(const char *key, size_t len) {
|
||||
/* systemd's string_hash32 (MurmurHash2, seed 0) */
|
||||
const unsigned m = 0x5bd1e995;
|
||||
const int r = 24;
|
||||
unsigned h = (unsigned)len;
|
||||
const unsigned char *data = (const unsigned char *)key;
|
||||
while (len >= 4) {
|
||||
unsigned k;
|
||||
memcpy(&k, data, 4);
|
||||
k *= m; k ^= k >> r; k *= m;
|
||||
h *= m; h ^= k;
|
||||
data += 4; len -= 4;
|
||||
}
|
||||
switch (len) {
|
||||
case 3: h ^= data[2] << 16; /* fallthrough */
|
||||
case 2: h ^= data[1] << 8; /* fallthrough */
|
||||
case 1: h ^= data[0]; h *= m;
|
||||
}
|
||||
h ^= h >> 13; h *= m; h ^= h >> 15;
|
||||
return h;
|
||||
}
|
||||
|
||||
static void inject_file(const char *path, unsigned long long seqnum) {
|
||||
char text[2048];
|
||||
FILE *f = fopen(path, "re");
|
||||
if (f == NULL) return;
|
||||
size_t n = fread(text, 1, sizeof(text) - 1, f);
|
||||
fclose(f);
|
||||
text[n] = '\0';
|
||||
char props[2300];
|
||||
size_t used = 0;
|
||||
char subsystem[64] = "";
|
||||
char *save = NULL;
|
||||
for (char *line = strtok_r(text, "\n", &save); line != NULL; line = strtok_r(NULL, "\n", &save)) {
|
||||
size_t len = strlen(line);
|
||||
if (len == 0 || used + len + 1 > sizeof(props) - 64) continue;
|
||||
if (strncmp(line, "SUBSYSTEM=", 10) == 0) snprintf(subsystem, sizeof(subsystem), "%s", line + 10);
|
||||
memcpy(props + used, line, len + 1);
|
||||
used += len + 1;
|
||||
}
|
||||
used += (size_t)snprintf(props + used, sizeof(props) - used, "SEQNUM=%llu", seqnum) + 1;
|
||||
struct monitor_netlink_header header;
|
||||
memset(&header, 0, sizeof(header));
|
||||
memcpy(header.prefix, "libudev", 8);
|
||||
header.magic = htobe32(0xfeedcafe);
|
||||
header.header_size = sizeof(header);
|
||||
header.properties_off = sizeof(header);
|
||||
header.properties_len = (unsigned)used;
|
||||
header.filter_subsystem_hash = htobe32(murmur_hash2(subsystem, strlen(subsystem)));
|
||||
char message[sizeof(header) + sizeof(props)];
|
||||
memcpy(message, &header, sizeof(header));
|
||||
memcpy(message + sizeof(header), props, used);
|
||||
int sent = 0;
|
||||
pthread_mutex_lock(&lock);
|
||||
for (size_t i = 0; i < sizeof(stand_ins) / sizeof(stand_ins[0]); i++) {
|
||||
if (stand_ins[i].fd == -1) continue;
|
||||
if (send(stand_ins[i].peer, message, sizeof(header) + used, MSG_DONTWAIT | MSG_NOSIGNAL) > 0) sent++;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
FILE *done = fopen(INJECT_DIR "/.log", "ae");
|
||||
if (done) {
|
||||
char exe[PATH_MAX] = "";
|
||||
ssize_t len = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
|
||||
if (len > 0) exe[len] = '\0';
|
||||
fprintf(done, "%s: pid %d (%s) sent to %d stand-in(s)\n", path, (int)getpid(), exe, sent);
|
||||
fclose(done);
|
||||
}
|
||||
}
|
||||
|
||||
static void *injector(void *arg) {
|
||||
(void)arg;
|
||||
unsigned long long seqnum = 900000;
|
||||
/* Every process with a stand-in sends each event file once (the client is one of several), so
|
||||
* files are never claimed; whoever sees one older than a few seconds removes it. */
|
||||
static char seen[32][128];
|
||||
size_t next_seen = 0;
|
||||
for (;;) {
|
||||
DIR *dir = opendir(INJECT_DIR);
|
||||
if (dir != NULL) {
|
||||
struct dirent *entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (entry->d_name[0] == '.') continue;
|
||||
char path[300];
|
||||
snprintf(path, sizeof(path), INJECT_DIR "/%s", entry->d_name);
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) continue;
|
||||
if (time(NULL) - st.st_mtime > 5) {
|
||||
unlink(path);
|
||||
continue;
|
||||
}
|
||||
char key[128];
|
||||
snprintf(key, sizeof(key), "%s:%lu", entry->d_name, (unsigned long)st.st_ino);
|
||||
int done = 0;
|
||||
for (size_t i = 0; i < 32; i++)
|
||||
if (strcmp(seen[i], key) == 0) done = 1;
|
||||
if (done) continue;
|
||||
snprintf(seen[next_seen++ % 32], sizeof(seen[0]), "%s", key);
|
||||
inject_file(path, ++seqnum);
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
struct timespec interval = {0, 100 * 1000 * 1000};
|
||||
nanosleep(&interval, NULL);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void spawn_injector(void) {
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, NULL, injector, NULL) == 0) pthread_detach(thread);
|
||||
}
|
||||
|
||||
static void start_injector(void) {
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
pthread_once(&once, spawn_injector);
|
||||
}
|
||||
|
||||
/* For ntsync.c's recvmsg(), which owns the symbol: 1 when fd was a stand-in and was answered here
|
||||
* (*result holds what recvmsg returns), 0 when it is not one. */
|
||||
__attribute__((visibility("hidden"))) int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags,
|
||||
ssize_t (*real_recvmsg)(int, struct msghdr *, int),
|
||||
ssize_t *result) {
|
||||
if (!is_stand_in(fd)) return 0;
|
||||
size_t control_room = msg->msg_controllen;
|
||||
socklen_t name_room = msg->msg_namelen;
|
||||
ssize_t n = real_recvmsg(fd, msg, flags);
|
||||
*result = n;
|
||||
if (n >= 0) {
|
||||
FILE *log = fopen(INJECT_DIR "/.recv", "ae");
|
||||
if (log) {
|
||||
fprintf(log, "pid %d fd %d got %zd bytes, name room %u, control room %zu\n", (int)getpid(), fd, n,
|
||||
(unsigned)name_room, control_room);
|
||||
fclose(log);
|
||||
}
|
||||
}
|
||||
if (n < 0) return 1;
|
||||
if (msg->msg_name != NULL && name_room >= sizeof(struct sockaddr_nl)) {
|
||||
struct sockaddr_nl nl;
|
||||
memset(&nl, 0, sizeof(nl));
|
||||
nl.nl_family = AF_NETLINK;
|
||||
nl.nl_groups = 2; /* udev's multicast group */
|
||||
memcpy(msg->msg_name, &nl, sizeof(nl));
|
||||
msg->msg_namelen = sizeof(nl);
|
||||
}
|
||||
if (msg->msg_control != NULL && control_room >= CMSG_SPACE(sizeof(struct ucred))) {
|
||||
struct cmsghdr *cmsg = (struct cmsghdr *)msg->msg_control;
|
||||
cmsg->cmsg_level = SOL_SOCKET;
|
||||
cmsg->cmsg_type = SCM_CREDENTIALS;
|
||||
cmsg->cmsg_len = CMSG_LEN(sizeof(struct ucred));
|
||||
struct ucred cred = {.pid = 1, .uid = 0, .gid = 0};
|
||||
memcpy(CMSG_DATA(cmsg), &cred, sizeof(cred));
|
||||
msg->msg_controllen = CMSG_SPACE(sizeof(struct ucred));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -231,6 +232,45 @@ class ImportsTest(unittest.TestCase):
|
||||
self.folder.rmdir()
|
||||
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
|
||||
|
||||
def test_absent_folder_sets_its_install_aside_until_it_returns(self):
|
||||
self.snapshot()
|
||||
imports.route(self.steam, self.acct, [self.game])
|
||||
manifest = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
manifest.write_text(manifest.read_text().replace('}', ' "buildid" "99"\n}'))
|
||||
state = manifest.read_text()
|
||||
away = self.root / 'unplugged'
|
||||
self.folder.rename(away)
|
||||
with patch('builtins.print'):
|
||||
self.assertEqual(([], {}), imports.route(self.steam, self.acct, []))
|
||||
self.assertFalse(manifest.exists())
|
||||
self.assertEqual(state, (self.steam / 'steamapps' / imports.PARKED / manifest.name).read_text())
|
||||
away.rename(self.folder)
|
||||
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertEqual(state, manifest.read_text())
|
||||
self.assertFalse((self.steam / 'steamapps' / imports.PARKED / manifest.name).exists())
|
||||
|
||||
def test_steam_installs_are_never_set_aside(self):
|
||||
manifest = self.steam / 'steamapps/appmanifest_42.acf'
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text('"AppState"\n{\n "appid" "42"\n "installdir" "Example"\n}\n')
|
||||
link = self.steam / 'steamapps/common/DroidDeck-42'
|
||||
link.parent.mkdir(parents=True)
|
||||
link.symlink_to(self.root / 'missing', target_is_directory=True)
|
||||
imports.route(self.steam, self.acct, [])
|
||||
self.assertTrue(manifest.is_file())
|
||||
|
||||
def test_install_in_another_library_is_used_as_is(self):
|
||||
other = self.root / 'Games/PC'
|
||||
(other / 'steamapps').mkdir(parents=True)
|
||||
(other / 'steamapps/appmanifest_42.acf').write_text('Steam installed it here')
|
||||
vdf = self.steam / 'steamapps/libraryfolders.vdf'
|
||||
vdf.parent.mkdir(parents=True)
|
||||
vdf.write_text('"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n}\n' % other)
|
||||
self.snapshot()
|
||||
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
|
||||
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
|
||||
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
|
||||
|
||||
def test_replaced_game_does_not_inherit_cached_ownership(self):
|
||||
self.snapshot()
|
||||
self.exe.write_bytes(b'a different game')
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
||||
|
||||
|
||||
def load(name):
|
||||
loader = importlib.machinery.SourceFileLoader(name.replace("-", "_"), str(BIN / name))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
library = load("droiddeck-steam-library")
|
||||
desktop = load("droiddeck-desktop-games")
|
||||
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
|
||||
|
||||
|
||||
def entry(index, path, label=""):
|
||||
return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"label"\t\t"%s"\n\t}\n' % (index, path, label)
|
||||
|
||||
|
||||
def manifest(steamapps, appid, name="Game", installdir="Game", flags="4"):
|
||||
steamapps.mkdir(parents=True, exist_ok=True)
|
||||
(steamapps / ("appmanifest_%s.acf" % appid)).write_text(
|
||||
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n}\n'
|
||||
% (appid, name, flags, installdir))
|
||||
|
||||
|
||||
class LibraryListTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
self.steam = self.root / "Steam"
|
||||
self.vdf = self.steam / "steamapps/libraryfolders.vdf"
|
||||
self.vdf.parent.mkdir(parents=True)
|
||||
self.games = self.root / "Games"
|
||||
self.card = self.root / "card"
|
||||
self.pc = self.games / "PC"
|
||||
self.old = self.games / "Old"
|
||||
self.user = self.root / "user-library"
|
||||
for folder in (self.pc, self.old, self.user, self.card):
|
||||
(folder / "steamapps").mkdir(parents=True)
|
||||
manifest(self.pc / "steamapps", 10)
|
||||
self.vdf.write_text('"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.old, "Old")
|
||||
+ entry(2, self.user, "Mine") + entry(3, self.card, "SD Card") + "}\n")
|
||||
self.listing = self.root / "steam-libraries.json"
|
||||
for target in (patch.object(library, "GAMES_DIR", str(self.games) + "/"),
|
||||
patch.object(library, "LIBRARIES", ((str(self.card), "SD Card"),)),
|
||||
patch("builtins.print")):
|
||||
target.start()
|
||||
self.addCleanup(target.stop)
|
||||
|
||||
def run_main(self, listing=True):
|
||||
with patch.dict(os.environ), patch("sys.argv", ["droiddeck-steam-library", str(self.steam)]):
|
||||
os.environ.pop("BL_STEAM_LIBRARIES", None)
|
||||
if listing:
|
||||
os.environ["BL_STEAM_LIBRARIES"] = str(self.listing)
|
||||
library.main()
|
||||
return self.vdf.read_text()
|
||||
|
||||
def paths(self, text):
|
||||
return [Path(p) for p in re.findall(r'"path"\s+"([^"]*)"', text)]
|
||||
|
||||
def test_listed_games_folder_is_registered_and_the_rest_are_forgotten(self):
|
||||
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC (PNY USB drive)"}]))
|
||||
text = self.run_main()
|
||||
self.assertEqual([self.steam, self.user, self.pc], self.paths(text))
|
||||
self.assertIn('"label"\t\t"PC (PNY USB drive)"', text)
|
||||
self.assertIn('"10"\t\t"0"', text)
|
||||
self.assertIn('"label"\t\t"PC (PNY USB drive)"', (self.pc / "libraryfolder.vdf").read_text())
|
||||
|
||||
def test_absent_listed_folder_is_forgotten_and_comes_back(self):
|
||||
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC"}, {"path": str(self.card), "label": "SD Card"}]))
|
||||
self.run_main()
|
||||
moved = self.root / "unplugged"
|
||||
self.pc.rename(moved)
|
||||
self.assertNotIn(self.pc, self.paths(self.run_main()))
|
||||
moved.rename(self.pc)
|
||||
self.assertIn(self.pc, self.paths(self.run_main()))
|
||||
self.assertIn(self.card, self.paths(self.vdf.read_text()))
|
||||
|
||||
def test_without_a_list_only_the_card_is_managed(self):
|
||||
text = self.run_main(listing=False)
|
||||
self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text))
|
||||
|
||||
def test_unreadable_list_keeps_games_folder_entries(self):
|
||||
self.listing.write_text("{not json")
|
||||
text = self.run_main()
|
||||
self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text))
|
||||
|
||||
def test_labels_cannot_break_the_vdf(self):
|
||||
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": 'P"C\\'}]))
|
||||
text = self.run_main()
|
||||
self.assertIn('"label"\t\t"P C "', text)
|
||||
|
||||
|
||||
class CompatLibrariesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
self.steam = self.root / "Steam"
|
||||
self.pc = self.root / "Games/My PC"
|
||||
(self.steam / "steamapps").mkdir(parents=True)
|
||||
(self.steam / "steamapps/libraryfolders.vdf").write_text(
|
||||
'"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.pc, "PC") + "}\n")
|
||||
|
||||
def test_titles_and_tools_in_a_registered_library_are_found(self):
|
||||
manifest(self.steam / "steamapps", 20)
|
||||
manifest(self.pc / "steamapps", 30)
|
||||
manifest(self.pc / "steamapps", 3127680, installdir="FEX")
|
||||
self.assertEqual(["20", "30"], COMPAT["installed_apps"](str(self.steam)))
|
||||
self.assertEqual(["1628350"], COMPAT["fex_missing"](str(self.steam)))
|
||||
|
||||
def test_newest_proton_across_libraries_wins(self):
|
||||
for root, build in ((self.steam, "100"), (self.pc, "200")):
|
||||
depot = root / "steamapps/common/Proton Experimental (ARM64)"
|
||||
(depot / "files/bin-arm64").mkdir(parents=True)
|
||||
(depot / "version").write_text(build + " x\n")
|
||||
self.assertEqual(str(self.pc / "steamapps/common/Proton Experimental (ARM64)"), COMPAT["find_source"](str(self.steam)))
|
||||
|
||||
def test_missing_list_still_reads_the_client_library(self):
|
||||
(self.steam / "steamapps/libraryfolders.vdf").unlink()
|
||||
self.assertEqual(str(self.steam), COMPAT["libraries"](str(self.steam))[0])
|
||||
|
||||
|
||||
class DesktopGamesTest(unittest.TestCase):
|
||||
def test_games_in_registered_libraries_get_entries(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
steam, pc = root / "Steam", root / "Games/PC"
|
||||
manifest(steam / "steamapps", 40, name="Internal")
|
||||
manifest(pc / "steamapps", 50, name="Outside")
|
||||
(steam / "steamapps/libraryfolders.vdf").write_text('"libraryfolders"\n{\n' + entry(0, steam) + entry(1, pc) + "}\n")
|
||||
with patch.object(desktop, "STEAM_ROOT", str(steam)), patch.object(desktop, "APPS_DIR", str(root / "apps")), \
|
||||
patch.object(desktop, "LIBRARIES", ((str(steam), "Steam Games"), (str(root / "card"), "SD Games"))):
|
||||
self.assertEqual({40: "Internal", 50: "Outside"}, desktop.write_entries())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
|
||||
|
||||
|
||||
def load(name):
|
||||
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
|
||||
spec = importlib.util.spec_from_loader(name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
shortcuts = load('droiddeck-steam-shortcuts')
|
||||
|
||||
APPID = 0x87654321
|
||||
GUEST = '/root/Games/PC/Example'
|
||||
|
||||
|
||||
class ShortcutsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.steam = self.root / 'Steam'
|
||||
self.config = self.steam / 'userdata/123/config'
|
||||
self.config.mkdir(parents=True)
|
||||
self.listing = self.root / 'added-games.json'
|
||||
|
||||
def game(self, exe='Example.exe', seen=0, appid=APPID, name='Example'):
|
||||
path = GUEST + '/' + exe
|
||||
return dict(name=name, exe=path, folder=GUEST, dir=path.rsplit('/', 1)[0], appid=appid, seen=seen)
|
||||
|
||||
def run_writer(self, *games):
|
||||
self.listing.write_text(json.dumps(list(games)))
|
||||
subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(self.listing)],
|
||||
check=True, capture_output=True)
|
||||
return self.entries()
|
||||
|
||||
def entries(self):
|
||||
data = shortcuts.parse((self.config / 'shortcuts.vdf').read_bytes())['shortcuts']
|
||||
return {value['appid'] & 0xFFFFFFFF: value for value in data.values()}
|
||||
|
||||
def steam_edits(self, appid=APPID, **fields):
|
||||
path = self.config / 'shortcuts.vdf'
|
||||
data = shortcuts.parse(path.read_bytes())['shortcuts']
|
||||
for value in data.values():
|
||||
if value['appid'] & 0xFFFFFFFF == appid:
|
||||
value.update(fields)
|
||||
path.write_bytes(shortcuts.write({'shortcuts': data}))
|
||||
|
||||
def record(self):
|
||||
return json.loads((self.config / shortcuts.RECORD).read_text())
|
||||
|
||||
def test_settings_changed_in_steam_survive_its_restart(self):
|
||||
self.run_writer(self.game())
|
||||
edits = dict(Exe='"%s/bin/Other.exe"' % GUEST, StartDir='"%s"' % GUEST, LaunchOptions='DXVK_HUD=1 %command%',
|
||||
AppName='Example GOTY', icon='/root/custom.png', IsHidden=1, AllowOverlay=0, LastPlayTime=1791372037,
|
||||
sortas='example', tags={'0': shortcuts.TAG, '1': 'Favorites'})
|
||||
self.steam_edits(**edits)
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
for field, value in edits.items():
|
||||
self.assertEqual(value, entry[field], field)
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
for field, value in edits.items():
|
||||
self.assertEqual(value, entry[field], field)
|
||||
|
||||
def test_steam_edit_is_recorded_for_the_app(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
state = self.record()['games'][str(APPID)]
|
||||
self.assertEqual('"%s/Example.exe"' % GUEST, state['app']['Exe'])
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, state['steam']['Exe'])
|
||||
self.assertEqual(1, state['rev'])
|
||||
self.run_writer(self.game())
|
||||
self.assertEqual(1, self.record()['games'][str(APPID)]['rev'])
|
||||
|
||||
def test_the_app_taking_steams_target_keeps_steams_start_in(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, StartDir='"%s/data"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
entry = self.run_writer(self.game('bin/Other.exe', seen=1))[APPID]
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('"%s/data"' % GUEST, entry['StartDir'])
|
||||
|
||||
def test_an_exe_chosen_in_the_app_replaces_steams_target(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, LaunchOptions='-windowed')
|
||||
self.run_writer(self.game())
|
||||
entry = self.run_writer(self.game('tools/Launcher.exe'))[APPID]
|
||||
self.assertEqual('"%s/tools/Launcher.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('"%s/tools"' % GUEST, entry['StartDir'])
|
||||
self.assertEqual('-windowed', entry['LaunchOptions'])
|
||||
|
||||
def test_going_back_to_the_apps_exe_after_taking_steams_wins(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
entry = self.run_writer(self.game(seen=1))[APPID]
|
||||
self.assertEqual('"%s/Example.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('"%s"' % GUEST, entry['StartDir'])
|
||||
|
||||
def test_a_later_steam_edit_is_kept_until_the_app_takes_it(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
self.run_writer(self.game(seen=1))
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
entry = self.run_writer(self.game(seen=1))[APPID]
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual(2, self.record()['games'][str(APPID)]['rev'])
|
||||
|
||||
def test_a_game_that_goes_away_comes_back_with_its_settings(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, LaunchOptions='-dx11', LastPlayTime=1791372037)
|
||||
self.assertEqual({}, self.run_writer())
|
||||
self.assertIn(str(APPID), self.record()['away'])
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('-dx11', entry['LaunchOptions'])
|
||||
self.assertEqual(1791372037, entry['LastPlayTime'])
|
||||
self.assertEqual({}, self.record()['away'])
|
||||
|
||||
def test_first_run_keeps_steams_own_fields_and_writes_the_apps_paths(self):
|
||||
old = dict(shortcuts.shortcut(self.game('Old.exe')), LaunchOptions='-novid', LastPlayTime=5, sortas='x')
|
||||
mine = dict(shortcuts.shortcut(dict(self.game(appid=0x81111111), name='Mine')), tags={'0': 'Personal'})
|
||||
(self.config / 'shortcuts.vdf').write_bytes(shortcuts.write({'shortcuts': {'0': mine, '1': old}}))
|
||||
entries = self.run_writer(self.game())
|
||||
entry = entries[APPID]
|
||||
self.assertEqual('"%s/Example.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('-novid', entry['LaunchOptions'])
|
||||
self.assertEqual(5, entry['LastPlayTime'])
|
||||
self.assertEqual('x', entry['sortas'])
|
||||
self.assertEqual(mine, entries[0x81111111])
|
||||
|
||||
def test_a_missing_tag_is_put_back_without_losing_steams(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(tags={'1': 'Favorites', '0': shortcuts.TAG})
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
self.assertEqual({'1': 'Favorites', '0': shortcuts.TAG}, entry['tags'])
|
||||
self.assertEqual({'0': shortcuts.TAG}, shortcuts.merge(shortcuts.shortcut(self.game()), dict(shortcuts.shortcut(self.game()), tags={}), None, 0)[0]['tags'])
|
||||
|
||||
def test_a_damaged_record_is_started_over(self):
|
||||
self.run_writer(self.game())
|
||||
(self.config / shortcuts.RECORD).write_text('{not json')
|
||||
self.steam_edits(LaunchOptions='-safe')
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
self.assertEqual('-safe', entry['LaunchOptions'])
|
||||
self.assertIn(str(APPID), self.record()['games'])
|
||||
|
||||
def test_games_set_aside_are_bounded(self):
|
||||
many = [self.game(appid=0x80000000 + n, name='Game %d' % n) for n in range(shortcuts.AWAY_LIMIT + 4)]
|
||||
self.run_writer(*many)
|
||||
self.run_writer()
|
||||
away = self.record()['away']
|
||||
self.assertEqual(shortcuts.AWAY_LIMIT, len(away))
|
||||
self.assertNotIn(str(0x80000000), away)
|
||||
self.assertIn(str(0x80000000 + shortcuts.AWAY_LIMIT + 3), away)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -11,6 +11,9 @@ seed_text = MODULE['seed_text']
|
||||
parse = MODULE['parse']
|
||||
initial_scale = MODULE['initial_scale']
|
||||
DISPLAY = MODULE['DISPLAY']
|
||||
seed_native_resolution = MODULE['seed_native_resolution']
|
||||
initialize_native_resolution = MODULE['initialize_native_resolution']
|
||||
NATIVE_RESOLUTION = MODULE['NATIVE_RESOLUTION']
|
||||
|
||||
|
||||
class SteamUiScaleTest(unittest.TestCase):
|
||||
@@ -96,3 +99,55 @@ class SteamUiScaleTest(unittest.TestCase):
|
||||
initialize(self.root, 0, 720)
|
||||
self.assertFalse(self.config.exists())
|
||||
self.assertFalse(self.marker.exists())
|
||||
|
||||
|
||||
class SteamNativeResolutionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.config = self.root / 'config/config.vdf'
|
||||
self.config.parent.mkdir()
|
||||
self.marker = self.config.with_name('droiddeck-native-resolution-initialized')
|
||||
|
||||
def native(self, text):
|
||||
return parse(text).get('InstallConfigStore').get('Gamescope').get(NATIVE_RESOLUTION)
|
||||
|
||||
def test_fresh_client_renders_at_the_session_size(self):
|
||||
self.assertTrue(initialize_native_resolution(self.root))
|
||||
self.assertEqual('1', self.native(self.config.read_text()))
|
||||
self.assertTrue(self.marker.exists())
|
||||
|
||||
def test_existing_client_gets_it_once_beside_its_settings(self):
|
||||
text = '"InstallConfigStore"\n{\n\t"Gamescope"\n\t{\n\t\t"HDREnabled"\t\t"1"\n\t}\n}\n'
|
||||
self.config.write_text(text)
|
||||
self.assertTrue(initialize_native_resolution(self.root))
|
||||
updated = self.config.read_text()
|
||||
self.assertEqual('1', self.native(updated))
|
||||
self.assertEqual('1', parse(updated).get('InstallConfigStore').get('Gamescope').get('HDREnabled'))
|
||||
|
||||
def test_users_choice_is_kept(self):
|
||||
text = f'"InstallConfigStore" {{ "Gamescope" {{ "{NATIVE_RESOLUTION}" "0" }} }}'
|
||||
self.assertEqual((text, False), seed_native_resolution(text))
|
||||
|
||||
def test_turning_it_off_after_initialization_survives_restart(self):
|
||||
initialize_native_resolution(self.root)
|
||||
text = self.config.read_text().replace('"1"', '"0"')
|
||||
self.config.write_text(text)
|
||||
self.assertFalse(initialize_native_resolution(self.root))
|
||||
self.assertEqual(text, self.config.read_text())
|
||||
|
||||
def test_ui_scale_and_native_resolution_seed_the_same_config(self):
|
||||
initialize(self.root, 2340, 1080)
|
||||
initialize_native_resolution(self.root)
|
||||
text = self.config.read_text()
|
||||
self.assertEqual('1', self.native(text))
|
||||
self.assertIsNotNone(parse(text).get('InstallConfigStore').get('UI').get('display').get(DISPLAY))
|
||||
|
||||
def test_invalid_config_is_never_rewritten_or_marked_initialized(self):
|
||||
for text in ('"InstallConfigStore" {', '"InstallConfigStore" { "Gamescope" "bad" }'):
|
||||
self.config.write_text(text)
|
||||
with self.assertRaises(ValueError):
|
||||
initialize_native_resolution(self.root)
|
||||
self.assertEqual(text, self.config.read_text())
|
||||
self.assertFalse(self.marker.exists())
|
||||
@@ -0,0 +1,149 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
|
||||
|
||||
FAKE = r"""
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static void path_of(int fd, char *out, size_t size) {
|
||||
char link[32];
|
||||
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
|
||||
long n = syscall(SYS_readlinkat, AT_FDCWD, link, out, size - 1);
|
||||
out[n > 0 ? n : 0] = '\0';
|
||||
}
|
||||
|
||||
int fallocate(int fd, int mode, off_t offset, off_t length) {
|
||||
(void)fd; (void)mode; (void)offset; (void)length;
|
||||
errno = EOPNOTSUPP;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int ftruncate(int fd, off_t length) {
|
||||
char path[4096];
|
||||
path_of(fd, path, sizeof(path));
|
||||
FILE *log = fopen(getenv("FAKE_LOG"), "a");
|
||||
if (log) { fprintf(log, "%s %lld\n", strrchr(path, '/') + 1, (long long)length); fclose(log); }
|
||||
if (strstr(path, "ntfs")) { errno = EPERM; return -1; }
|
||||
return (int)syscall(SYS_ftruncate, fd, length);
|
||||
}
|
||||
"""
|
||||
|
||||
PROGRAM = r"""
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
for (int i = 1; i + 2 < argc; i += 3) {
|
||||
int fd = open(argv[i + 1], O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0) return 2;
|
||||
long long length = atoll(argv[i + 2]);
|
||||
int result;
|
||||
errno = 0;
|
||||
if (strcmp(argv[i], "fallocate") == 0) result = fallocate(fd, 0, 0, length);
|
||||
else result = ftruncate(fd, length);
|
||||
int error = result == 0 ? 0 : errno;
|
||||
struct stat st;
|
||||
fstat(fd, &st);
|
||||
printf("%s %d %s %lld\n", argv[i], result, error ? strerrorname_np(error) : "-", (long long)st.st_size);
|
||||
close(fd);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("cc"), "needs a C compiler")
|
||||
class StoragePreloadTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.dir = Path(tempfile.mkdtemp())
|
||||
build = cls.dir / "build"
|
||||
build.mkdir()
|
||||
(build / "fake.c").write_text(FAKE)
|
||||
(build / "program.c").write_text(PROGRAM)
|
||||
cls.storage = build / "storage.so"
|
||||
cls.fake = build / "fake.so"
|
||||
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.storage), str(PRELOAD / "storage.c"), "-ldl"], check=True)
|
||||
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.fake), str(build / "fake.c")], check=True)
|
||||
cls.steam = cls.dir / "steamrtarm64/steam"
|
||||
cls.steam.parent.mkdir()
|
||||
subprocess.run(["cc", "-O2", "-o", str(cls.steam), str(build / "program.c")], check=True)
|
||||
cls.other = build / "steamwebhelper"
|
||||
shutil.copy(cls.steam, cls.other)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.dir, True)
|
||||
|
||||
def setUp(self):
|
||||
self.lib = Path(tempfile.mkdtemp(dir=self.dir))
|
||||
self.staging = self.lib / "steamapps/downloading/252410"
|
||||
self.staging.mkdir(parents=True)
|
||||
self.log = self.lib / "ftruncate.log"
|
||||
|
||||
def run_program(self, *steps, program=None):
|
||||
env = dict(os.environ, LD_PRELOAD="%s:%s" % (self.storage, self.fake), FAKE_LOG=str(self.log))
|
||||
env.pop("BL_STORAGE_DIAGNOSTICS", None)
|
||||
args = [str(program or self.steam)]
|
||||
for call, path, length in steps:
|
||||
args += [call, str(path), str(length)]
|
||||
result = subprocess.run(args, env=env, capture_output=True, text=True, check=True)
|
||||
return result.stdout.splitlines()
|
||||
|
||||
def reached(self):
|
||||
return self.log.read_text().splitlines() if self.log.exists() else []
|
||||
|
||||
def test_reservation_after_failed_fallocate_is_not_zero_filled(self):
|
||||
data = self.staging / "data.pak"
|
||||
out = self.run_program(("fallocate", data, 1 << 20), ("ftruncate", data, 1 << 20), ("ftruncate", data, 0))
|
||||
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate 0 - 0", "ftruncate 0 - 0"], out)
|
||||
self.assertEqual(["data.pak 0"], self.reached())
|
||||
|
||||
def test_reservation_is_real_until_the_device_has_refused_one(self):
|
||||
data = self.staging / "data.pak"
|
||||
self.assertEqual(["ftruncate 0 - 4096"], self.run_program(("ftruncate", data, 4096)))
|
||||
self.assertEqual(["data.pak 4096"], self.reached())
|
||||
|
||||
def test_refused_extension_is_reported_as_done_and_remembered(self):
|
||||
first, second = self.staging / "ntfs-one", self.staging / "two"
|
||||
out = self.run_program(("ftruncate", first, 8192), ("ftruncate", second, 8192))
|
||||
self.assertEqual(["ftruncate 0 - 0", "ftruncate 0 - 0"], out)
|
||||
self.assertEqual(["ntfs-one 8192"], self.reached())
|
||||
|
||||
def test_refused_shrink_keeps_its_error(self):
|
||||
data = self.staging / "ntfs-data"
|
||||
data.write_bytes(b"x" * 100)
|
||||
self.assertEqual(["ftruncate -1 EPERM 100"], self.run_program(("ftruncate", data, 10)))
|
||||
|
||||
def test_files_outside_staging_are_untouched(self):
|
||||
manifest = self.lib / "steamapps/ntfs-appmanifest.acf"
|
||||
out = self.run_program(("fallocate", self.staging / "a", 4096), ("ftruncate", manifest, 4096))
|
||||
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out)
|
||||
self.assertEqual(["ntfs-appmanifest.acf 4096"], self.reached())
|
||||
|
||||
def test_other_programs_are_untouched(self):
|
||||
data = self.staging / "ntfs-data"
|
||||
out = self.run_program(("fallocate", data, 4096), ("ftruncate", data, 4096), program=self.other)
|
||||
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user