mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
feat: Steam's touch controller as on-screen controls (beta)
This commit is contained in:
1 parent
e070bd72e0
commit
270dfbdcc3
13 files changed
+1848
-6
No files matched your search
@@ -11,6 +11,7 @@
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <climits>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -1216,6 +1217,7 @@ struct DeckHidraw {
|
||||
FakeInputRingHeader *ring = nullptr;
|
||||
size_t mapping_size = 0;
|
||||
uint8_t pending_feature = 0;
|
||||
bool touch = false; // Steam's touch controller (below), not the Deck
|
||||
};
|
||||
|
||||
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
|
||||
@@ -1483,6 +1485,321 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
|
||||
// ---- Steam's touch controller ----
|
||||
//
|
||||
// docs/development/steam-touch-controller.md. Steam builds its touch controller (Steam Input's
|
||||
// Mobile Touch type, the one Steam Link's on-screen controls are) for any HID device with the ids
|
||||
// 0000:11fb. With FAKE_TOUCHCTL_RING set, the client is offered one as /dev/hidraw17 beside the
|
||||
// Deck, its sysfs written by the app (SteamDeckPad.kt). The app draws the controls; this side only
|
||||
// carries the device's traffic through the file FAKE_TOUCHCTL_RING names, which both map:
|
||||
//
|
||||
// - the app writes the 40-byte input report (under a seqlock), whether the device is plugged in,
|
||||
// and the battery level;
|
||||
// - this side writes what the client tells the device: the app and action set it is on (output
|
||||
// report 4), the action set layers added and removed (5, 6), rumble (1, also passed to the app's
|
||||
// vibration like any pad's) and the setting report (3), and how many readers have it open.
|
||||
//
|
||||
// Plugged in and out by the app (while its controls are up and no physical pad is in use), as
|
||||
// Steam Link withdraws its touch controls: an unplugged device ends its open stream, refuses new
|
||||
// opens, and a udev event (libblsession's stand-in monitor, udevmon.c) tells the client's HID
|
||||
// discovery either way. Only the client is offered it; a game reads Steam Input's virtual pad.
|
||||
static constexpr const char *TOUCH_HIDRAW_PATH = "/dev/hidraw17";
|
||||
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
|
||||
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 constexpr const char *TOUCH_SERIAL = "DROIDDECK0001";
|
||||
static constexpr const char *TOUCH_DEVPATH = "/devices/droiddeck/usb2/2-1:1.0/0003:0000:11FB.0002/hidraw/hidraw17";
|
||||
// Generic Desktop / Game Pad: for a vendor id that is not Valve's the client opens only a device
|
||||
// with a gamepad usage. One 40-byte input report, one 64-byte output and one 64-byte feature
|
||||
// report, no report ids. Must match SteamDeckPad.kt.
|
||||
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};
|
||||
|
||||
// The shared file. Must match SteamTouchDevice.kt.
|
||||
struct TouchRingFile {
|
||||
uint32_t magic; // TCH1
|
||||
uint32_t version;
|
||||
uint64_t report_seq; // odd while the app writes report
|
||||
uint8_t report[TOUCH_REPORT_BYTES];
|
||||
uint32_t plugged;
|
||||
uint32_t battery; // percent
|
||||
// Written here.
|
||||
uint64_t state_seq; // odd while being written
|
||||
uint32_t opened;
|
||||
uint32_t appid;
|
||||
uint32_t action_set;
|
||||
uint32_t layer_count;
|
||||
uint32_t layers[8];
|
||||
uint32_t rumble_seq;
|
||||
uint16_t rumble_low;
|
||||
uint16_t rumble_high;
|
||||
uint32_t rumble_ms;
|
||||
uint32_t setting30;
|
||||
uint32_t action_seq;
|
||||
};
|
||||
static constexpr uint32_t TOUCH_RING_MAGIC = 0x31484354;
|
||||
static constexpr size_t TOUCH_RING_SIZE = 256;
|
||||
static_assert(offsetof(TouchRingFile, report) == 16, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, plugged) == 56, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, state_seq) == 64, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, layers) == 88, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, rumble_seq) == 120, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, action_seq) == 136, "TouchRingFile layout");
|
||||
static_assert(sizeof(TouchRingFile) <= TOUCH_RING_SIZE, "TouchRingFile layout");
|
||||
|
||||
__attribute__((visibility("hidden"))) static TouchRingFile *touch_ring() {
|
||||
static TouchRingFile *ring = nullptr;
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
const char *path = getenv("FAKE_TOUCHCTL_RING");
|
||||
if (!path || !*path || !fake_deck_enabled()) return;
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
int fd = my_open(path, O_RDWR | O_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
Logger::log("touch: %s not openable (%s)\n", path, strerror(errno));
|
||||
return;
|
||||
}
|
||||
void *mapping = mmap(nullptr, TOUCH_RING_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
syscall(SYS_close, fd);
|
||||
if (mapping == MAP_FAILED || static_cast<TouchRingFile *>(mapping)->magic != TOUCH_RING_MAGIC) {
|
||||
Logger::log("touch: %s %s\n", path, mapping == MAP_FAILED ? "could not be mapped" : "has no valid header");
|
||||
if (mapping != MAP_FAILED) munmap(mapping, TOUCH_RING_SIZE);
|
||||
return;
|
||||
}
|
||||
ring = static_cast<TouchRingFile *>(mapping);
|
||||
});
|
||||
return ring;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool touch_plugged() {
|
||||
TouchRingFile *ring = touch_ring();
|
||||
return ring && __atomic_load_n(&ring->plugged, __ATOMIC_ACQUIRE) != 0;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
|
||||
return pathname && !strcmp(pathname, TOUCH_HIDRAW_PATH) && process_is_steam_client() && touch_ring();
|
||||
}
|
||||
|
||||
// The state the client gave the device, written under the ring's seqlock.
|
||||
template <typename F>
|
||||
__attribute__((visibility("hidden"))) static void touch_state(F &&write) {
|
||||
TouchRingFile *ring = touch_ring();
|
||||
if (!ring) return;
|
||||
static std::mutex mutex;
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
__atomic_fetch_add(&ring->state_seq, 1, __ATOMIC_ACQ_REL);
|
||||
write(*ring);
|
||||
__atomic_fetch_add(&ring->state_seq, 1, __ATOMIC_ACQ_REL);
|
||||
}
|
||||
|
||||
static inline uint32_t get32(const uint8_t *at) {
|
||||
return static_cast<uint32_t>(at[0]) | static_cast<uint32_t>(at[1]) << 8 | static_cast<uint32_t>(at[2]) << 16 |
|
||||
static_cast<uint32_t>(at[3]) << 24;
|
||||
}
|
||||
|
||||
// An output report from the client (Steam Link's CVirtualController::HandleFeatureReports): the
|
||||
// type, then its fields.
|
||||
__attribute__((visibility("hidden"))) static void touch_output(const uint8_t *data, size_t size) {
|
||||
if (size < 9) return;
|
||||
static std::atomic<unsigned> seen[8];
|
||||
uint8_t type = data[0];
|
||||
unsigned n = type < 8 ? ++seen[type] : 0;
|
||||
if (n && (n & (n - 1)) == 0)
|
||||
Logger::log("touch: output report %u (x%u): app %u, %u\n", type, n, get32(data + 1), get32(data + 5));
|
||||
switch (type) {
|
||||
case 1: { // rumble: low, high, duration
|
||||
uint16_t low = data[1] | data[2] << 8, high = data[3] | data[4] << 8;
|
||||
uint32_t ms = get32(data + 5);
|
||||
touch_state([&](TouchRingFile &r) {
|
||||
r.rumble_low = low;
|
||||
r.rumble_high = high;
|
||||
r.rumble_ms = ms;
|
||||
r.rumble_seq++;
|
||||
});
|
||||
send_vibration(low, high, static_cast<uint16_t>(std::min<uint32_t>(ms, 0xffff)), 0);
|
||||
break;
|
||||
}
|
||||
case 3: // a setting: 0x30 and a value
|
||||
if (data[1] == 0x30) touch_state([&](TouchRingFile &r) { r.setting30 = data[2] | data[3] << 8; });
|
||||
break;
|
||||
case 4: // the app and its action set
|
||||
touch_state([&](TouchRingFile &r) {
|
||||
r.appid = get32(data + 1);
|
||||
r.action_set = get32(data + 5);
|
||||
r.layer_count = 0;
|
||||
r.action_seq++;
|
||||
});
|
||||
break;
|
||||
case 5: // a layer added to the app's action set
|
||||
case 6: // and removed
|
||||
touch_state([&](TouchRingFile &r) {
|
||||
uint32_t layer = get32(data + 5);
|
||||
uint32_t count = std::min<uint32_t>(r.layer_count, 8);
|
||||
uint32_t kept = 0;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
if (r.layers[i] != layer) r.layers[kept++] = r.layers[i];
|
||||
if (type == 5 && kept < 8) r.layers[kept++] = layer;
|
||||
r.layer_count = kept;
|
||||
r.action_seq++;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool read_touch_report(TouchRingFile *ring, uint8_t *out) {
|
||||
for (int attempt = 0; attempt < 8; attempt++) {
|
||||
uint64_t seq = __atomic_load_n(&ring->report_seq, __ATOMIC_ACQUIRE);
|
||||
if (seq & 1) continue;
|
||||
memcpy(out, ring->report, TOUCH_REPORT_BYTES);
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (seq == __atomic_load_n(&ring->report_seq, __ATOMIC_RELAXED)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
__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;
|
||||
TouchRingFile *ring = touch_ring();
|
||||
uint8_t report[TOUCH_REPORT_BYTES] = {};
|
||||
while (touch_plugged()) {
|
||||
read_touch_report(ring, 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);
|
||||
}
|
||||
// Ending the stream is the unplug: the client's read fails and it lets the controller go.
|
||||
syscall(SYS_close, self->peer);
|
||||
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags) {
|
||||
if (!touch_plugged()) {
|
||||
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;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map()[pair[0]] = touch;
|
||||
}
|
||||
touch_state([](TouchRingFile &r) { r.opened++; });
|
||||
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]);
|
||||
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
pthread_detach(thread);
|
||||
Logger::log("touch: %s opened as fd %d\n", TOUCH_HIDRAW_PATH, 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: // HIDIOCGRDESCSIZE
|
||||
*static_cast<int *>(argp) = sizeof(kTouchReportDescriptor);
|
||||
return 0;
|
||||
case 0x02: { // HIDIOCGRDESC
|
||||
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: { // HIDIOCGRAWINFO
|
||||
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); // HIDIOCGRAWNAME
|
||||
case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-2/input0"); // HIDIOCGRAWPHYS
|
||||
case 0x08: return copy_ioctl_string(op, argp, TOUCH_SERIAL); // HIDIOCGRAWUNIQ
|
||||
case 0x06: // HIDIOCSFEATURE: Steam Link's controller ignores these
|
||||
touch.pending_feature = size > 0 ? static_cast<uint8_t *>(argp)[0] : 0;
|
||||
return static_cast<int>(size);
|
||||
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery
|
||||
auto *buf = static_cast<uint8_t *>(argp);
|
||||
uint8_t id = size ? buf[0] : 0;
|
||||
memset(buf, 0, size);
|
||||
if (size) buf[0] = id;
|
||||
if (size > 2) {
|
||||
TouchRingFile *ring = touch_ring();
|
||||
buf[1] = 2;
|
||||
buf[2] = static_cast<uint8_t>(ring && ring->battery ? std::min<uint32_t>(ring->battery, 100) : 100);
|
||||
}
|
||||
return static_cast<int>(std::min<size_t>(size, 18));
|
||||
}
|
||||
default:
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugging in and out: the app flips `plugged`; this tells the client's HID discovery, which
|
||||
// otherwise never looks again (the sandbox gives it no kernel uevents).
|
||||
__attribute__((visibility("hidden"))) static void touch_uevent(const char *action) {
|
||||
using inject_fn = int (*)(const char *, const char *, const char *, const char *, unsigned, unsigned);
|
||||
static auto inject = reinterpret_cast<inject_fn>(dlsym(RTLD_DEFAULT, "bl_udevmon_inject"));
|
||||
int sent = inject ? inject(action, TOUCH_DEVPATH, "hidraw", TOUCH_HIDRAW_PATH, DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR) : -1;
|
||||
Logger::log("touch: %s (udev event to %d monitor%s)\n", action, sent, sent == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void *touch_plug_thread(void *) {
|
||||
bool was = touch_plugged();
|
||||
Logger::log("touch: %s at start\n", was ? "plugged in" : "unplugged");
|
||||
for (;;) {
|
||||
struct timespec interval = {0, 50 * 1000 * 1000};
|
||||
nanosleep(&interval, nullptr);
|
||||
bool now = touch_plugged();
|
||||
if (now == was) continue;
|
||||
was = now;
|
||||
touch_uevent(now ? "add" : "remove");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void start_touch_controller() {
|
||||
config();
|
||||
if (!process_is_steam_client() || !touch_ring()) return;
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, nullptr, touch_plug_thread, nullptr) == 0) pthread_detach(thread);
|
||||
}
|
||||
|
||||
// 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 +2140,7 @@ 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);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1897,6 +2215,7 @@ 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);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1957,6 +2276,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
if (is_touch_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR);
|
||||
return 0;
|
||||
}
|
||||
if (is_deck_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
@@ -2008,7 +2333,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_HIDRAW_MINOR);
|
||||
return ret;
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -2054,6 +2379,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)) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2090,6 +2416,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)) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2211,7 +2538,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 +2865,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) touch_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 +2893,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) touch_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);
|
||||
|
||||
@@ -144,6 +144,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
|
||||
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
|
||||
private var oscStyle by mutableStateOf(SessionPrefs.OSC_STYLE_DROIDDECK)
|
||||
private var steamTouchAvailable by mutableStateOf(false)
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private val sessionClipboard by lazy {
|
||||
@@ -559,6 +563,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
|
||||
oscStyle = if (SessionState.mode == SessionService.MODE_STEAM) oscStyle else null,
|
||||
onOscStyle = { v ->
|
||||
SessionPrefs.setOscStyle(this@SessionActivity, v)
|
||||
readPrefs()
|
||||
updateOnScreenControls()
|
||||
if (v == SessionPrefs.OSC_STYLE_STEAM && com.droiddeck.launcher.input.SteamTouchDevice.current == null)
|
||||
Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
onEditSteamTouch = if (steamTouchAvailable && oscStyle == SessionPrefs.OSC_STYLE_STEAM && onScreenButtonsVisible) ({
|
||||
drawerOpen = false
|
||||
steamTouchControls?.startEditing()
|
||||
}) else null,
|
||||
onSuspendPolicy = { policy ->
|
||||
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
|
||||
suspendPolicy = policy
|
||||
@@ -798,6 +814,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
oscStyle = SessionPrefs.oscStyle(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
}
|
||||
|
||||
@@ -1799,6 +1816,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
/** Steam's touch controls, once the session has made the device (SessionService). */
|
||||
private fun steamTouch(): com.droiddeck.launcher.input.SteamTouchControls? {
|
||||
steamTouchControls?.let { return it }
|
||||
val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return null
|
||||
val root = onScreenControls?.parent as? android.view.ViewGroup ?: return null
|
||||
val view = com.droiddeck.launcher.input.SteamTouchControls(this, device,
|
||||
onMenu = { drawerOpen = true }, onKeyboard = ::togglePcKeyboard)
|
||||
view.visibility = View.GONE
|
||||
// The client reads a controller's config when it connects: after a save, plug the device
|
||||
// out and back in so it takes the new one.
|
||||
view.onSaved = {
|
||||
device.plugged = false
|
||||
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
|
||||
}
|
||||
root.addView(view, root.indexOfChild(onScreenControls) + 1)
|
||||
steamTouchControls = view
|
||||
steamTouchAvailable = true
|
||||
return view
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
@@ -1809,9 +1846,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
// Steam's touch controller stands in for the app's pad, and is plugged into the client only
|
||||
// while it is shown - as Steam Link withdraws its controls when a physical pad is in use.
|
||||
val steam = if (!buttonsOnly && SessionState.mode == SessionService.MODE_STEAM &&
|
||||
SessionPrefs.oscStyle(this) == SessionPrefs.OSC_STYLE_STEAM) steamTouch() else null
|
||||
if (steam == null) {
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
}
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
SessionPrefs.OSC_ALWAYS -> true
|
||||
@@ -1823,6 +1870,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (steam != null) {
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
if (!show) steam.releaseAll()
|
||||
steam.visibility = if (show) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
|
||||
return
|
||||
}
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
|
||||
@@ -60,6 +60,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
Handler(worker.looper).post {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
@@ -85,6 +86,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
target[1] = counts(-z * scale)
|
||||
target[2] = counts(y * scale)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Steam's touch configs, read and written where Steam keeps them.
|
||||
*
|
||||
* A touch config is a Steam Input config (`controller_mobile_touch`) like any other - bindings per
|
||||
* action set, synced to the account, shared as community configs - with one extra key,
|
||||
* `touch_layout`: where each on-screen control sits, per action set, as a hex-encoded
|
||||
* `CVirtualControllerLayouts` protobuf (steammessages_virtualcontroller.proto). Steam Link is
|
||||
* handed both by the client it streams from; here the client tells the device only the app and
|
||||
* action set it is on (SteamTouchDevice), so the config is found on disk: the file the client's
|
||||
* console log says it loaded for the touch controller, else the app's entry in
|
||||
* `configset_controller_mobile_touch.vdf`. With no layout in it, controls go where Steam Link puts
|
||||
* them by default. Saving a layout writes `touch_layout` into the app's autosave config, as the
|
||||
* client does when Steam Link saves one; Steam Cloud picks that file up like any autosave.
|
||||
* (docs/development/steam-touch-controller.md.)
|
||||
*/
|
||||
object SteamTouchConfig {
|
||||
private const val TAG = "SteamTouch"
|
||||
|
||||
// EControllerElementType.
|
||||
const val THUMB = 0
|
||||
const val STEAM = 1
|
||||
const val JOYSTICK_LEFT = 2
|
||||
const val JOYSTICK_LEFT_BUTTON = 3
|
||||
const val JOYSTICK_RIGHT = 4
|
||||
const val JOYSTICK_RIGHT_BUTTON = 5
|
||||
const val DPAD = 6
|
||||
const val A = 7
|
||||
const val B = 8
|
||||
const val X = 9
|
||||
const val Y = 10
|
||||
const val SELECT = 11
|
||||
const val START = 12
|
||||
const val TRIGGER_LEFT = 13
|
||||
const val TRIGGER_RIGHT = 14
|
||||
const val BUMPER_LEFT = 15
|
||||
const val BUMPER_RIGHT = 16
|
||||
const val MACRO_0 = 17 // 17..24 = macros 0-7
|
||||
const val TRACKPAD_CENTER = 25
|
||||
const val TRACKPAD_LEFT = 26
|
||||
const val TRACKPAD_RIGHT = 27
|
||||
const val KEYBOARD = 28
|
||||
const val MACRO_1_FINGER = 30
|
||||
const val MACRO_2_FINGER = 31
|
||||
|
||||
/** A control's place: centre as a fraction of the screen, and its scale. */
|
||||
data class Element(val type: Int, val visible: Boolean, val x: Float, val y: Float, val xScale: Float = 1f, val yScale: Float = 1f)
|
||||
|
||||
data class Layout(val actionSet: Int, val elements: List<Element>, val color: FloatArray?, val version: Int?)
|
||||
|
||||
/** A decoded `touch_layout`; [rest] is every other top-level field, written back untouched. */
|
||||
class Layouts(val layouts: List<Layout>, val rest: ByteArray) {
|
||||
fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id }
|
||||
}
|
||||
|
||||
/** A config as the overlay needs it. */
|
||||
class Config(
|
||||
val file: File?,
|
||||
val text: String?,
|
||||
val layouts: Layouts?,
|
||||
/** Bound controls per preset (action set id - 1). */
|
||||
val available: Map<Int, Set<Int>>,
|
||||
) {
|
||||
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
|
||||
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
|
||||
val base = available[presetOf(actionSet)] ?: available.values.firstOrNull() ?: DEFAULT_SET
|
||||
return layers.fold(base) { set, layer -> set + (available[presetOf(layer)] ?: emptySet()) } + ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
/** Big Picture's own config uses Steam's basic UI bindings: the default set. */
|
||||
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
|
||||
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
|
||||
|
||||
/** The layout entry for the action set the device was told about (the client counts from 1,
|
||||
* 0 for an app with none). */
|
||||
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
|
||||
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
|
||||
|
||||
// ---- Where Steam keeps them ----
|
||||
|
||||
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
|
||||
fun configDir(context: Context): File? {
|
||||
val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs")
|
||||
val account = accountId(context)
|
||||
if (account != null) File(base, "$account/config").takeIf { it.isDirectory }?.let { return it }
|
||||
return base.listFiles()?.filter { it.name.all(Char::isDigit) && File(it, "config").isDirectory }
|
||||
?.maxByOrNull { it.lastModified() }?.let { File(it, "config") }
|
||||
}
|
||||
|
||||
/** The signed-in account (loginusers.vdf's MostRecent) as a 32-bit account id. */
|
||||
private fun accountId(context: Context): Long? = try {
|
||||
val users = KeyValues.parse(File(steamRoot(context), "config/loginusers.vdf").readText())
|
||||
val node = users.child("users")
|
||||
val entries = node?.entries?.mapNotNull { (k, v) -> (v as? KeyValues.Node)?.let { k to it } }.orEmpty()
|
||||
val chosen = entries.firstOrNull { it.second.string("MostRecent") == "1" } ?: entries.firstOrNull()
|
||||
chosen?.first?.toLongOrNull()?.minus(76561197960265728L)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The config the client has for app [appId] on the touch controller. Blocking: reads files. */
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
val file = loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
if (file == null) {
|
||||
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
|
||||
return Config(null, null, null, emptyMap())
|
||||
}
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val kv = KeyValues.parse(text.removePrefix(""))
|
||||
val mappings = kv.child("controller_mappings") ?: kv
|
||||
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
|
||||
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
|
||||
Config(file, text, layouts, availability(mappings))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: ${file.path} unreadable: $e")
|
||||
Config(file, null, null, emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTouchConfig(file: File) = file.isFile &&
|
||||
file.bufferedReader().use { r -> generateSequence { r.readLine() }.take(40).any { it.contains("controller_mobile_touch") } }
|
||||
|
||||
/** From the client's console log: the last config it loaded for [appId] on the touch controller
|
||||
* (`Loaded Config for ... App ID <n>, Controller <i>: <path>`, where controller i reported
|
||||
* type 43). Guest paths come back as the app's own. */
|
||||
private fun loggedConfig(context: Context, appId: Int): File? = try {
|
||||
val log = File(steamRoot(context), "logs/console_log.txt")
|
||||
val tail = RandomAccessFile(log, "r").use { raf ->
|
||||
val start = maxOf(0L, raf.length() - 2_000_000L)
|
||||
raf.seek(start)
|
||||
val bytes = ByteArray((raf.length() - start).toInt())
|
||||
raf.readFully(bytes)
|
||||
String(bytes)
|
||||
}
|
||||
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
|
||||
if (touchIndex == null) null else {
|
||||
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
|
||||
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
|
||||
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The app's entry in configset_controller_mobile_touch.vdf. */
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
}?.takeIf { it.isFile }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
|
||||
|
||||
// ---- What a config binds ----
|
||||
|
||||
/** Per preset, the controls its active sources bind - what Steam Link shows (UpdateElementAvailability). */
|
||||
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
|
||||
val groups = mappings.children("group").associateBy({ it.string("id") ?: "" }) { group ->
|
||||
group.child("inputs")?.entries?.filter { (_, v) -> (v as? KeyValues.Node)?.let(::hasBinding) == true }
|
||||
?.map { it.first }?.toSet().orEmpty()
|
||||
}
|
||||
return mappings.children("preset").associate { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: 0
|
||||
val set = mutableSetOf<Int>()
|
||||
preset.child("group_source_bindings")?.entries?.forEach { (groupId, value) ->
|
||||
val parts = (value as? String)?.split(' ') ?: return@forEach
|
||||
if (parts.getOrNull(1) != "active" || parts.contains("modeshift")) return@forEach
|
||||
val bound = groups[groupId].orEmpty()
|
||||
when (parts[0]) {
|
||||
"button_diamond" -> mapOf("button_a" to A, "button_b" to B, "button_x" to X, "button_y" to Y)
|
||||
.forEach { (input, element) -> if (input in bound) set += element }
|
||||
"dpad" -> set += DPAD
|
||||
"joystick" -> { set += JOYSTICK_LEFT; if ("click" in bound) set += JOYSTICK_LEFT_BUTTON }
|
||||
"right_joystick" -> { set += JOYSTICK_RIGHT; if ("click" in bound) set += JOYSTICK_RIGHT_BUTTON }
|
||||
"left_trigger" -> set += TRIGGER_LEFT
|
||||
"right_trigger" -> set += TRIGGER_RIGHT
|
||||
"left_trackpad" -> set += TRACKPAD_LEFT
|
||||
"right_trackpad" -> set += TRACKPAD_RIGHT
|
||||
"center_trackpad" -> set += TRACKPAD_CENTER
|
||||
"switch" -> bound.forEach { input ->
|
||||
when (input) {
|
||||
"button_escape" -> set += START
|
||||
"button_menu" -> set += SELECT
|
||||
"left_bumper" -> set += BUMPER_LEFT
|
||||
"right_bumper" -> set += BUMPER_RIGHT
|
||||
"button_macro_1finger" -> set += MACRO_1_FINGER
|
||||
"button_macro_2finger" -> set += MACRO_2_FINGER
|
||||
else -> Regex("button_macro(\\d)").matchEntire(input)?.let { set += MACRO_0 + it.groupValues[1].toInt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id to set.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBinding(input: KeyValues.Node): Boolean =
|
||||
input.child("activators")?.entries?.any { (_, activator) ->
|
||||
(activator as? KeyValues.Node)?.child("bindings")?.entries?.any { (k, v) -> k == "binding" && (v as? String)?.isNotBlank() == true } == true
|
||||
} == true
|
||||
|
||||
// ---- Steam Link's defaults ----
|
||||
|
||||
/** Where Steam Link puts a control with no layout (CVirtualController::BInitializeDefaultElement,
|
||||
* on a 1280x720 screen), and the same scheme for the controls its table leaves out, made room
|
||||
* for when a config binds sticks. */
|
||||
fun defaultElement(type: Int, available: Set<Int>): Element? {
|
||||
val leftStick = JOYSTICK_LEFT in available
|
||||
val rightStick = JOYSTICK_RIGHT in available
|
||||
val faceY = if (rightStick) -0.33f else 0f
|
||||
val p: Pair<Float, Float> = when (type) {
|
||||
THUMB -> 75f / 1280 to 75f / 720
|
||||
STEAM -> 636f / 1280 to 75f / 720
|
||||
SELECT -> 516f / 1280 to 75f / 720
|
||||
START -> 756f / 1280 to 75f / 720
|
||||
KEYBOARD -> 1205f / 1280 to 75f / 720
|
||||
DPAD -> if (leftStick) 0.147f to 0.43f else 200f / 1280 to 525f / 720
|
||||
A -> 1083f / 1280 to 607f / 720 + faceY
|
||||
B -> 1163f / 1280 to 527f / 720 + faceY
|
||||
X -> 1003f / 1280 to 527f / 720 + faceY
|
||||
Y -> 1083f / 1280 to 447f / 720 + faceY
|
||||
JOYSTICK_LEFT -> 0.137f to 0.76f
|
||||
JOYSTICK_RIGHT -> 0.85f to 0.76f
|
||||
JOYSTICK_LEFT_BUTTON -> 0.29f to 0.88f
|
||||
JOYSTICK_RIGHT_BUTTON -> 0.71f to 0.88f
|
||||
BUMPER_LEFT -> 0.06f to 0.25f
|
||||
TRIGGER_LEFT -> 0.16f to 0.25f
|
||||
BUMPER_RIGHT -> 0.94f to 0.25f
|
||||
TRIGGER_RIGHT -> 0.84f to 0.25f
|
||||
TRACKPAD_LEFT -> 0.3f to 0.55f
|
||||
TRACKPAD_CENTER -> 0.5f to 0.55f
|
||||
TRACKPAD_RIGHT -> 0.7f to 0.55f
|
||||
MACRO_1_FINGER -> 0.42f to 0.92f
|
||||
MACRO_2_FINGER -> 0.58f to 0.92f
|
||||
in MACRO_0..MACRO_0 + 7 -> (0.3f + 0.057f * (type - MACRO_0)) to 0.22f
|
||||
else -> return null
|
||||
}
|
||||
return Element(type, true, p.first, p.second)
|
||||
}
|
||||
|
||||
/** The controls to draw: the layout's own where it has one, Steam Link's defaults otherwise,
|
||||
* only those the config binds. */
|
||||
fun elementsFor(config: Config, actionSet: Int, layers: List<Int>): List<Element> {
|
||||
val available = config.availableFor(actionSet, layers)
|
||||
val layout = config.layouts?.forActionSet(layoutIdOf(actionSet))
|
||||
val placed = layout?.elements?.associateBy { it.type }.orEmpty()
|
||||
return available.mapNotNull { type ->
|
||||
val own = placed[type]
|
||||
when {
|
||||
own != null && !own.visible -> null
|
||||
own != null -> own
|
||||
else -> defaultElement(type, available)
|
||||
}
|
||||
}.sortedBy { it.type }
|
||||
}
|
||||
|
||||
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
|
||||
|
||||
// ---- Saving ----
|
||||
|
||||
/**
|
||||
* Saves [elements] as the layout of [actionSet] in app [appId]'s touch config: into the app's
|
||||
* autosave config (`<appid>/controller_mobile_touch.vdf`, made from the config in use when the
|
||||
* app has none) and selected for the app in the configset, as the client saves a layout Steam
|
||||
* Link sends it. Returns the file written, or null.
|
||||
*/
|
||||
fun saveLayout(context: Context, appId: Int, config: Config, actionSet: Int, elements: List<Element>): File? {
|
||||
val dir = configDir(context) ?: return null
|
||||
val source = config.text ?: return null
|
||||
val target = File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
val id = layoutIdOf(actionSet)
|
||||
val old = config.layouts
|
||||
val keep = old?.layouts?.filter { it.actionSet != id }.orEmpty()
|
||||
val color = old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f)
|
||||
val layouts = Layouts(keep + Layout(id, elements, color, old?.forActionSet(id)?.version),
|
||||
old?.rest ?: byteArrayOf(0x10, 0x02)) // input_mode = controller
|
||||
val hex = bytesToHex(encodeLayouts(layouts))
|
||||
val line = "\t\"touch_layout\"\t\t\"$hex\""
|
||||
val existing = Regex("""\n\s*"touch_layout"\s+"[0-9a-fA-F]*"""")
|
||||
val text = if (existing.containsMatchIn(source)) source.replace(existing, "\n$line")
|
||||
else source.replaceFirst(Regex("""("controller_type"\s+"controller_mobile_touch"[^\n]*)"""), "$1\n$line")
|
||||
if (!text.contains("touch_layout")) return null
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
Log.i(TAG, "steam touch: saved layout ${id} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
/** Points the app's configset entry at its autosave config. */
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
|
||||
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
|
||||
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
|
||||
}
|
||||
|
||||
// ---- touch_layout: CVirtualControllerLayouts ----
|
||||
|
||||
fun decodeLayouts(bytes: ByteArray): Layouts {
|
||||
val layouts = mutableListOf<Layout>()
|
||||
val rest = ByteArrayOutputStream()
|
||||
Proto(bytes).forEachField { field, wire, start, end, reader ->
|
||||
if (field == 1 && wire == 2) layouts += decodeLayout(reader.bytes()) else rest.write(bytes, start, end - start)
|
||||
}
|
||||
return Layouts(layouts, rest.toByteArray())
|
||||
}
|
||||
|
||||
private fun decodeLayout(bytes: ByteArray): Layout {
|
||||
var actionSet = 0
|
||||
var version: Int? = null
|
||||
var color: FloatArray? = null
|
||||
val elements = mutableListOf<Element>()
|
||||
Proto(bytes).forEachField { field, wire, _, _, r ->
|
||||
when {
|
||||
field == 1 && wire == 0 -> version = r.varint().toInt()
|
||||
field == 2 && wire == 0 -> actionSet = r.varint().toInt()
|
||||
field == 4 && wire == 2 -> elements += decodeElement(r.bytes())
|
||||
field == 5 && wire == 2 -> color = decodeColor(r.bytes())
|
||||
else -> r.skip(wire)
|
||||
}
|
||||
}
|
||||
return Layout(actionSet, elements, color, version)
|
||||
}
|
||||
|
||||
private fun decodeElement(bytes: ByteArray): Element {
|
||||
var type = -1
|
||||
var visible = false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var sx = 1f
|
||||
var sy = 1f
|
||||
Proto(bytes).forEachField { field, wire, _, _, r ->
|
||||
when {
|
||||
field == 1 && wire == 0 -> type = r.varint().toInt()
|
||||
field == 2 && wire == 0 -> visible = r.varint() != 0L
|
||||
field == 3 && wire == 5 -> x = r.float()
|
||||
field == 4 && wire == 5 -> y = r.float()
|
||||
field == 5 && wire == 5 -> sx = r.float()
|
||||
field == 6 && wire == 5 -> sy = r.float()
|
||||
else -> r.skip(wire)
|
||||
}
|
||||
}
|
||||
return Element(type, visible, x, y, sx, sy)
|
||||
}
|
||||
|
||||
private fun decodeColor(bytes: ByteArray): FloatArray {
|
||||
val c = floatArrayOf(1f, 1f, 1f, 1f)
|
||||
Proto(bytes).forEachField { field, wire, _, _, r ->
|
||||
if (field in 1..4 && wire == 5) c[field - 1] = r.float() else r.skip(wire)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
fun encodeLayouts(layouts: Layouts): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
layouts.layouts.forEach { layout ->
|
||||
val l = ByteArrayOutputStream()
|
||||
layout.version?.let { tag(l, 1, 0); varint(l, it.toLong()) }
|
||||
tag(l, 2, 0); varint(l, layout.actionSet.toLong())
|
||||
layout.elements.forEach { e ->
|
||||
val m = ByteArrayOutputStream()
|
||||
tag(m, 1, 0); varint(m, e.type.toLong())
|
||||
tag(m, 2, 0); varint(m, if (e.visible) 1 else 0)
|
||||
tag(m, 3, 5); fixed(m, e.x)
|
||||
tag(m, 4, 5); fixed(m, e.y)
|
||||
tag(m, 5, 5); fixed(m, e.xScale)
|
||||
tag(m, 6, 5); fixed(m, e.yScale)
|
||||
tag(l, 4, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
layout.color?.let { c ->
|
||||
val m = ByteArrayOutputStream()
|
||||
for (i in 0 until 4) { tag(m, i + 1, 5); fixed(m, c[i]) }
|
||||
tag(l, 5, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
tag(out, 1, 2); bytes(out, l.toByteArray())
|
||||
}
|
||||
out.write(layouts.rest)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun tag(out: ByteArrayOutputStream, field: Int, wire: Int) = varint(out, (field shl 3 or wire).toLong())
|
||||
private fun varint(out: ByteArrayOutputStream, value: Long) {
|
||||
var v = value
|
||||
while (true) {
|
||||
if (v and 0x7fL.inv() == 0L) { out.write(v.toInt()); return }
|
||||
out.write((v and 0x7f or 0x80).toInt())
|
||||
v = v ushr 7
|
||||
}
|
||||
}
|
||||
private fun fixed(out: ByteArrayOutputStream, f: Float) {
|
||||
val bits = java.lang.Float.floatToIntBits(f)
|
||||
for (i in 0 until 4) out.write(bits ushr (8 * i) and 0xff)
|
||||
}
|
||||
private fun bytes(out: ByteArrayOutputStream, b: ByteArray) { varint(out, b.size.toLong()); out.write(b) }
|
||||
|
||||
private class Proto(val data: ByteArray) {
|
||||
var pos = 0
|
||||
fun varint(): Long {
|
||||
var shift = 0
|
||||
var result = 0L
|
||||
while (pos < data.size) {
|
||||
val b = data[pos++].toInt() and 0xff
|
||||
result = result or ((b and 0x7f).toLong() shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun float(): Float {
|
||||
var bits = 0
|
||||
for (i in 0 until 4) bits = bits or ((data[pos + i].toInt() and 0xff) shl (8 * i))
|
||||
pos += 4
|
||||
return java.lang.Float.intBitsToFloat(bits)
|
||||
}
|
||||
fun bytes(): ByteArray {
|
||||
val n = varint().toInt()
|
||||
return data.copyOfRange(pos, pos + n).also { pos += n }
|
||||
}
|
||||
fun skip(wire: Int) {
|
||||
when (wire) {
|
||||
0 -> varint()
|
||||
1 -> pos += 8
|
||||
2 -> pos += varint().toInt()
|
||||
5 -> pos += 4
|
||||
else -> pos = data.size
|
||||
}
|
||||
}
|
||||
inline fun forEachField(block: (field: Int, wire: Int, start: Int, end: Int, reader: Proto) -> Unit) {
|
||||
while (pos < data.size) {
|
||||
val start = pos
|
||||
val key = varint().toInt()
|
||||
val field = key ushr 3
|
||||
val wire = key and 7
|
||||
val mark = pos
|
||||
// Each handler reads or skips the field; the raw span is measured from a copy.
|
||||
val probe = Proto(data).also { it.pos = mark; it.skip(wire) }
|
||||
block(field, wire, start, probe.pos, this)
|
||||
pos = probe.pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
|
||||
fun bytesToHex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Valve's KeyValues text format, as far as controller configs use it. */
|
||||
class KeyValues private constructor() {
|
||||
class Node(val entries: MutableList<Pair<String, Any>> = mutableListOf()) {
|
||||
fun child(key: String): Node? = entries.firstOrNull { it.first.equals(key, true) && it.second is Node }?.second as Node?
|
||||
fun children(key: String): List<Node> = entries.filter { it.first.equals(key, true) && it.second is Node }.map { it.second as Node }
|
||||
fun string(key: String): String? = entries.firstOrNull { it.first.equals(key, true) && it.second is String }?.second as String?
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(text: String): Node {
|
||||
val tokens = tokenize(text)
|
||||
var i = 0
|
||||
fun node(): Node {
|
||||
val n = Node()
|
||||
while (i < tokens.size) {
|
||||
val t = tokens[i++]
|
||||
if (t == "}" ) return n
|
||||
if (t == "{") continue
|
||||
if (i >= tokens.size) break
|
||||
val next = tokens[i]
|
||||
if (next == "{") { i++; n.entries += t to node() } else { i++; n.entries += t to next }
|
||||
}
|
||||
return n
|
||||
}
|
||||
return node()
|
||||
}
|
||||
|
||||
private fun tokenize(text: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += c.toString(); i++ }
|
||||
c == '"' -> {
|
||||
val sb = StringBuilder()
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') {
|
||||
if (text[i] == '\\' && i + 1 < text.length) {
|
||||
i++
|
||||
sb.append(when (text[i]) { 'n' -> '\n'; 't' -> '\t'; else -> text[i] })
|
||||
} else sb.append(text[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
out += sb.toString()
|
||||
}
|
||||
c == '[' -> { while (i < text.length && text[i] != ']') i++; i++ }
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += text.substring(start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import com.droiddeck.launcher.input.SteamTouchConfig.Element
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Steam's touch controls, drawn by the app.
|
||||
*
|
||||
* The layout is Steam's: the game's touch config for the action set the client says is active
|
||||
* (SteamTouchDevice.State, SteamTouchConfig), placed where the config's layout puts each control or
|
||||
* where Steam Link would by default, and only the controls the config binds. Touches become the
|
||||
* touch controller's input report; the client does the rest - bindings, action sets, the virtual
|
||||
* pad the game reads. [startEditing] moves, resizes and hides controls and saves the layout into
|
||||
* the game's touch config, as Steam Link does.
|
||||
*/
|
||||
@SuppressLint("ViewConstructor")
|
||||
class SteamTouchControls(
|
||||
context: Context,
|
||||
private val device: SteamTouchDevice,
|
||||
private val onMenu: () -> Unit,
|
||||
private val onKeyboard: () -> Unit,
|
||||
) : View(context) {
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val loader = Executors.newSingleThreadExecutor()
|
||||
|
||||
private var config = SteamTouchConfig.Config(null, null, null, emptyMap())
|
||||
private var configApp = -1
|
||||
private var appId = 0
|
||||
private var actionSet = 0
|
||||
private var layers: List<Int> = emptyList()
|
||||
private var actionSeq = -1
|
||||
private var elements: List<Element> = emptyList()
|
||||
private var tint = Color.WHITE
|
||||
private var alphaScale = 0.45f
|
||||
|
||||
// Touch state.
|
||||
private class Finger(val element: Element?, var x: Float, var y: Float, val toolbar: Int = -1)
|
||||
private val fingers = HashMap<Int, Finger>()
|
||||
|
||||
// Editing.
|
||||
private var editing = false
|
||||
private var editElements: MutableList<Element> = mutableListOf()
|
||||
private var hidden: MutableList<Element> = mutableListOf()
|
||||
private var selected = -1
|
||||
private var dragOffset = 0f to 0f
|
||||
private var pinchStart = 0f
|
||||
private var pinchScale = 1f
|
||||
private var saving = false
|
||||
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; isFakeBoldText = true }
|
||||
|
||||
private val poll = object : Runnable {
|
||||
override fun run() {
|
||||
refreshState()
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
handler.removeCallbacks(poll)
|
||||
releaseAll()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
/** Follows what the client tells the device: a new app loads its config, a new action set or
|
||||
* layer redraws. */
|
||||
private fun refreshState() {
|
||||
val state = device.state()
|
||||
val app = if (state.appId == 0) BIG_PICTURE else state.appId
|
||||
if (app != configApp) {
|
||||
configApp = app
|
||||
reload(app)
|
||||
}
|
||||
if (state.actionSeq != actionSeq) {
|
||||
actionSeq = state.actionSeq
|
||||
actionSet = state.actionSet
|
||||
layers = state.layers
|
||||
appId = app
|
||||
rebuild()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload(app: Int) {
|
||||
loader.execute {
|
||||
val loaded = SteamTouchConfig.load(context, app)
|
||||
handler.post {
|
||||
if (app != configApp) return@post
|
||||
config = loaded
|
||||
appId = app
|
||||
rebuild()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads the config for the current app (after a save). */
|
||||
fun reloadConfig() {
|
||||
configApp = -1
|
||||
refreshState()
|
||||
}
|
||||
|
||||
private fun rebuild() {
|
||||
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
|
||||
val color = SteamTouchConfig.layoutColor(config, actionSet)
|
||||
tint = if (color != null) Color.rgb((color[0] * 255).toInt(), (color[1] * 255).toInt(), (color[2] * 255).toInt()) else Color.WHITE
|
||||
alphaScale = color?.get(3)?.coerceIn(0.15f, 1f) ?: 0.45f
|
||||
if (!editing) {
|
||||
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
|
||||
releaseAll()
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// ---- Geometry ----
|
||||
|
||||
private fun unit() = min(width / 1280f, height / 720f)
|
||||
|
||||
private fun radius(e: Element): Float = baseRadius(e.type) * e.xScale * unit()
|
||||
|
||||
private fun baseRadius(type: Int) = when (type) {
|
||||
SteamTouchConfig.DPAD -> 100f
|
||||
SteamTouchConfig.JOYSTICK_LEFT, SteamTouchConfig.JOYSTICK_RIGHT -> 105f
|
||||
SteamTouchConfig.TRACKPAD_LEFT, SteamTouchConfig.TRACKPAD_RIGHT, SteamTouchConfig.TRACKPAD_CENTER -> 115f
|
||||
SteamTouchConfig.A, SteamTouchConfig.B, SteamTouchConfig.X, SteamTouchConfig.Y -> 40f
|
||||
SteamTouchConfig.TRIGGER_LEFT, SteamTouchConfig.TRIGGER_RIGHT, SteamTouchConfig.BUMPER_LEFT, SteamTouchConfig.BUMPER_RIGHT -> 38f
|
||||
else -> 32f
|
||||
}
|
||||
|
||||
private fun cx(e: Element) = e.x * width
|
||||
private fun cy(e: Element) = e.y * height
|
||||
|
||||
private fun hit(list: List<Element>, x: Float, y: Float): Element? =
|
||||
list.filter { hypot(x - cx(it), y - cy(it)) <= radius(it) * 1.15f }
|
||||
.minByOrNull { hypot(x - cx(it), y - cy(it)) / radius(it) }
|
||||
|
||||
// ---- Drawing ----
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val shown = if (editing) editElements else elements
|
||||
shown.forEach { draw(canvas, it, editing && editElements.indexOf(it) == selected) }
|
||||
if (editing) drawToolbar(canvas)
|
||||
}
|
||||
|
||||
private fun pressedTypes(): Set<Int> = fingers.values.mapNotNull { it.element?.type }.toSet()
|
||||
|
||||
private fun draw(canvas: Canvas, e: Element, isSelected: Boolean) {
|
||||
val x = cx(e)
|
||||
val y = cy(e)
|
||||
val r = radius(e)
|
||||
val pressed = !editing && e.type in pressedTypes()
|
||||
val baseAlpha = (255 * alphaScale).toInt()
|
||||
fill.color = Color.argb(if (pressed) min(255, baseAlpha + 90) else baseAlpha / 2, 20, 24, 30)
|
||||
stroke.color = if (isSelected) Color.rgb(102, 192, 244) else Color.argb(min(255, baseAlpha + 60), Color.red(tint), Color.green(tint), Color.blue(tint))
|
||||
stroke.strokeWidth = if (isSelected) 4f * unit() + 2f else 2f * unit() + 1f
|
||||
text.color = Color.argb(min(255, baseAlpha + 110), 255, 255, 255)
|
||||
text.textSize = r * 0.7f
|
||||
when (e.type) {
|
||||
SteamTouchConfig.DPAD -> drawDpad(canvas, e, x, y, r)
|
||||
SteamTouchConfig.JOYSTICK_LEFT, SteamTouchConfig.JOYSTICK_RIGHT -> {
|
||||
canvas.drawCircle(x, y, r, fill)
|
||||
canvas.drawCircle(x, y, r, stroke)
|
||||
val f = fingers.values.firstOrNull { it.element?.type == e.type }
|
||||
var kx = x
|
||||
var ky = y
|
||||
if (f != null && !editing) {
|
||||
val d = hypot(f.x - x, f.y - y)
|
||||
val k = if (d > r) r / d else 1f
|
||||
kx = x + (f.x - x) * k
|
||||
ky = y + (f.y - y) * k
|
||||
}
|
||||
fill.color = Color.argb(min(255, baseAlpha + 40), 60, 66, 76)
|
||||
canvas.drawCircle(kx, ky, r * 0.42f, fill)
|
||||
canvas.drawCircle(kx, ky, r * 0.42f, stroke)
|
||||
}
|
||||
SteamTouchConfig.TRACKPAD_LEFT, SteamTouchConfig.TRACKPAD_RIGHT, SteamTouchConfig.TRACKPAD_CENTER -> {
|
||||
val rect = RectF(x - r, y - r, x + r, y + r)
|
||||
canvas.drawRoundRect(rect, r * 0.25f, r * 0.25f, fill)
|
||||
canvas.drawRoundRect(rect, r * 0.25f, r * 0.25f, stroke)
|
||||
}
|
||||
else -> {
|
||||
canvas.drawCircle(x, y, r, fill)
|
||||
canvas.drawCircle(x, y, r, stroke)
|
||||
val label = label(e.type)
|
||||
LABEL_COLORS[e.type]?.let { text.color = it }
|
||||
canvas.drawText(label, x, y - (text.descent() + text.ascent()) / 2, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawDpad(canvas: Canvas, e: Element, x: Float, y: Float, r: Float) {
|
||||
val arm = r * 0.36f
|
||||
val dirs = if (editing) 0 else dpadBits(e)
|
||||
val arms = listOf(DPAD_UP to (0f to -1f), DPAD_DOWN to (0f to 1f), DPAD_LEFT to (-1f to 0f), DPAD_RIGHT to (1f to 0f))
|
||||
for ((bit, d) in arms) {
|
||||
val (dx, dy) = d
|
||||
val cxArm = x + dx * r * 0.6f
|
||||
val cyArm = y + dy * r * 0.6f
|
||||
val rect = RectF(cxArm - if (dx == 0f) arm else r * 0.4f, cyArm - if (dy == 0f) arm else r * 0.4f,
|
||||
cxArm + if (dx == 0f) arm else r * 0.4f, cyArm + if (dy == 0f) arm else r * 0.4f)
|
||||
val old = fill.color
|
||||
if (dirs and bit != 0L) fill.color = Color.argb(200, 20, 24, 30)
|
||||
canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, fill)
|
||||
canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, stroke)
|
||||
fill.color = old
|
||||
val tri = Path()
|
||||
val s = arm * 0.5f
|
||||
val tx = cxArm + dx * r * 0.08f
|
||||
val ty = cyArm + dy * r * 0.08f
|
||||
if (dx == 0f) { tri.moveTo(tx, ty + dy * s); tri.lineTo(tx - s, ty - dy * s); tri.lineTo(tx + s, ty - dy * s) }
|
||||
else { tri.moveTo(tx + dx * s, ty); tri.lineTo(tx - dx * s, ty - s); tri.lineTo(tx - dx * s, ty + s) }
|
||||
tri.close()
|
||||
val tp = Paint(text).apply { style = Paint.Style.FILL }
|
||||
canvas.drawPath(tri, tp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun label(type: Int) = when (type) {
|
||||
SteamTouchConfig.A -> "A"
|
||||
SteamTouchConfig.B -> "B"
|
||||
SteamTouchConfig.X -> "X"
|
||||
SteamTouchConfig.Y -> "Y"
|
||||
SteamTouchConfig.STEAM -> "STEAM"
|
||||
SteamTouchConfig.THUMB -> "…"
|
||||
SteamTouchConfig.KEYBOARD -> "⌨"
|
||||
SteamTouchConfig.SELECT -> "⧉"
|
||||
SteamTouchConfig.START -> "☰"
|
||||
SteamTouchConfig.BUMPER_LEFT -> "LB"
|
||||
SteamTouchConfig.BUMPER_RIGHT -> "RB"
|
||||
SteamTouchConfig.TRIGGER_LEFT -> "LT"
|
||||
SteamTouchConfig.TRIGGER_RIGHT -> "RT"
|
||||
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> "L3"
|
||||
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> "R3"
|
||||
SteamTouchConfig.MACRO_1_FINGER -> "1F"
|
||||
SteamTouchConfig.MACRO_2_FINGER -> "2F"
|
||||
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> "M${type - SteamTouchConfig.MACRO_0 + 1}"
|
||||
else -> "?"
|
||||
}.let { if (it == "STEAM") "S" else it }
|
||||
|
||||
// ---- The report ----
|
||||
|
||||
private fun dpadBits(e: Element): Long {
|
||||
var bits = 0L
|
||||
fingers.values.filter { it.element?.type == SteamTouchConfig.DPAD }.forEach { f ->
|
||||
val dx = f.x - cx(e)
|
||||
val dy = f.y - cy(e)
|
||||
if (hypot(dx, dy) < radius(e) * 0.22f) return@forEach
|
||||
val angle = Math.toDegrees(atan2(-dy, dx).toDouble()).let { if (it < 0) it + 360 else it }
|
||||
// Eight sectors: each direction covers 67.5°, diagonals press two.
|
||||
if (angle < 67.5 || angle > 292.5) bits = bits or DPAD_RIGHT
|
||||
if (angle in 22.5..157.5) bits = bits or DPAD_UP
|
||||
if (angle in 112.5..247.5) bits = bits or DPAD_LEFT
|
||||
if (angle in 202.5..337.5) bits = bits or DPAD_DOWN
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
var buttons = 0L
|
||||
val sticks = ShortArray(4)
|
||||
val pads = ShortArray(6)
|
||||
for (f in fingers.values) {
|
||||
val e = f.element ?: continue
|
||||
val r = radius(e)
|
||||
when (e.type) {
|
||||
SteamTouchConfig.DPAD -> buttons = buttons or dpadBits(e)
|
||||
SteamTouchConfig.JOYSTICK_LEFT, SteamTouchConfig.JOYSTICK_RIGHT -> {
|
||||
val right = e.type == SteamTouchConfig.JOYSTICK_RIGHT
|
||||
var dx = (f.x - cx(e)) / r
|
||||
var dy = (f.y - cy(e)) / r
|
||||
val d = hypot(dx, dy)
|
||||
if (d > 1f) { dx /= d; dy /= d }
|
||||
sticks[if (right) 2 else 0] = (dx * 32767).toInt().coerceIn(-32767, 32767).toShort()
|
||||
sticks[if (right) 3 else 1] = (-dy * 32767).toInt().coerceIn(-32767, 32767).toShort()
|
||||
buttons = buttons or if (right) STICK_RIGHT_TOUCHED else STICK_LEFT_TOUCHED
|
||||
}
|
||||
SteamTouchConfig.TRACKPAD_CENTER, SteamTouchConfig.TRACKPAD_LEFT, SteamTouchConfig.TRACKPAD_RIGHT -> {
|
||||
val index = e.type - SteamTouchConfig.TRACKPAD_CENTER // centre, left, right
|
||||
val nx = ((f.x - (cx(e) - r)) / (2 * r)).coerceIn(0f, 1f)
|
||||
val ny = ((f.y - (cy(e) - r)) / (2 * r)).coerceIn(0f, 1f)
|
||||
pads[2 * index] = (((nx * 65535).toInt().coerceIn(0, 65535)) xor 0x8000).toShort()
|
||||
pads[2 * index + 1] = (((ny * 65535).toInt().coerceIn(0, 65535)) xor 0x7fff).toShort()
|
||||
buttons = buttons or TRACKPAD_TOUCHED[index]
|
||||
}
|
||||
else -> BUTTON_BITS[e.type]?.let { buttons = buttons or it }
|
||||
}
|
||||
}
|
||||
device.setControls(buttons, sticks, pads)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
fingers.clear()
|
||||
device.setControls(0L, ShortArray(4), ShortArray(6))
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// ---- Touch ----
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (editing) return onEditTouch(event)
|
||||
val index = event.actionIndex
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
val e = hit(elements, x, y) ?: return event.actionMasked == MotionEvent.ACTION_POINTER_DOWN
|
||||
fingers[event.getPointerId(index)] = Finger(e, x, y)
|
||||
requestUnbufferedDispatch(event)
|
||||
publish()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
for (i in 0 until event.pointerCount) {
|
||||
val f = fingers[event.getPointerId(i)] ?: continue
|
||||
f.x = event.getX(i)
|
||||
f.y = event.getY(i)
|
||||
// A finger on a button can slide onto another, as on Steam Link.
|
||||
val e = f.element ?: continue
|
||||
if (BUTTON_BITS.containsKey(e.type)) {
|
||||
val over = hit(elements, f.x, f.y)
|
||||
if (over != null && over !== e && BUTTON_BITS.containsKey(over.type))
|
||||
fingers[event.getPointerId(i)] = Finger(over, f.x, f.y)
|
||||
}
|
||||
}
|
||||
publish()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val id = event.getPointerId(index)
|
||||
val f = fingers.remove(id)
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) fingers.clear()
|
||||
val e = f?.element
|
||||
if (event.actionMasked != MotionEvent.ACTION_CANCEL && e != null && hypot(event.getX(index) - cx(e), event.getY(index) - cy(e)) <= radius(e) * 1.3f) {
|
||||
when (e.type) {
|
||||
SteamTouchConfig.THUMB -> onMenu()
|
||||
SteamTouchConfig.KEYBOARD -> onKeyboard()
|
||||
}
|
||||
}
|
||||
publish()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Editing ----
|
||||
|
||||
val isEditing get() = editing
|
||||
|
||||
/** Moves, resizes and hides the current action set's controls; Save writes them to the game's
|
||||
* touch config. */
|
||||
fun startEditing() {
|
||||
releaseAll()
|
||||
editing = true
|
||||
val available = config.availableFor(actionSet, layers)
|
||||
editElements = available.mapNotNull { type ->
|
||||
elements.firstOrNull { it.type == type } ?: SteamTouchConfig.defaultElement(type, available)
|
||||
}.sortedBy { it.type }.toMutableList()
|
||||
hidden = available.filter { type -> editElements.none { it.type == type } }
|
||||
.mapNotNull { SteamTouchConfig.defaultElement(it, available)?.copy(visible = false) }.toMutableList()
|
||||
selected = -1
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun stopEditing() {
|
||||
editing = false
|
||||
selected = -1
|
||||
rebuild()
|
||||
}
|
||||
|
||||
private val toolbarLabels = listOf("−", "+", "Hide", "Reset", "Cancel", "Save")
|
||||
|
||||
private fun toolbarRects(): List<RectF> {
|
||||
val u = max(unit(), 0.6f)
|
||||
val w = 120f * u
|
||||
val h = 56f * u
|
||||
val gap = 12f * u
|
||||
val total = toolbarLabels.size * w + (toolbarLabels.size - 1) * gap
|
||||
val left = (width - total) / 2
|
||||
val top = height * 0.16f
|
||||
return toolbarLabels.indices.map { i -> RectF(left + i * (w + gap), top, left + i * (w + gap) + w, top + h) }
|
||||
}
|
||||
|
||||
private fun drawToolbar(canvas: Canvas) {
|
||||
val bar = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
val rects = toolbarRects()
|
||||
val title = Paint(text).apply { color = Color.WHITE; textSize = 26f * max(unit(), 0.6f) }
|
||||
canvas.drawText(if (saving) "Saving…" else "Editing touch layout for ${if (appId == BIG_PICTURE) "Steam" else "app $appId"} - drag, pinch or use −/+",
|
||||
width / 2f, rects[0].top - 18f * unit(), title)
|
||||
rects.forEachIndexed { i, r ->
|
||||
val enabled = i > 2 || selected >= 0
|
||||
bar.color = when (i) {
|
||||
5 -> Color.rgb(26, 159, 255)
|
||||
else -> Color.argb(if (enabled) 230 else 120, 50, 56, 66)
|
||||
}
|
||||
canvas.drawRoundRect(r, 10f, 10f, bar)
|
||||
val t = Paint(text).apply { color = Color.WHITE; textSize = r.height() * 0.42f }
|
||||
canvas.drawText(toolbarLabels[i], r.centerX(), r.centerY() - (t.descent() + t.ascent()) / 2, t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEditTouch(event: MotionEvent): Boolean {
|
||||
val index = event.actionIndex
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
val tool = toolbarRects().indexOfFirst { it.contains(x, y) }
|
||||
if (tool >= 0) {
|
||||
toolbar(tool)
|
||||
return true
|
||||
}
|
||||
val e = hit(editElements, x, y)
|
||||
selected = if (e != null) editElements.indexOf(e) else -1
|
||||
if (e != null) dragOffset = (x - cx(e)) to (y - cy(e))
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_DOWN -> if (selected >= 0 && event.pointerCount == 2) {
|
||||
pinchStart = hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
|
||||
pinchScale = editElements[selected].xScale
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> if (selected >= 0) {
|
||||
val e = editElements[selected]
|
||||
if (event.pointerCount >= 2 && pinchStart > 0f) {
|
||||
val d = hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1))
|
||||
val s = (pinchScale * d / pinchStart).coerceIn(0.4f, 3f)
|
||||
editElements[selected] = e.copy(xScale = s, yScale = s)
|
||||
} else if (event.pointerCount == 1) {
|
||||
val nx = ((event.getX(0) - dragOffset.first) / width).coerceIn(0f, 1f)
|
||||
val ny = ((event.getY(0) - dragOffset.second) / height).coerceIn(0f, 1f)
|
||||
editElements[selected] = e.copy(x = nx, y = ny)
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
MotionEvent.ACTION_POINTER_UP -> pinchStart = 0f
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> pinchStart = 0f
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun toolbar(tool: Int) {
|
||||
if (saving) return
|
||||
when (tool) {
|
||||
0, 1 -> if (selected >= 0) {
|
||||
val e = editElements[selected]
|
||||
val s = (e.xScale * if (tool == 0) 0.85f else 1.18f).coerceIn(0.4f, 3f)
|
||||
editElements[selected] = e.copy(xScale = s, yScale = s)
|
||||
}
|
||||
2 -> if (selected >= 0) {
|
||||
hidden += editElements.removeAt(selected).copy(visible = false)
|
||||
selected = -1
|
||||
}
|
||||
3 -> {
|
||||
val available = config.availableFor(actionSet, layers)
|
||||
editElements = available.mapNotNull { SteamTouchConfig.defaultElement(it, available) }.sortedBy { it.type }.toMutableList()
|
||||
hidden.clear()
|
||||
selected = -1
|
||||
}
|
||||
4 -> stopEditing()
|
||||
5 -> save()
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
saving = true
|
||||
invalidate()
|
||||
val app = appId
|
||||
val set = actionSet
|
||||
val cfg = config
|
||||
val layout = editElements.map { it.copy(visible = true) } + hidden.filter { h -> editElements.none { it.type == h.type } }
|
||||
loader.execute {
|
||||
val file = try {
|
||||
SteamTouchConfig.saveLayout(context, app, cfg, set, layout)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: saving failed: $e")
|
||||
null
|
||||
}
|
||||
handler.post {
|
||||
saving = false
|
||||
if (file == null) {
|
||||
Toast.makeText(context, "Could not save the touch layout", Toast.LENGTH_SHORT).show()
|
||||
invalidate()
|
||||
return@post
|
||||
}
|
||||
Toast.makeText(context, "Touch layout saved to Steam", Toast.LENGTH_SHORT).show()
|
||||
onSaved()
|
||||
stopEditing()
|
||||
reloadConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called after a save: the client reads the config again when the controller reconnects. */
|
||||
var onSaved: () -> Unit = {}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamTouch"
|
||||
private const val POLL_MS = 150L
|
||||
const val BIG_PICTURE = 769
|
||||
|
||||
private const val DPAD_UP = 1L shl 8
|
||||
private const val DPAD_RIGHT = 1L shl 9
|
||||
private const val DPAD_LEFT = 1L shl 10
|
||||
private const val DPAD_DOWN = 1L shl 11
|
||||
private const val STICK_LEFT_TOUCHED = 1L shl 46
|
||||
private const val STICK_RIGHT_TOUCHED = 1L shl 47
|
||||
private val TRACKPAD_TOUCHED = longArrayOf(1L shl 27, 1L shl 19, 1L shl 20)
|
||||
|
||||
/** Steam Link's button bits (CVirtualController's table, by element type). */
|
||||
private val BUTTON_BITS: Map<Int, Long> = buildMap {
|
||||
put(SteamTouchConfig.STEAM, 1L shl 13)
|
||||
put(SteamTouchConfig.JOYSTICK_LEFT_BUTTON, 1L shl 22)
|
||||
put(SteamTouchConfig.JOYSTICK_RIGHT_BUTTON, 1L shl 26)
|
||||
put(SteamTouchConfig.A, 1L shl 7)
|
||||
put(SteamTouchConfig.B, 1L shl 5)
|
||||
put(SteamTouchConfig.X, 1L shl 6)
|
||||
put(SteamTouchConfig.Y, 1L shl 4)
|
||||
put(SteamTouchConfig.SELECT, 1L shl 12)
|
||||
put(SteamTouchConfig.START, 1L shl 14)
|
||||
put(SteamTouchConfig.TRIGGER_LEFT, 1L shl 1)
|
||||
put(SteamTouchConfig.TRIGGER_RIGHT, 1L shl 0)
|
||||
put(SteamTouchConfig.BUMPER_LEFT, 1L shl 3)
|
||||
put(SteamTouchConfig.BUMPER_RIGHT, 1L shl 2)
|
||||
for (i in 0 until 8) put(SteamTouchConfig.MACRO_0 + i, 1L shl (32 + i))
|
||||
put(SteamTouchConfig.MACRO_1_FINGER, 1L shl 48)
|
||||
put(SteamTouchConfig.MACRO_2_FINGER, 1L shl 49)
|
||||
// Not bits: the menu and the keyboard are the app's own.
|
||||
put(SteamTouchConfig.THUMB, 0L)
|
||||
put(SteamTouchConfig.KEYBOARD, 0L)
|
||||
}
|
||||
|
||||
private val LABEL_COLORS = mapOf(
|
||||
SteamTouchConfig.A to Color.rgb(96, 200, 90),
|
||||
SteamTouchConfig.B to Color.rgb(230, 80, 70),
|
||||
SteamTouchConfig.X to Color.rgb(70, 140, 240),
|
||||
SteamTouchConfig.Y to Color.rgb(240, 200, 60),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/**
|
||||
* Steam's touch controller, as the app's side of it.
|
||||
*
|
||||
* libfakeinput offers the Steam client a HID device with the ids of Steam Link's touch controls
|
||||
* (0000:11fb), and the client builds its own touch controller for it: Steam Input's Mobile Touch
|
||||
* type, with the game's touch config, action sets, community configs and cloud sync all Steam's
|
||||
* (docs/development/steam-touch-controller.md). This class is the file the two share
|
||||
* (fakeinput_steam.cpp: TouchRingFile): the app writes the device's 40-byte input report and
|
||||
* whether it is plugged in; libfakeinput writes back what the client told the device - the app and
|
||||
* action set it is on, its layers, rumble.
|
||||
*/
|
||||
class SteamTouchDevice private constructor(private val buffer: ByteBuffer) {
|
||||
|
||||
/** What the client last told the device. */
|
||||
data class State(
|
||||
val opened: Int,
|
||||
val appId: Int,
|
||||
val actionSet: Int,
|
||||
val layers: List<Int>,
|
||||
val actionSeq: Int,
|
||||
)
|
||||
|
||||
// The input report (Steam Link's CVirtualController, docs/development/steam-touch-controller.md):
|
||||
// bytes 0-7 buttons, 8-15 sticks, 16-27 trackpads, 28-33 accelerometer, 34-39 gyro.
|
||||
private val report = ByteArray(REPORT_BYTES)
|
||||
private var reportSeq = 0L
|
||||
|
||||
/** The controls part of the report: button bits, sticks (LX, LY, RX, RY, already in the report's
|
||||
* units) and trackpads (centre, left, right: x, y each). */
|
||||
@Synchronized
|
||||
fun setControls(buttons: Long, sticks: ShortArray, pads: ShortArray) {
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 8) next[i] = (buttons ushr (8 * i)).toByte()
|
||||
for (i in 0 until 4) putShort(next, 8 + 2 * i, sticks[i])
|
||||
for (i in 0 until 6) putShort(next, 16 + 2 * i, pads[i])
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Motion in the Deck's axes and units (PadMotion), which the touch report shares. */
|
||||
@Synchronized
|
||||
fun setMotion(accel: ShortArray, gyro: ShortArray) {
|
||||
if (!plugged) return
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 3) {
|
||||
putShort(next, 28 + 2 * i, accel[i])
|
||||
putShort(next, 34 + 2 * i, gyro[i])
|
||||
}
|
||||
publish(next)
|
||||
}
|
||||
|
||||
private fun putShort(into: ByteArray, at: Int, value: Short) {
|
||||
into[at] = value.toInt().toByte()
|
||||
into[at + 1] = (value.toInt() shr 8).toByte()
|
||||
}
|
||||
|
||||
/** Writes the report under the seqlock libfakeinput reads it with. */
|
||||
private fun publish(next: ByteArray) {
|
||||
if (next.contentEquals(report)) return
|
||||
next.copyInto(report)
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
for (i in 0 until REPORT_BYTES) buffer.put(OFF_REPORT + i, report[i])
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
}
|
||||
|
||||
var plugged: Boolean
|
||||
get() = buffer.getInt(OFF_PLUGGED) != 0
|
||||
set(value) {
|
||||
if (value == plugged) return
|
||||
if (!value) synchronized(this) { publish(ByteArray(REPORT_BYTES)) }
|
||||
buffer.putInt(OFF_PLUGGED, if (value) 1 else 0)
|
||||
Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged")
|
||||
}
|
||||
|
||||
fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100))
|
||||
|
||||
fun state(): State {
|
||||
repeat(8) {
|
||||
val seq = buffer.getLong(OFF_STATE_SEQ)
|
||||
if (seq and 1L != 0L) return@repeat
|
||||
val count = buffer.getInt(OFF_LAYER_COUNT).coerceIn(0, 8)
|
||||
val read = State(
|
||||
opened = buffer.getInt(OFF_OPENED),
|
||||
appId = buffer.getInt(OFF_APPID),
|
||||
actionSet = buffer.getInt(OFF_ACTION_SET),
|
||||
layers = (0 until count).map { buffer.getInt(OFF_LAYERS + 4 * it) },
|
||||
actionSeq = buffer.getInt(OFF_ACTION_SEQ),
|
||||
)
|
||||
if (seq == buffer.getLong(OFF_STATE_SEQ)) return read
|
||||
}
|
||||
return State(0, 0, 0, emptyList(), -1)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamTouch"
|
||||
const val REPORT_BYTES = 40
|
||||
private const val SIZE = 256
|
||||
private const val MAGIC = 0x31484354 // TCH1
|
||||
private const val OFF_REPORT_SEQ = 8
|
||||
private const val OFF_REPORT = 16
|
||||
private const val OFF_PLUGGED = 56
|
||||
private const val OFF_BATTERY = 60
|
||||
private const val OFF_STATE_SEQ = 64
|
||||
private const val OFF_OPENED = 72
|
||||
private const val OFF_APPID = 76
|
||||
private const val OFF_ACTION_SET = 80
|
||||
private const val OFF_LAYER_COUNT = 84
|
||||
private const val OFF_LAYERS = 88
|
||||
private const val OFF_ACTION_SEQ = 136
|
||||
|
||||
@Volatile
|
||||
var current: SteamTouchDevice? = null
|
||||
private set
|
||||
|
||||
/** The shared file in the session's pad directory, made fresh each session. */
|
||||
fun ringFile(fakeInputDir: File) = File(fakeInputDir, "touchctl")
|
||||
|
||||
/** Creates the file for a new session (unplugged) and maps it. */
|
||||
fun prepare(fakeInputDir: File): File? = try {
|
||||
val file = ringFile(fakeInputDir)
|
||||
file.delete()
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.setLength(SIZE.toLong())
|
||||
val map = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()).order(ByteOrder.LITTLE_ENDIAN)
|
||||
map.putInt(4, 1)
|
||||
map.putInt(0, MAGIC)
|
||||
current = SteamTouchDevice(map)
|
||||
}
|
||||
file.setReadable(true, false)
|
||||
file.setWritable(true, false)
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: no shared file: $e")
|
||||
current = null
|
||||
null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
current?.plugged = false
|
||||
current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ object SessionPrefs {
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
const val OSC_NEVER = "never"
|
||||
/** Whose on-screen controls: the app's own pad, or Steam's touch controller (SteamTouchControls). */
|
||||
const val OSC_STYLE_DROIDDECK = "droiddeck"
|
||||
const val OSC_STYLE_STEAM = "steam"
|
||||
|
||||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||||
@@ -111,6 +114,12 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("osc", mode).apply()
|
||||
}
|
||||
|
||||
fun oscStyle(context: Context): String = prefs(context).getString("oscStyle", OSC_STYLE_DROIDDECK) ?: OSC_STYLE_DROIDDECK
|
||||
|
||||
fun setOscStyle(context: Context, style: String) {
|
||||
prefs(context).edit().putString("oscStyle", style).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
|
||||
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
|
||||
|
||||
@@ -744,7 +744,9 @@ class SessionService : Service() {
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
@@ -754,6 +756,10 @@ class SessionService : Service() {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
}
|
||||
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
|
||||
}
|
||||
} else if (SessionState.mode == MODE_STEAM) {
|
||||
@@ -1493,6 +1499,7 @@ class SessionService : Service() {
|
||||
}
|
||||
components.clear()
|
||||
FakeInputWriter.releaseAllRingSlots()
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.release()
|
||||
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
val finishAfterTeardown: () -> Unit = {
|
||||
if (prootPid > 1 || auxiliary.isNotEmpty()) {
|
||||
@@ -1753,6 +1760,7 @@ class SessionService : Service() {
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
private const val NO_STEAM_TOUCH_SWITCH = "Download/droiddeck-no-steam-touch"
|
||||
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
|
||||
private const val FIRST_VIRTUAL_PAD = 16
|
||||
|
||||
|
||||
@@ -44,6 +44,24 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
|
||||
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
private const val TOUCH_NODE = "hidraw$TOUCH_MINOR"
|
||||
private const val TOUCH_USB = "$GUEST_DEVICES/usb2"
|
||||
private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0"
|
||||
private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002"
|
||||
private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE"
|
||||
private const val TOUCH_SERIAL = "DROIDDECK0001"
|
||||
|
||||
/** libfakeinput's kTouchReportDescriptor: Generic Desktop / Game Pad, 40-byte input report. */
|
||||
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()
|
||||
|
||||
/** 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,
|
||||
@@ -55,7 +73,7 @@ object SteamDeckPad {
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File): List<String> {
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
@@ -107,6 +125,34 @@ 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")
|
||||
|
||||
if (touch) {
|
||||
write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n")
|
||||
write(TOUCH_USB, "idVendor", "0000\n")
|
||||
write(TOUCH_USB, "idProduct", "11fb\n")
|
||||
write(TOUCH_USB, "bcdDevice", "0000\n")
|
||||
write(TOUCH_USB, "manufacturer", "Valve Software\n")
|
||||
write(TOUCH_USB, "product", "Mobile Touch Control\n")
|
||||
write(TOUCH_USB, "serial", "$TOUCH_SERIAL\n")
|
||||
link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
|
||||
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" +
|
||||
"HID_UNIQ=$TOUCH_SERIAL\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
|
||||
File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
|
||||
link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid")
|
||||
write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n")
|
||||
write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n")
|
||||
link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID)
|
||||
link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW)
|
||||
File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -216,6 +216,11 @@ class DrawerActions(
|
||||
val onSteamMenu: (() -> Unit)?,
|
||||
val onQam: (() -> Unit)?,
|
||||
val onOsc: (String) -> Unit,
|
||||
/** Whose on-screen controls (SessionPrefs.OSC_STYLE_*); null where Steam's are not offered. */
|
||||
val oscStyle: String? = null,
|
||||
val onOscStyle: (String) -> Unit = {},
|
||||
/** Edits the Steam touch layout of the game in front, when Steam's controls are up. */
|
||||
val onEditSteamTouch: (() -> Unit)? = null,
|
||||
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
|
||||
val onRumble: (Boolean) -> Unit = {},
|
||||
val onSteamButton: (Boolean) -> Unit = {},
|
||||
@@ -420,6 +425,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
|
||||
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
|
||||
a.oscStyle?.let { style ->
|
||||
ChoiceRow(host, "osc-style", stringResource(R.string.osc_style), stringResource(R.string.osc_style_hint),
|
||||
listOf(SessionPrefs.OSC_STYLE_DROIDDECK to stringResource(R.string.osc_style_droiddeck), SessionPrefs.OSC_STYLE_STEAM to stringResource(R.string.osc_style_steam)),
|
||||
style, chipModifier = focus.track(page, "osc-style"), onPick = a.onOscStyle)
|
||||
}
|
||||
a.onEditSteamTouch?.let { edit ->
|
||||
ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit)
|
||||
}
|
||||
a.controller?.let { c ->
|
||||
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
|
||||
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
|
||||
|
||||
@@ -255,6 +255,13 @@
|
||||
<string name="mode_osc">On-screen controls</string>
|
||||
<string name="mode_osc_qam">Steam + QAM</string>
|
||||
<string name="mode_osc_note">Auto shows the controls when no controller is connected.</string>
|
||||
<string name="osc_style">Touch controls</string>
|
||||
<string name="osc_style_hint">DroidDeck\'s own pad, or Steam\'s touch controller: per-game layouts and configs from Steam</string>
|
||||
<string name="osc_style_droiddeck">DroidDeck</string>
|
||||
<string name="osc_style_steam">Steam (beta)</string>
|
||||
<string name="osc_style_next_session">Steam\'s touch controls start with the next Steam session</string>
|
||||
<string name="osc_steam_layout">Steam touch layout</string>
|
||||
<string name="osc_steam_layout_hint">Move, resize or hide this game\'s touch controls; saved to Steam</string>
|
||||
<string name="mode_controller">Controller</string>
|
||||
<string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string>
|
||||
<string name="mode_controller_deck">Steam Deck controller</string>
|
||||
|
||||
@@ -1237,9 +1237,15 @@ 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");
|
||||
/* udevmon.c's stand-in netlink sockets first: their messages are dressed as udevd's. */
|
||||
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;
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
* container anyway. The real socket is tried first, so this stays out of the way wherever a
|
||||
* monitor could genuinely be created.
|
||||
*
|
||||
* The stand-in can also carry events after all (bl_udevmon_inject, below): libfakeinput plugs
|
||||
* Steam's touch controller in and out of the client that way.
|
||||
*
|
||||
* Wine is left without one. Its HID bus reads the session's pads itself only when its SDL bus
|
||||
* cannot start, and that is the path inputudev.c describes them on. Given a monitor the SDL bus
|
||||
* comes up and takes the pads over from it.
|
||||
@@ -31,6 +34,8 @@
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <endian.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
struct stand_in {
|
||||
@@ -189,3 +194,110 @@ int setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt
|
||||
}
|
||||
return real_setsockopt(fd, level, optname, optval, optlen);
|
||||
}
|
||||
|
||||
/* udevd's message to the "udev" multicast group (systemd, device-monitor.c): this header, then the
|
||||
* properties as NUL-separated KEY=VALUE strings. */
|
||||
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;
|
||||
};
|
||||
|
||||
/* systemd's string_hash32: MurmurHash2, seed 0. */
|
||||
static unsigned murmur_hash2(const char *key, size_t len) {
|
||||
const unsigned m = 0x5bd1e995;
|
||||
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 >> 24;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Sends a device event to every stand-in in this process, as udevd would; returns how many got it.
|
||||
* For libfakeinput (found with dlsym), which knows when its devices come and go. */
|
||||
__attribute__((visibility("default"))) int bl_udevmon_inject(const char *action, const char *devpath,
|
||||
const char *subsystem, const char *devname,
|
||||
unsigned major, unsigned minor) {
|
||||
static unsigned long long seqnum = 900000;
|
||||
char props[1024];
|
||||
int used = snprintf(props, sizeof(props), "ACTION=%s%cDEVPATH=%s%cSUBSYSTEM=%s%cDEVNAME=%s%cMAJOR=%u%cMINOR=%u%cSEQNUM=%llu",
|
||||
action, 0, devpath, 0, subsystem, 0, devname, 0, major, 0, minor, 0,
|
||||
__atomic_add_fetch(&seqnum, 1, __ATOMIC_RELAXED));
|
||||
if (used <= 0 || (size_t)used >= sizeof(props)) return -1;
|
||||
used++; /* the last string's NUL */
|
||||
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, (size_t)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) + (size_t)used, MSG_DONTWAIT | MSG_NOSIGNAL) > 0) sent++;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
return sent;
|
||||
}
|
||||
|
||||
/* For ntsync.c's recvmsg(), which owns the symbol: 1 when fd is a stand-in, answered here (*result
|
||||
* is what recvmsg returns), 0 otherwise. A message is dressed as udevd's: libudev drops one that
|
||||
* does not come from the udev multicast group with root credentials. The sender address goes in
|
||||
* the room msg_namelen had before the call; a unix socket sets it to 0. */
|
||||
__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) 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 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;
|
||||
}
|
||||
Reference in new issue
Block a user