Compare commits

..
71 changed files with 1302 additions and 5988 deletions

No files matched your search

-29
View File
@@ -13,28 +13,13 @@ permissions:
contents: read
pull-requests: read
concurrency:
group: pr-release-${{ github.event.pull_request.number }}
cancel-in-progress: false
queue: max
jobs:
cleanup:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check the current PR state
id: state
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
state=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .state)
echo "closed=$([ "$state" = closed ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
if: ${{ steps.state.outputs.closed == 'true' }}
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
@@ -42,7 +27,6 @@ jobs:
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete the test build
if: ${{ steps.state.outputs.closed == 'true' }}
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -58,19 +42,6 @@ jobs:
done
[ "$found" = 1 ] || echo "No test build for PR #$PR_NUMBER"
- uses: actions/checkout@v4
if: ${{ steps.state.outputs.closed == 'true' }}
with:
ref: main
persist-credentials: false
- name: Retire the Discord PR announcement
if: ${{ steps.state.outputs.closed == 'true' }}
env:
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: python3 tools/release/pr_discord.py retire
catalog:
needs: cleanup
if: ${{ always() }}
+21 -14
View File
@@ -26,7 +26,6 @@ permissions:
concurrency:
group: pr-release-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
cancel-in-progress: false
queue: max
jobs:
authorize:
@@ -179,10 +178,6 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-pr-signed-*
@@ -312,27 +307,39 @@ jobs:
$NEXT
EOF
)
comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --slurp | \
jq "[.[][] | select(.user.login == \"github-actions[bot]\") | select(.body | startswith(\"$mark\"))] | first // {}")
id=$(jq -r '.id // empty' <<<"$comment")
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$mark\")) | .id" | head -1 || true)
if [ -n "$id" ]; then
state=$(jq -r '.body | split("\n")[] | select(startswith("<!-- droiddeck-discord: ") and endswith(" -->"))' <<<"$comment")
if [ -n "$state" ]; then body=$(printf '%s\n%s' "$body" "$state"); fi
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
else
gh api -X POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" >/dev/null
fi
- name: Update the Discord PR announcement
if: ${{ always() && steps.publish.outcome == 'success' }}
# Asked for (the label, a manual run): always announced. Signed on its own after a push:
# only a PR's first test build is, and later ones update its release quietly.
- name: Post to Discord
if: ${{ always() && steps.publish.outcome == 'success' && (needs.authorize.outputs.automatic != 'true' || steps.publish.outputs.first == 'true') }}
env:
WEBHOOK: ${{ secrets.DISCORD_PR_BUILDS_WEBHOOK }}
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
RELEASE: ${{ steps.publish.outputs.release }}
APK: ${{ steps.publish.outputs.apk }}
run: python3 tools/release/pr_discord.py publish
run: |
set -euo pipefail
if [ -z "$WEBHOOK" ]; then
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
exit 0
fi
short=${HEAD_SHA:0:7}
title=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .title)
what="[#$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed PR test build** \`pr-%s\` · \`%s\`\n%s\n_Not merged — for testing._\n%s' "$PR_NUMBER" "$short" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
catalog:
needs: publish
-1
View File
@@ -13,7 +13,6 @@ permissions:
concurrency:
group: update-app-catalog
cancel-in-progress: false
queue: max
jobs:
catalog:
+2 -2
View File
@@ -79,8 +79,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 12
versionName "0.3.2"
versionCode 11
versionName "0.3.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
+256 -5
View File
@@ -1216,8 +1216,70 @@ struct DeckHidraw {
FakeInputRingHeader *ring = nullptr;
size_t mapping_size = 0;
uint8_t pending_feature = 0;
bool touch = false; // the Mobile Touch experiment below, not the Deck
int touch_index = 0;
};
// ---- EXPERIMENT: Steam's Mobile Touch controller as a local device ----
//
// docs/development/steam-touch-controller.md. Steam Link hands the host its touch controls as a
// remote HID device, 0000:11fb ("Mobile Touch Control"), which Steam's controller code classes as
// k_eControllerType_MobileTouch by its ids alone. FAKE_EVDEV_TOUCHCTL=1 offers the client the same
// ids as a local hidraw node, to see whether it builds the touch controller with no stream. The
// 40-byte input report is read from TOUCHCTL_REPORT_FILE (written by hand while experimenting); every
// output and feature report the client sends is logged and appended to TOUCHCTL_OUT_FILE.
// FAKE_EVDEV_TOUCHCTL=<n> offers n of them: /dev/hidraw17, 18, ... with serials MT-DROIDDECK0001, 0002,
// ..., each reading its own report file (touchctl.report, touchctl1.report, ...).
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
static constexpr int TOUCH_MAX = 4;
static constexpr int TOUCH_REPORT_BYTES = 40;
static constexpr int TOUCH_REPORT_INTERVAL_US = 8000;
static constexpr const char *TOUCH_NAME = "Mobile Touch Control";
static std::string touch_serial(int index) {
char serial[32];
snprintf(serial, sizeof(serial), "MT-DROIDDECK%04d", index + 1);
return serial;
}
static std::string touch_report_file(int index) {
return index == 0 ? std::string("/tmp/touchctl.report") : "/tmp/touchctl" + std::to_string(index) + ".report";
}
static constexpr const char *TOUCHCTL_OUT_FILE = "/tmp/touchctl.out";
// While this file exists the device is unplugged: the open stream ends and new opens fail.
static constexpr const char *TOUCHCTL_OFF_FILE = "/tmp/touchctl.off";
// Generic Desktop / Game Pad: one 40-byte input report, one 64-byte output report, one 64-byte feature report.
static const uint8_t kTouchReportDescriptor[] = {
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0};
static int touchctl_count() {
static int count = -1;
if (count < 0) count = getenv("FAKE_EVDEV_TOUCHCTL") ? std::max(0, std::min(TOUCH_MAX, atoi(getenv("FAKE_EVDEV_TOUCHCTL")))) : 0;
return count;
}
static bool fake_touchctl_enabled() { return touchctl_count() > 0; }
__attribute__((visibility("hidden"))) static void touchctl_note(const char *what, const uint8_t *data, size_t size) {
static std::atomic<unsigned> count{0};
unsigned n = ++count;
char hex[3 * 72 + 1] = {};
size_t shown = std::min<size_t>(size, 72);
for (size_t i = 0; i < shown; i++) snprintf(hex + 3 * i, 4, "%02x ", data[i]);
if (n <= 200 || (n & (n - 1)) == 0) Logger::log("touchctl: %s (%zu bytes): %s\n", what, size, hex);
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
int fd = my_open(TOUCHCTL_OUT_FILE, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
if (fd >= 0) {
char line[3 * 72 + 64];
struct timespec now;
clock_gettime(CLOCK_REALTIME, &now);
int length = snprintf(line, sizeof(line), "%ld.%03ld %s %s\n", static_cast<long>(now.tv_sec),
now.tv_nsec / 1000000, what, hex);
syscall(SYS_write, fd, line, length);
syscall(SYS_close, fd);
}
}
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
// back grips and trackpads (the second screen's Deck controls, DeckControls), already in the
// Deck's axes and units, under a seqlock (FakeInputWriter.writeMotion / writeDeckControls).
@@ -1307,7 +1369,8 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
__attribute__((constructor)) static void log_client_start() {
config();
if (process_is_steam_client())
Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
Logger::log("loaded in the Steam client: deck %s, touchctl %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
fake_touchctl_enabled() ? "on" : "off",
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
}
@@ -1483,6 +1546,172 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
return pair[0];
}
__attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg) {
auto *holder = static_cast<std::shared_ptr<DeckHidraw> *>(arg);
std::shared_ptr<DeckHidraw> self = *holder;
delete holder;
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
uint8_t last[TOUCH_REPORT_BYTES] = {};
for (;;) {
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
Logger::log("touchctl: unplugged\n");
break;
}
uint8_t report[TOUCH_REPORT_BYTES] = {};
int fd = my_open(touch_report_file(self->touch_index).c_str(), O_RDONLY | O_CLOEXEC);
if (fd >= 0) {
syscall(SYS_read, fd, report, sizeof(report));
syscall(SYS_close, fd);
}
if (memcmp(report, last, sizeof(report))) {
touchctl_note(self->touch_index ? "input#1+" : "input", report, sizeof(report));
memcpy(last, report, sizeof(report));
}
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
}
Logger::log("touchctl: reader closed\n");
syscall(SYS_close, self->peer);
return nullptr;
}
// Which of the experiment's sysfs files the client reads, each once: where its scan stops.
__attribute__((visibility("hidden"))) static void touchctl_trace(int dirfd, const char *pathname) {
if (!fake_touchctl_enabled() || !pathname || !process_is_steam_client()) return;
std::string path;
if (pathname[0] == '/' || dirfd == AT_FDCWD) {
path = pathname;
} else {
char link[64], dir[PATH_MAX];
snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd);
ssize_t length = readlink(link, dir, sizeof(dir) - 1);
if (length <= 0) return;
path.assign(dir, length);
path += '/';
path += pathname;
}
if (path.find("usb2") == std::string::npos && path.find("usb3") == std::string::npos && path.find("hidraw17") == std::string::npos &&
path.find("11FB") == std::string::npos && path.find("240:17") == std::string::npos)
return;
static std::mutex mutex;
static auto *seen = new std::vector<std::string>();
{
std::lock_guard<std::mutex> guard(mutex);
if (seen->size() >= 128 || std::find(seen->begin(), seen->end(), path) != seen->end()) return;
seen->push_back(path);
}
Logger::log("touchctl: client read %s\n", path.c_str());
}
// The index of the experiment's node at pathname, or -1.
__attribute__((visibility("hidden"))) static int touch_hidraw_index(const char *pathname) {
if (!pathname || !fake_touchctl_enabled() || strncmp(pathname, "/dev/hidraw", 11)) return -1;
char *end = nullptr;
long minor = strtol(pathname + 11, &end, 10);
if (!end || *end || minor < TOUCH_HIDRAW_MINOR || minor >= TOUCH_HIDRAW_MINOR + touchctl_count()) return -1;
return static_cast<int>(minor - TOUCH_HIDRAW_MINOR);
}
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
return touch_hidraw_index(pathname) >= 0;
}
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags, int index) {
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
Logger::log("touchctl: open refused while unplugged\n");
errno = ENOENT;
return -1;
}
if (!process_is_steam_client()) {
errno = ENOENT;
return -1;
}
int pair[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1;
int buffer = TOUCH_REPORT_BYTES * 4;
setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer));
if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0);
if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK);
auto touch = std::make_shared<DeckHidraw>();
touch->peer = pair[1];
touch->touch = true;
touch->touch_index = index;
{
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map()[pair[0]] = touch;
}
pthread_t thread;
auto *arg = new std::shared_ptr<DeckHidraw>(touch);
if (pthread_create(&thread, nullptr, touch_report_thread, arg) != 0) {
delete arg;
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map().erase(pair[0]);
syscall(SYS_close, pair[0]);
syscall(SYS_close, pair[1]);
errno = ENOMEM;
return -1;
}
pthread_detach(thread);
Logger::log("touchctl: /dev/hidraw%u opened as fd %d\n", TOUCH_HIDRAW_MINOR + index, pair[0]);
return pair[0];
}
static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value);
__attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, ioctl_request_t op, void *argp) {
if (_IOC_TYPE(op) != 'H') {
errno = ENOTTY;
return -1;
}
size_t size = _IOC_SIZE(op);
switch (_IOC_NR(op)) {
case 0x01:
*static_cast<int *>(argp) = sizeof(kTouchReportDescriptor);
return 0;
case 0x02: {
auto *descriptor = static_cast<uint8_t *>(argp);
uint32_t want;
memcpy(&want, descriptor, sizeof(want));
memcpy(descriptor + 4, kTouchReportDescriptor, std::min<size_t>(want, sizeof(kTouchReportDescriptor)));
return 0;
}
case 0x03: {
struct {
uint32_t bustype;
int16_t vendor;
int16_t product;
} info = {BUS_USB, 0x0000, static_cast<int16_t>(0x11fb)};
memcpy(argp, &info, sizeof(info));
return 0;
}
case 0x04: return copy_ioctl_string(op, argp, TOUCH_NAME);
case 0x05: return copy_ioctl_string(op, argp, ("usb-droiddeck-" + std::to_string(2 + touch.touch_index) + "/input0").c_str());
case 0x08: return copy_ioctl_string(op, argp, touch_serial(touch.touch_index).c_str());
case 0x06: // HIDIOCSFEATURE
touchctl_note("set-feature", static_cast<uint8_t *>(argp), size);
touch.pending_feature = size > 1 ? static_cast<uint8_t *>(argp)[1] : 0;
return static_cast<int>(size);
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery in Steam Link's controller
auto *buf = static_cast<uint8_t *>(argp);
touchctl_note("get-feature", buf, std::min<size_t>(size, 8));
uint8_t id = buf[0];
memset(buf, 0, size);
buf[0] = id;
if (size > 2) {
buf[1] = 2;
buf[2] = 100;
}
return static_cast<int>(std::min<size_t>(size, 18));
}
default:
Logger::log("touchctl: unhandled hidraw ioctl 0x%02x\n", _IOC_NR(op));
errno = EINVAL;
return -1;
}
}
// libudev (systemd's sd-device) accepts a device only where its directory is on sysfs, which it
// checks with fstatfs on the directory it resolved. The Deck's sysfs is the app's own files bound
// into /sys, so those paths are said to be sysfs; nothing else is touched.
@@ -1823,6 +2052,8 @@ EXPORT int open(const char *pathname, int flags, ...) {
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
touchctl_trace(AT_FDCWD, pathname);
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -1897,6 +2128,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
touchctl_trace(dirfd, pathname);
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -1957,6 +2190,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
errno = ENOENT;
return -1;
}
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR + touch_hidraw_index(pathname));
return 0;
}
if (is_deck_hidraw_path(pathname)) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
@@ -2008,7 +2247,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
if (ret == 0 && deck_map().count(fd)) {
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR + deck_map()[fd]->touch_index : DECK_HIDRAW_MINOR);
return ret;
}
auto controller = controller_map().find(fd);
@@ -2054,6 +2293,7 @@ EXPORT int access(const char *pathname, int mode) {
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
errno = ENOENT;
return -1;
@@ -2090,6 +2330,7 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(dlsym(RTLD_NEXT, "faccessat"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
errno = ENOENT;
return -1;
@@ -2157,6 +2398,8 @@ EXPORT int inotify_add_watch(int fd, const char *pathname, uint32_t mask) {
}
int ret = my_inotify_add_watch(fd, pathname, mask);
if (fake_touchctl_enabled() && process_is_steam_client())
Logger::log("touchctl: client watches %s (mask 0x%x) -> %d\n", pathname ? pathname : "(null)", mask, ret);
if (fake_path)
free(fake_path);
return ret;
@@ -2211,7 +2454,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
auto maker = uinput_map().find(fd);
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
auto deck = deck_map().find(fd);
if (deck != deck_map().end()) return ioctl_deck(*deck->second, op, argp);
if (deck != deck_map().end())
return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp);
auto controller = controller_map().find(fd);
if (controller == controller_map().end()) {
guard.unlock();
@@ -2537,7 +2781,10 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto made = uinput_map().find(fd);
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
if (deck_map().count(fd)) return static_cast<ssize_t>(count);
if (deck_map().count(fd)) {
if (deck_map()[fd]->touch) touchctl_note("output", static_cast<const uint8_t *>(buf), count);
return static_cast<ssize_t>(count);
}
auto controller = controller_map().find(fd);
if (controller != controller_map().end()) {
if (fake_fd_is_stale(fd)) {
@@ -2562,7 +2809,11 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
if (deck_map().count(fd)) {
ssize_t total = 0;
for (int i = 0; i < iovcnt; i++) total += static_cast<ssize_t>(iov[i].iov_len);
bool touch = deck_map()[fd]->touch;
for (int i = 0; i < iovcnt; i++) {
if (touch) touchctl_note("output", static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
total += static_cast<ssize_t>(iov[i].iov_len);
}
return total;
}
auto made = uinput_map().find(fd);
@@ -35,7 +35,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
@@ -356,7 +355,6 @@ class MainActivity : ComponentActivity() {
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var agentCommands by mutableStateOf(false)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
@@ -528,7 +526,7 @@ class MainActivity : ComponentActivity() {
updates.start()
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
@@ -548,7 +546,7 @@ class MainActivity : ComponentActivity() {
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -660,10 +658,6 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
},
onAgentCommands = {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
},
onShareLogs = {
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
@@ -1498,7 +1492,6 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
@@ -362,7 +362,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
SessionState.padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
@@ -2053,7 +2052,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
@@ -1,48 +0,0 @@
package com.droiddeck.launcher.agent
import android.content.Context
/**
* Who may run code through the agent bridge. Reading state, starting and stopping sessions,
* launching and quitting games and sending input only need the shell (the provider's DUMP
* permission). Running commands in the guest, evaluating JavaScript in the Steam client, session
* environment, binary overrides and preference writes reach inside the app's sandbox - the guest
* holds the Steam login - which the shell of a stock device otherwise cannot. They require
* Setup > Session > Debugging tools to be on, in every build. The bridge itself cannot turn it on.
*/
object AgentAccess {
const val MAX_REQUEST_CHARS = 256 * 1024
private const val PREFS = "agent"
private const val KEY_COMMANDS = "commands"
/** The preferences file the bridge must never write: set-pref refuses it. */
const val PROTECTED_PREFS = PREFS
fun commandsAllowed(context: Context): Boolean =
prefs(context).getBoolean(KEY_COMMANDS, false)
/** The Setup toggle's own state. */
fun commandsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_COMMANDS, false)
fun setCommandsEnabled(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean(KEY_COMMANDS, enabled).apply()
if (!enabled) runCatching { AgentEnv.revoke(context) }
.onFailure { android.util.Log.w("AgentAccess", "Could not finish clearing debugging experiments") }
}
fun requireCommands(context: Context) {
if (!commandsAllowed(context)) throw AgentException(
"AGENT_COMMANDS_DISABLED",
"Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
)
}
fun requireStart(context: Context, request: org.json.JSONObject) {
if (request.optString("mode") == "run") requireCommands(context)
}
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
/** A refusal with a stable code, answered as {"ok": false, "error": {code, message}}. */
class AgentException(val code: String, message: String) : RuntimeException(message)
@@ -10,14 +10,13 @@ import android.net.Uri
import android.os.Binder
import android.os.Bundle
import android.os.Process
import android.util.Base64
import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.json.JSONArray
@@ -26,14 +25,7 @@ import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* The shell's control surface (docs/agent-control.md), in every build: the provider and its start
* Activity need android.permission.DUMP, held by the shell and privileged or explicitly granted callers. Commands that
* reach into the app's sandbox are further gated by [AgentAccess].
*
* A method's request is JSON, base64-encoded in the "request" extra; every answer is JSON in the
* "json" key of the returned Bundle, {"ok": true, ...} or {"ok": false, "error": {code, message}}.
*/
/** A shell-only control surface, installed only by debug builds. */
class AgentBridgeProvider : ContentProvider() {
override fun onCreate(): Boolean {
val appContext = context?.applicationContext ?: return false
@@ -56,53 +48,17 @@ class AgentBridgeProvider : ContentProvider() {
}
val response = try {
val request = decode(extras?.getString(EXTRA_REQUEST))
// Binder identity is the shell's; everything below acts as the app.
val token = Binder.clearCallingIdentity()
try {
dispatch(context, method, request)
} finally {
Binder.restoreCallingIdentity(token)
when (method) {
"state" -> state(context).put("ok", true)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
}
} catch (e: AgentException) {
error(e.code, e.message ?: e.code)
} catch (e: Exception) {
error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName)
}
val json = response.toString().let {
if (it.length <= 384 * 1024) it else error("RESPONSE_TOO_LARGE", "Reduce the command output or select fewer fields").toString()
}
return Bundle().apply { putString(RESULT_JSON, json) }
}
private fun dispatch(context: Context, method: String, request: JSONObject): JSONObject = when (method) {
"state" -> state(context).put("ok", true)
"start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity")
"stop" -> stop(context)
"resume" -> resume(context)
"launch" -> launch(context, request)
"focus" -> guest(context, JSONObject().put("kind", "focus"), 15_000)
"quit" -> guest(context, JSONObject().put("kind", "quit").put("appId", request.opt("appId"))
.put("timeout", request.optDouble("timeout", 15.0)), timeoutMs(request, 15.0, 30_000))
"input" -> AgentInput.handle(request)
"ui" -> AgentUi.handle(request)
"guest" -> {
AgentAccess.requireCommands(context)
guest(context, JSONObject(request.toString()).put("kind", "exec"), timeoutMs(request, 30.0, 10_000))
}
"cdp" -> {
AgentAccess.requireCommands(context)
guest(context, JSONObject(request.toString()).put("kind", "cdp"), timeoutMs(request, 15.0, 5_000))
}
"env" -> env(context, request)
"override" -> {
AgentAccess.requireCommands(context)
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
}
"prefs" -> prefs(context, request)
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
return Bundle().apply { putString(RESULT_JSON, response.toString()) }
}
private fun state(context: Context): JSONObject {
@@ -135,31 +91,13 @@ class AgentBridgeProvider : ContentProvider() {
.put("status", SessionState.failureStatus ?: JSONObject.NULL)
session.put("failure", failure)
// gamescope's focus as last published by the guest agent, without the window lists
// (the focus command returns those, fresh).
val focus = AgentGuest.focus(context)?.let { snap ->
JSONObject()
.put("focusedApp", snap.opt("focusedApp") ?: JSONObject.NULL)
.put("focusedWindow", snap.opt("focusedWindow") ?: JSONObject.NULL)
.put("focusableApps", snap.optJSONArray("focusableApps") ?: JSONArray())
.put("baselayerAppIds", snap.optJSONArray("baselayerAppIds") ?: JSONArray())
.put("seq", snap.optInt("seq"))
.put("t", snap.optLong("t"))
}
session.put("focus", focus ?: JSONObject.NULL)
val hello = AgentGuest.hello(context)
return JSONObject()
.put("schema", SCHEMA)
.put("schema", 1)
.put("build", BuildConfig.BUILD_LABEL)
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
.put("runtime", JSONObject()
.put("installed", LinuxRuntime.isInstalled(context))
.put("version", runtimeVersion ?: JSONObject.NULL))
.put("agent", JSONObject()
.put("guest", hello != null)
.put("guestFocus", hello?.optBoolean("focus") ?: false)
.put("commands", AgentAccess.commandsAllowed(context)))
.put("session", session)
}
@@ -193,67 +131,6 @@ class AgentBridgeProvider : ContentProvider() {
return JSONObject().put("ok", true).put("command", "resume")
}
private fun launch(context: Context, request: JSONObject): JSONObject {
val appId = request.optString("appId")
if (!GameLaunchLink.validId(appId)) throw AgentException("INVALID_APP_ID", "appId must be a decimal Steam app id")
if (!SessionState.running || SessionState.mode != SessionService.MODE_STEAM) {
throw AgentException("NO_STEAM_SESSION", "Games launch inside a running Steam session")
}
if (!SessionService.launchGame(context, appId)) throw AgentException("LAUNCH_REJECTED", "The session did not accept the launch")
SessionEvents.record("agent.launch_requested", mapOf("appId" to appId))
return JSONObject().put("ok", true).put("command", "launch").put("appId", appId)
}
private fun env(context: Context, request: JSONObject): JSONObject {
AgentAccess.requireCommands(context)
val result = when (val op = request.optString("op", "list")) {
"list" -> AgentEnv.list(context)
"set" -> {
val lines = request.optJSONArray("lines") ?: JSONArray()
AgentEnv.set(context, List(lines.length()) { lines.optString(it) }, request.optBoolean("persistent"))
}
"clear" -> AgentEnv.clear(context, request.optString("scope", "next"))
else -> throw AgentException("INVALID_REQUEST", "env op '$op' is not list, set or clear")
}
return result.put("ok", true).put("command", "env")
}
private fun prefs(context: Context, request: JSONObject): JSONObject {
AgentAccess.requireCommands(context)
val file = request.optString("file")
val result = when (val op = request.optString("op", "get")) {
"get" -> AgentPrefs.get(context, file)
"set" -> AgentPrefs.set(context, file, request.optString("key"), request.optString("type", "string"),
if (request.isNull("value")) null else request.optString("value"))
else -> throw AgentException("INVALID_REQUEST", "prefs op '$op' is not get or set")
}
return result.put("ok", true).put("command", "prefs")
}
/** A guest agent request; its own answer already carries ok or error. */
private fun guest(context: Context, request: JSONObject, timeoutMs: Long): JSONObject =
AgentGuest.call(context, request, timeoutMs)
/** The guest's own timeout plus slack for the round trip. */
private fun timeoutMs(request: JSONObject, defaultSeconds: Double, slackMs: Long): Long {
val seconds = if (request.has("timeout")) request.opt("timeout")?.toString()?.toDoubleOrNull()
?: throw AgentException("INVALID_REQUEST", "timeout must be a number") else defaultSeconds
if (!seconds.isFinite() || seconds <= 0 || seconds > 600) {
throw AgentException("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
}
return (seconds * 1000).toLong() + slackMs
}
private fun decode(encoded: String?): JSONObject {
if (encoded.isNullOrBlank()) return JSONObject()
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) throw AgentException("INVALID_REQUEST", "The encoded request exceeds 256 KiB")
return try {
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
} catch (e: Exception) {
throw AgentException("INVALID_REQUEST", "The request extra is not base64 JSON")
}
}
private fun error(code: String, message: String) = JSONObject()
.put("ok", false)
.put("error", JSONObject().put("code", code).put("message", message))
@@ -266,9 +143,6 @@ class AgentBridgeProvider : ContentProvider() {
companion object {
const val RESULT_JSON = "json"
const val EXTRA_REQUEST = "request"
/** 2 added agent and session.focus; everything in 1 is unchanged. */
const val SCHEMA = 2
private val recoveryComplete = CountDownLatch(1)
fun awaitRecovery() {
@@ -1,222 +0,0 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption.NOFOLLOW_LINKS
import java.nio.file.StandardOpenOption.READ
/**
* Experiment switches an agent sets without touching Download/droiddeck-env or rebuilding the apk:
*
* - environment lines (KEY=VALUE) for the session, applied after the user's own droiddeck-env, so
* they win. "next" lines are taken by the next session that starts and dropped when it ends;
* "persistent" ones stay until cleared.
* - binary overrides: a file pushed to the app's external files (agent-inbox/<name>) is installed
* as /opt/droiddeck-agent/bin/<name> in the rootfs and put first on the next session's PATH -
* a gamescope build under test, say. Removed when that session ends.
*
* Kept in the app's own files (agent-env.json), never in a place the shell can write.
*/
object AgentEnv {
private const val TAG = "AgentEnv"
private const val FILE = "agent-env.json"
private const val GUEST_BIN = "/opt/droiddeck-agent/bin"
private const val BASE_PATH = "/usr/local/bin:/usr/bin:/bin"
private val NAME = Regex("[A-Za-z0-9._-]{1,64}")
private val KEY = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val lock = Any()
fun inbox(context: Context): File = File(context.getExternalFilesDir(null), "agent-inbox")
fun list(context: Context): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
load(context)
}
/** Add [lines] for the next session ([persistent] = every session until cleared). */
fun set(context: Context, lines: List<String>, persistent: Boolean): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
if (lines.any { line -> KEY.matchEntire(line.substringBefore('=', "")) == null || !line.contains('=') || line.any { it == '\n' || it == '\r' || it == '\u0000' } }) {
throw AgentException("INVALID_ENV", "Environment entries must be single KEY=VALUE lines without NUL bytes")
}
val state = load(context)
val key = if (persistent) "persistent" else "pending"
val merged = merge(strings(state.optJSONArray(key)), lines)
state.put(key, JSONArray(merged))
save(context, state)
state
}
/** Drop lines: [scope] is "next", "persistent" or "all". */
fun clear(context: Context, scope: String): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
val state = load(context)
when (scope) {
"next" -> state.put("pending", JSONArray())
"persistent" -> state.put("persistent", JSONArray())
"all" -> { state.put("pending", JSONArray()); state.put("persistent", JSONArray()); state.put("pendingOverrides", JSONArray()) }
else -> throw AgentException("INVALID_SCOPE", "Scope must be next, persistent or all")
}
save(context, state)
state
}
/** Install agent-inbox/[name] as an override for the next session. */
fun override(context: Context, name: String): JSONObject = synchronized(lock) {
AgentAccess.requireCommands(context)
if (NAME.matchEntire(name) == null || name in setOf(".", "..")) throw AgentException("INVALID_NAME", "Override names are letters, digits, '.', '_' and '-'")
val source = File(inbox(context), name)
if (Files.isSymbolicLink(source.toPath())) throw AgentException("INVALID_NAME", "The override must be a regular file")
if (!source.isFile) throw AgentException("NO_SUCH_FILE", "Push the binary to ${source.path} first")
val target = File(bin(context), name)
target.parentFile?.mkdirs()
val staged = File.createTempFile("override-", ".tmp", target.parentFile)
try {
Files.newInputStream(source.toPath(), READ, NOFOLLOW_LINKS).use { input -> staged.outputStream().use { input.copyTo(it) } }
} catch (e: Exception) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not stage the override")
}
if (!staged.setExecutable(true, false) || !staged.renameTo(target)) {
staged.delete()
throw AgentException("OVERRIDE_FAILED", "Could not install $name in the rootfs")
}
source.delete()
val state = load(context)
state.put("pendingOverrides", JSONArray(merge(strings(state.optJSONArray("pendingOverrides")), listOf(name))))
save(context, state)
state.put("installed", "$GUEST_BIN/$name")
}
/**
* At session start: the lines this session gets, after the user's droiddeck-env. Pending lines
* and overrides become this session's own, to be dropped by [endSession].
*/
fun beginSession(context: Context): List<String> = synchronized(lock) {
if (!AgentAccess.commandsAllowed(context)) {
revoke(context)
return@synchronized emptyList()
}
val state = load(context)
val active = strings(state.optJSONArray("pending"))
val overrides = strings(state.optJSONArray("pendingOverrides"))
state.put("active", JSONArray(active)).put("pending", JSONArray())
state.put("activeOverrides", JSONArray(overrides)).put("pendingOverrides", JSONArray())
save(context, state)
// Anything else there was left by a session that never reached endSession (the app died).
bin(context).listFiles()
?.filter { it.name !in overrides }?.forEach { it.delete() }
val lines = strings(state.optJSONArray("persistent")) + active
val withPath = if (overrides.isEmpty()) lines else lines + "PATH=$GUEST_BIN:${pathOf(lines)}"
if (withPath.isNotEmpty()) Log.i(TAG, "agent environment keys for this session: ${withPath.map { it.substringBefore('=') }}")
withPath
}
fun revoke(context: Context) = synchronized(lock) {
val state = load(context)
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) state.put(key, JSONArray())
save(context, state)
bin(context).listFiles()?.forEach { it.delete() }
}
/** At session end: this session's lines and override binaries go. */
fun endSession(context: Context) = synchronized(lock) {
val state = load(context)
val overrides = strings(state.optJSONArray("activeOverrides"))
val pending = strings(state.optJSONArray("pendingOverrides"))
val bin = bin(context)
overrides.filter { it !in pending }.forEach { File(bin, it).delete() }
state.put("active", JSONArray()).put("activeOverrides", JSONArray())
save(context, state)
}
/** A PATH= line among [lines] (the user's or an agent's) is extended rather than replaced. */
private fun pathOf(lines: List<String>) =
lines.lastOrNull { it.startsWith("PATH=") }?.substringAfter('=')?.takeIf { it.isNotBlank() } ?: BASE_PATH
private fun bin(context: Context): File {
val root = LinuxRuntime.rootDir(context).canonicalFile.toPath()
val directory = root.resolve(GUEST_BIN.removePrefix("/"))
var path = directory
while (path != root) {
if (Files.isSymbolicLink(path)) throw AgentException("OVERRIDE_FAILED", "The override directory must not contain symbolic links")
path = path.parent
}
return directory.toFile()
}
private fun merge(existing: List<String>, added: List<String>): List<String> {
val keys = added.map { it.substringBefore('=') }.toSet()
return existing.filter { it.substringBefore('=') !in keys } + added
}
private fun strings(array: JSONArray?): List<String> =
if (array == null) emptyList() else List(array.length()) { array.optString(it) }.filter { it.isNotEmpty() }
private fun load(context: Context): JSONObject {
val file = File(context.filesDir, FILE)
val state = try { if (file.isFile) JSONObject(file.readText()) else JSONObject() } catch (e: Exception) { JSONObject() }
for (key in listOf("pending", "active", "persistent", "pendingOverrides", "activeOverrides")) {
if (!state.has(key)) state.put(key, JSONArray())
}
return state
}
private fun save(context: Context, state: JSONObject) {
val file = File(context.filesDir, FILE)
val staged = File(context.filesDir, "$FILE.tmp")
staged.writeText(state.toString())
if (!staged.renameTo(file)) {
staged.delete()
throw AgentException("ENV_WRITE_FAILED", "Could not save the debugging environment")
}
}
}
/** Typed writes to the app's SharedPreferences, for driving a settings matrix without the UI. */
object AgentPrefs {
fun set(context: Context, file: String, key: String, type: String, value: String?): JSONObject {
AgentAccess.requireCommands(context)
validateFile(file)
if (file == AgentAccess.PROTECTED_PREFS) throw AgentException("PROTECTED_PREFS", "The agent preferences are set on the device only")
if (file.isBlank() || key.isBlank()) throw AgentException("INVALID_PREF", "file and key are required")
val prefs = context.getSharedPreferences(file, Context.MODE_PRIVATE)
val previous = prefs.all[key]
val editor = prefs.edit()
try {
when (type) {
"remove" -> editor.remove(key)
"bool" -> editor.putBoolean(key, value?.toBooleanStrictOrNull() ?: throw NumberFormatException("not true/false"))
"int" -> editor.putInt(key, value!!.toInt())
"long" -> editor.putLong(key, value!!.toLong())
"float" -> editor.putFloat(key, value!!.toFloat())
"string" -> editor.putString(key, value ?: "")
else -> throw AgentException("INVALID_PREF", "type must be bool, int, long, float, string or remove")
}
} catch (e: RuntimeException) {
if (e is AgentException) throw e
throw AgentException("INVALID_PREF", "The value is not a $type")
}
if (!editor.commit()) throw AgentException("PREF_WRITE_FAILED", "Could not write $file/$key")
return JSONObject().put("file", file).put("key", key).put("type", type)
.put("previous", previous ?: JSONObject.NULL).put("value", prefs.all[key] ?: JSONObject.NULL)
}
fun get(context: Context, file: String): JSONObject {
AgentAccess.requireCommands(context)
validateFile(file)
val all = context.getSharedPreferences(file, Context.MODE_PRIVATE).all
return JSONObject().put("file", file).put("values", JSONObject(all.mapValues { it.value ?: JSONObject.NULL }))
}
private fun validateFile(file: String) {
if (!Regex("[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}").matches(file)) {
throw AgentException("INVALID_PREF", "Preference files must be simple names")
}
}
}
@@ -1,128 +0,0 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.FileObserver
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionState
import org.json.JSONObject
import java.io.File
import java.util.UUID
/**
* The app's side of the guest agent (tools/linuxfs/overlay/usr/local/bin/droiddeck-agent): requests
* go in as files under the session root's agent directory and answers come back the same way, so
* whatever runs does so inside the live session with its own environment.
*/
object AgentGuest {
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
private var observer: FileObserver? = null
private var lastFocusKey: String? = null
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "agent")
/**
* Before each session: nothing of the last one's daemon, answers or focus may be read as
* current. Then watch for the new daemon's hello and focus snapshots, which go into the
* session's events.jsonl.
*/
fun reset(context: Context) {
synchronized(lock) {
observer?.stopWatching()
observer = null
lastFocusKey = null
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@Suppress("DEPRECATION")
observer = object : FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
when (path) {
"hello.json" -> readJson(File(dir, path))?.let {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
}
}
}.also { it.startWatching() }
}
}
/** At the end of a session: no more events for a folder that is being closed. */
fun stop() {
synchronized(lock) {
observer?.stopWatching()
observer = null
}
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
"focusableApps" to snapshot.optJSONArray("focusableApps")?.toString(),
"baselayerAppIds" to snapshot.optJSONArray("baselayerAppIds")?.toString(),
)
val key = fields.values.joinToString("|")
synchronized(lock) {
if (key == lastFocusKey) return
lastFocusKey = key
}
SessionEvents.record("focus.changed", fields + ("seq" to snapshot.optInt("seq")))
}
/** The daemon's hello, when it is running in the current session. */
fun hello(context: Context): JSONObject? {
if (!SessionState.running) return null
val hello = readJson(File(dir(context), "hello.json")) ?: return null
val pid = hello.optInt("pid", -1)
return hello.takeIf { pid > 1 && File("/proc/$pid").exists() }
}
/** The newest focus snapshot the daemon published, when a session is running. */
fun focus(context: Context): JSONObject? =
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
val dir = dir(context)
val requests = File(dir, "req").apply { mkdirs() }
val answers = File(dir, "resp").apply { mkdirs() }
val id = UUID.randomUUID().toString()
val staged = File(requests, "$id.json.tmp")
val published = File(requests, "$id.json")
val answer = File(answers, "$id.json")
staged.writeText(request.toString())
if (!staged.renameTo(published)) {
staged.delete()
throw AgentException("AGENT_UNAVAILABLE", "Could not hand the request to the guest agent")
}
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (answer.isFile) {
if (answer.length() > 1024 * 1024) {
answer.delete()
throw AgentException("RESPONSE_TOO_LARGE", "The guest response exceeds 1 MiB")
}
val result = readJson(answer)
answer.delete()
return result ?: throw AgentException("INVALID_AGENT_RESPONSE", "The guest agent's answer was not JSON")
}
Thread.sleep(POLL_MS)
}
published.delete()
Log.w(TAG, "no answer to ${request.optString("kind")} in ${timeoutMs}ms")
throw AgentException("TIMEOUT", "The guest agent did not answer within ${timeoutMs / 1000}s")
}
private fun readJson(file: File): JSONObject? = try {
if (file.isFile && file.length() <= 1024 * 1024) JSONObject(file.readText()) else null
} catch (e: Exception) {
null
}
}
@@ -1,221 +0,0 @@
package com.droiddeck.launcher.agent
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
/**
* Input into the running session the way a player gives it: keys and touches through the
* compositor (what the session screen sends), and the virtual pad through the same PadBridge the
* on-screen controls drive, so games that ignore touch still see a controller.
*/
object AgentInput {
/** Out of the way of real fingers, which Android numbers from 0. */
private const val TOUCH_ID = 9
private const val TOUCH_SCALE_X = 1919f
private const val TOUCH_SCALE_Y = 1079f
fun handle(request: JSONObject): JSONObject {
if (!SessionState.running || !SessionState.firstFrameSeen) {
throw AgentException("SESSION_NOT_READY", "Input needs a running session that has drawn its first frame")
}
return when (val type = request.optString("type")) {
"key" -> key(request)
"text" -> text(request)
"tap" -> tap(request)
"swipe" -> swipe(request)
"pad" -> pad(request)
"stick" -> stick(request)
"trigger" -> trigger(request)
else -> throw AgentException("INVALID_INPUT", "Unknown input type '$type'")
}.put("ok", true).put("command", "input").put("type", request.optString("type"))
}
private fun key(request: JSONObject): JSONObject {
val name = request.optString("key")
val code = KEYS[name.uppercase()] ?: name.toIntOrNull()
?: throw AgentException("INVALID_INPUT", "Unknown key '$name' (a name such as ENTER or an evdev code)")
val modifiers = request.optJSONArray("modifiers")?.let { array ->
List(array.length()) { i ->
KEYS[array.optString(i).uppercase()] ?: throw AgentException("INVALID_INPUT", "Unknown modifier '${array.optString(i)}'")
}
}.orEmpty()
when (val action = request.optString("action", "tap")) {
"down" -> WaylandCompositor.nativeSendKey(code, 1)
"up" -> WaylandCompositor.nativeSendKey(code, 0)
"tap" -> {
modifiers.forEach { WaylandCompositor.nativeSendKey(it, 1) }
WaylandCompositor.nativeSendKey(code, 1)
hold(request, 40)
WaylandCompositor.nativeSendKey(code, 0)
modifiers.asReversed().forEach { WaylandCompositor.nativeSendKey(it, 0) }
}
else -> throw AgentException("INVALID_INPUT", "Key action '$action' is not tap, down or up")
}
return JSONObject().put("code", code)
}
/** Typed as key presses (US layout), which reaches X programs that have no text-input support. */
private fun text(request: JSONObject): JSONObject {
val text = request.optString("text")
if (request.optBoolean("commit")) {
WaylandCompositor.textInputCommit(text)
return JSONObject().put("length", text.length).put("via", "text-input")
}
val missing = text.filter { TYPED[it] == null }
if (missing.isNotEmpty()) throw AgentException("INVALID_INPUT", "Cannot type '${missing.toSet().joinToString("")}' as keys; use commit")
val delay = request.optLong("delayMs", 15)
for (ch in text) {
val (code, shift) = TYPED.getValue(ch)
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 1)
WaylandCompositor.nativeSendKey(code, 1)
WaylandCompositor.nativeSendKey(code, 0)
if (shift) WaylandCompositor.nativeSendKey(KEYS.getValue("LEFTSHIFT"), 0)
Thread.sleep(delay)
}
return JSONObject().put("length", text.length).put("via", "keys")
}
private fun tap(request: JSONObject): JSONObject {
val (x, y) = point(request, "x", "y")
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x, y)
hold(request, 60)
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x, y)
return JSONObject().put("touch", org.json.JSONArray().put(x).put(y))
}
private fun swipe(request: JSONObject): JSONObject {
val (x1, y1) = point(request, "x", "y")
val (x2, y2) = point(request, "x2", "y2")
val duration = request.optLong("durationMs", 300).coerceIn(16, 10_000)
val steps = (duration / 16).toInt().coerceAtLeast(2)
WaylandCompositor.nativeSendTouch(0, TOUCH_ID, x1, y1)
for (i in 1..steps) {
Thread.sleep(duration / steps)
WaylandCompositor.nativeSendTouch(1, TOUCH_ID, x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
}
WaylandCompositor.nativeSendTouch(2, TOUCH_ID, x2, y2)
return JSONObject().put("from", org.json.JSONArray().put(x1).put(y1)).put("to", org.json.JSONArray().put(x2).put(y2))
}
/**
* A point in the compositor's touch space (0..1919 x 0..1079 whatever the session's size), from
* the session's output pixels (space "output", the default: what the guest draws at) or 0..1
* fractions (space "fraction").
*/
private fun point(request: JSONObject, xKey: String, yKey: String): Pair<Int, Int> {
if (!request.has(xKey) || !request.has(yKey)) throw AgentException("INVALID_INPUT", "$xKey and $yKey are required")
val x = request.getDouble(xKey)
val y = request.getDouble(yKey)
val (fx, fy) = when (val space = request.optString("space", "output")) {
"fraction" -> x to y
"output" -> {
val (w, h) = SessionState.outputSize
x / (w - 1).coerceAtLeast(1) to y / (h - 1).coerceAtLeast(1)
}
else -> throw AgentException("INVALID_INPUT", "space '$space' is not output or fraction")
}
return (fx.coerceIn(0.0, 1.0) * TOUCH_SCALE_X).toInt() to (fy.coerceIn(0.0, 1.0) * TOUCH_SCALE_Y).toInt()
}
private fun pad(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val name = request.optString("button").lowercase()
if (name == "qam") {
bridge.triggerQam()
return JSONObject().put("button", name)
}
val button = BUTTONS[name]
if (button == null && name !in DPAD) throw AgentException("INVALID_INPUT", "Unknown pad button '$name'")
val apply = { s: PadState, down: Boolean ->
when (name) {
"up" -> s.up = down
"down" -> s.down = down
"left" -> s.left = down
"right" -> s.right = down
else -> s.press(button!!, down)
}
}
when (val action = request.optString("action", "tap")) {
"down" -> bridge.applyTouch { apply(it, true) }
"up" -> bridge.applyTouch { apply(it, false) }
"tap" -> {
bridge.applyTouch { apply(it, true) }
hold(request, 120)
bridge.applyTouch { apply(it, false) }
}
else -> throw AgentException("INVALID_INPUT", "Pad action '$action' is not tap, down or up")
}
return JSONObject().put("button", name)
}
private fun stick(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val right = request.optString("stick", "left") == "right"
val x = request.optDouble("x", 0.0).toFloat().coerceIn(-1f, 1f)
val y = request.optDouble("y", 0.0).toFloat().coerceIn(-1f, 1f)
bridge.applyTouch { if (right) { it.rightX = x; it.rightY = y } else { it.leftX = x; it.leftY = y } }
hold(request, 300)
bridge.applyTouch { if (right) { it.rightX = 0f; it.rightY = 0f } else { it.leftX = 0f; it.leftY = 0f } }
return JSONObject().put("stick", if (right) "right" else "left")
}
private fun trigger(request: JSONObject): JSONObject {
val bridge = SessionState.padBridge ?: throw AgentException("NO_PAD", "The session has no virtual pad")
val right = request.optString("trigger", "right") == "right"
val value = request.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)
bridge.applyTouch { if (right) it.rightTrigger = value else it.leftTrigger = value }
hold(request, 200)
bridge.applyTouch { if (right) it.rightTrigger = 0f else it.leftTrigger = 0f }
return JSONObject().put("trigger", if (right) "right" else "left")
}
private fun hold(request: JSONObject, defaultMs: Long) =
Thread.sleep(request.optLong("holdMs", defaultMs).coerceIn(0, 10_000))
private val DPAD = setOf("up", "down", "left", "right")
private val BUTTONS = mapOf(
"a" to PadState.A, "b" to PadState.B, "x" to PadState.X, "y" to PadState.Y,
"lb" to PadState.LB, "rb" to PadState.RB, "select" to PadState.SELECT, "back" to PadState.SELECT,
"start" to PadState.START, "menu" to PadState.START, "l3" to PadState.L3, "r3" to PadState.R3,
"guide" to PadState.GUIDE, "steam" to PadState.GUIDE,
)
/** Linux evdev codes (input-event-codes.h) by name. */
private val KEYS: Map<String, Int> = buildMap {
"1234567890".forEachIndexed { i, c -> put(c.toString(), 2 + i) }
"QWERTYUIOP".forEachIndexed { i, c -> put(c.toString(), 16 + i) }
"ASDFGHJKL".forEachIndexed { i, c -> put(c.toString(), 30 + i) }
"ZXCVBNM".forEachIndexed { i, c -> put(c.toString(), 44 + i) }
(1..10).forEach { put("F$it", 58 + it) }
put("F11", 87); put("F12", 88)
putAll(mapOf(
"ESC" to 1, "MINUS" to 12, "EQUAL" to 13, "BACKSPACE" to 14, "TAB" to 15, "LEFTBRACE" to 26,
"RIGHTBRACE" to 27, "ENTER" to 28, "LEFTCTRL" to 29, "SEMICOLON" to 39, "APOSTROPHE" to 40,
"GRAVE" to 41, "LEFTSHIFT" to 42, "BACKSLASH" to 43, "COMMA" to 51, "DOT" to 52, "SLASH" to 53,
"RIGHTSHIFT" to 54, "LEFTALT" to 56, "SPACE" to 57, "CAPSLOCK" to 58, "RIGHTCTRL" to 97,
"RIGHTALT" to 100, "HOME" to 102, "UP" to 103, "PAGEUP" to 104, "LEFT" to 105, "RIGHT" to 106,
"END" to 107, "DOWN" to 108, "PAGEDOWN" to 109, "INSERT" to 110, "DELETE" to 111, "LEFTMETA" to 125,
"CTRL" to 29, "SHIFT" to 42, "ALT" to 56, "META" to 125, "SUPER" to 125, "RETURN" to 28, "ESCAPE" to 1,
))
}
/** US-layout characters as (evdev code, shift). */
private val TYPED: Map<Char, Pair<Int, Boolean>> = buildMap {
for (c in 'a'..'z') put(c, KEYS.getValue(c.uppercase()) to false)
for (c in 'A'..'Z') put(c, KEYS.getValue(c.toString()) to true)
for (c in '0'..'9') put(c, KEYS.getValue(c.toString()) to false)
"!@#$%^&*()".forEachIndexed { i, c -> put(c, KEYS.getValue(((i + 1) % 10).toString()) to true) }
putAll(mapOf(
' ' to (57 to false), '\n' to (28 to false), '\t' to (15 to false),
'-' to (12 to false), '_' to (12 to true), '=' to (13 to false), '+' to (13 to true),
'[' to (26 to false), '{' to (26 to true), ']' to (27 to false), '}' to (27 to true),
';' to (39 to false), ':' to (39 to true), '\'' to (40 to false), '"' to (40 to true),
'`' to (41 to false), '~' to (41 to true), '\\' to (43 to false), '|' to (43 to true),
',' to (51 to false), '<' to (51 to true), '.' to (52 to false), '>' to (52 to true),
'/' to (53 to false), '?' to (53 to true),
))
}
}
@@ -38,10 +38,6 @@ class AgentStartActivity : Activity() {
Log.e(TAG, "missing agent start request")
return null
}
if (encoded.length > AgentAccess.MAX_REQUEST_CHARS) {
Log.w(TAG, "agent start request is too large")
return null
}
return try {
JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8))
} catch (e: Exception) {
@@ -51,13 +47,6 @@ class AgentStartActivity : Activity() {
}
private fun startSession(request: JSONObject) {
try {
AgentAccess.requireStart(this, request)
} catch (e: AgentException) {
Log.w(TAG, e.code)
finish()
return
}
val requestedMode = request.optString("mode")
val sessionMode = when (requestedMode) {
"steam" -> SessionService.MODE_STEAM
@@ -1,157 +0,0 @@
package com.droiddeck.launcher.agent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.view.inspector.WindowInspector
import androidx.compose.ui.platform.ViewRootForTest
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* The app's own UI as an agent sees it: every Compose node in every window of the process (popups
* and menus included), read from Compose's semantics rather than through UI Automator, which on a
* dual-screen device dumps whichever window has focus - often not this app's. Nodes carry their
* test tag (ui/TestTags.kt), text, content description, role, state and on-screen bounds; click
* and set-text act on a node by tag or text, so a test needs no coordinates.
*/
object AgentUi {
private const val TIMEOUT_MS = 5_000L
/** Setup's Debugging tools toggle: the bridge never acts on its own gate (AgentAccess). */
private const val GATE_TAG = "setting-agent-commands"
fun handle(request: JSONObject): JSONObject = when (val op = request.optString("op", "dump")) {
"dump" -> onMain { dump(request.optBoolean("all")) }
"click" -> onMain { act(request) { node -> perform(node, SemanticsActions.OnClick) } }
"set-text" -> onMain {
val text = request.optString("text")
act(request) { node ->
node.config.getOrNull(SemanticsActions.SetText)?.action?.invoke(AnnotatedString(text))
?: throw AgentException("NOT_EDITABLE", "That node takes no text")
}
}
else -> throw AgentException("INVALID_REQUEST", "ui op '$op' is not dump, click or set-text")
}.put("ok", true).put("command", "ui")
private fun roots(): List<Pair<View, ViewRootForTest>> {
if (Build.VERSION.SDK_INT < 29) throw AgentException("UNSUPPORTED", "The UI dump needs Android 10 or later")
val found = mutableListOf<Pair<View, ViewRootForTest>>()
fun walk(view: View) {
if (view is ViewRootForTest) found += view to view
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
WindowInspector.getGlobalWindowViews().filter { it.isAttachedToWindow && it.isShown }.forEach(::walk)
return found
}
private fun dump(all: Boolean): JSONObject {
val windows = JSONArray()
for ((view, root) in roots()) {
val nodes = JSONArray()
val location = IntArray(2).also { view.getLocationOnScreen(it) }
fun visit(node: SemanticsNode) {
val json = describe(node, location)
if (all || json.has("tag") || json.has("text") || json.has("description") || json.optBoolean("clickable")) nodes.put(json)
node.children.forEach(::visit)
}
visit(root.semanticsOwner.rootSemanticsNode)
windows.put(JSONObject()
.put("display", view.display?.displayId ?: JSONObject.NULL)
.put("window", view.rootView.javaClass.simpleName)
.put("nodes", nodes))
}
return JSONObject().put("windows", windows)
}
private fun describe(node: SemanticsNode, origin: IntArray): JSONObject {
val config = node.config
val json = JSONObject().put("id", node.id)
config.getOrNull(SemanticsProperties.TestTag)?.let { json.put("tag", it) }
val text = textFields(config)
text.keys().forEach { json.put(it, text.get(it)) }
config.getOrNull(SemanticsProperties.Role)?.let { json.put("role", it.toString()) }
config.getOrNull(SemanticsProperties.ToggleableState)?.let { json.put("toggle", it.name) }
config.getOrNull(SemanticsProperties.Selected)?.let { json.put("selected", it) }
config.getOrNull(SemanticsProperties.Focused)?.let { json.put("focused", it) }
if (config.contains(SemanticsProperties.Disabled)) json.put("disabled", true)
if (config.contains(SemanticsActions.OnClick)) json.put("clickable", true)
if (config.contains(SemanticsActions.SetText)) json.put("editable", true)
val b = node.boundsInWindow
json.put("bounds", JSONArray()
.put(origin[0] + b.left.toInt()).put(origin[1] + b.top.toInt())
.put(origin[0] + b.right.toInt()).put(origin[1] + b.bottom.toInt()))
return json
}
internal fun textFields(config: SemanticsConfiguration): JSONObject {
val password = config.contains(SemanticsProperties.Password)
val json = JSONObject()
for ((key, value) in listOf(
"text" to config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text },
"editableText" to config.getOrNull(SemanticsProperties.EditableText)?.text,
"description" to config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" "),
"state" to config.getOrNull(SemanticsProperties.StateDescription),
)) value?.let { json.put(key, if (password) "<redacted>" else it) }
return json
}
/**
* The node a request names by "tag" (exact) or "match" (text or description containing it,
* ignoring case); "index" picks among several, in tree order.
*/
private fun act(request: JSONObject, action: (SemanticsNode) -> Unit): JSONObject {
val tag = request.optString("tag").takeIf { it.isNotEmpty() }
val text = if (tag == null) request.optString("match").takeIf { it.isNotEmpty() } else null
if (tag == null && text == null) throw AgentException("INVALID_REQUEST", "Name the node by tag or match")
val matches = mutableListOf<Pair<SemanticsNode, IntArray>>()
for ((view, root) in roots()) {
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
fun visit(node: SemanticsNode) {
val config = node.config
val hit = if (tag != null) config.getOrNull(SemanticsProperties.TestTag) == tag
else (config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
?: config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ") ?: "")
.contains(text!!, ignoreCase = true)
if (hit) matches += node to origin
node.children.forEach(::visit)
}
visit(root.semanticsOwner.rootSemanticsNode)
}
val index = request.optInt("index", 0)
val (node, origin) = matches.getOrNull(index)
?: throw AgentException("NO_SUCH_NODE", "No node with ${if (tag != null) "tag '$tag'" else "text '$text'"} (${matches.size} found)")
if (generateSequence(node) { it.parent }.any { it.config.getOrNull(SemanticsProperties.TestTag) == GATE_TAG }) {
throw AgentException("PROTECTED_CONTROL", "Debugging tools are switched on the device only")
}
action(node)
return JSONObject().put("matches", matches.size).put("node", describe(node, origin))
}
private fun perform(node: SemanticsNode, key: androidx.compose.ui.semantics.SemanticsPropertyKey<androidx.compose.ui.semantics.AccessibilityAction<() -> Boolean>>) {
val action = node.config.getOrNull(key)?.action ?: throw AgentException("NOT_CLICKABLE", "That node has no click action")
action()
}
private fun <T> onMain(block: () -> T): T {
if (Looper.myLooper() == Looper.getMainLooper()) return block()
val result = AtomicReference<Result<T>>()
val done = CountDownLatch(1)
Handler(Looper.getMainLooper()).post {
result.set(runCatching(block))
done.countDown()
}
if (!done.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) throw AgentException("TIMEOUT", "The UI thread did not answer")
return result.get().getOrThrow()
}
}
@@ -181,14 +181,8 @@ object DeviceReport {
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files")
SessionService.envSwitchFile(context)?.takeIf { it.isFile }?.let { f ->
k("droiddeck-env", FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
}
if (File(Environment.getExternalStorageDirectory(), "Download/droiddeck-env").isFile) {
k("Download/droiddeck-env", "(ignored - it moved to Android/data/${context.packageName}/files)")
}
for (name in listOf("droiddeck-tu-debug", "droiddeck-driver",
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
"droiddeck-osc", "droiddeck-no-pad",
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
@@ -40,7 +40,7 @@ object HostProcess {
if (onLine == null) builder.redirectOutput(File("/dev/null"))
val process = builder.start()
val pid = pidOf(process)
Log.i(TAG, "started pid $pid: ${argv.first()} (${argv.size - 1} arguments)")
Log.i(TAG, "started pid $pid: $command")
if (onLine != null) {
Thread({
try {
@@ -59,7 +59,7 @@ object HostProcess {
}
pid
} catch (e: Exception) {
Log.e(TAG, "could not start: ${argv.first()}", e)
Log.e(TAG, "could not start: $command", e)
-1
}
}
@@ -60,15 +60,6 @@ object LogRedactor {
private val IPV4 = Regex("(?<![0-9.])(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(?![0-9.])")
/** An IPv6 literal: "::" somewhere, or all eight groups (a clock's 10:03:05 is neither). */
private val IPV6 = Regex("(?<![0-9A-Za-z:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%[A-Za-z0-9_.]+)?(?![0-9A-Za-z:])")
private val MAC = Regex("(?i)(?<![0-9a-z:-])[0-9a-f]{2}([:-])(?:[0-9a-f]{2}\\1){4}[0-9a-f]{2}(?![0-9a-z:-])")
private val MAC_FIELD = Regex(
"(?i)(\\bmac(?:[_ ]?address)?[\"']?\\s*[:=]\\s*[\"']?)[0-9a-f]{2}([:-])" +
"(?:[0-9a-f]{2}\\2){4}[0-9a-f]{2}(?![0-9a-z:-])"
)
private val DEVICE_IDENTIFIER = Regex(
"(?i)(\\b(?:android[_ ]?id|(?:device|controller)[_ ]?serial|serial(?:[_ ]?number)?|build\\.serial)" +
"[\"']?\\s*[:=]\\s*)(\"[^\"\\r\\n<>]+\"|'[^'\\r\\n<>]+'|[^\\s\"'<>&;,\\[\\]{}]+)"
)
/**
* The account a Steam UI login line names ("Login: OnLoginStateChange <account> 2 1 0 0"): a
@@ -205,7 +196,7 @@ object LogRedactor {
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
* out.
*/
const val RULES_VERSION = 2
const val RULES_VERSION = 1
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
@@ -217,13 +208,6 @@ object LogRedactor {
if (line.isEmpty()) return line
return try {
var out = line
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
out = MAC.replace(out, "<redacted:mac>")
out = DEVICE_IDENTIFIER.replace(out) {
val value = it.groupValues[2]
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
}
out = GUID.replace(out, "<redacted:guid>")
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
out = JWT_BASE64.replace(out, "<redacted:jwt>")
@@ -41,8 +41,6 @@ object SessionFiles {
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
"libssbs.so" to "usr/local/lib/libssbs.so",
"usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session",
"usr/local/bin/droiddeck-fonts" to "usr/local/bin/droiddeck-fonts",
"usr/local/bin/droiddeck-agent" to "usr/local/bin/droiddeck-agent",
"usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility",
"usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard",
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
@@ -285,15 +285,11 @@ class SessionService : Service() {
}
private fun extraEnv(): List<String> {
if (File(Environment.getExternalStorageDirectory(), LEGACY_ENV_SWITCH).isFile) {
Log.w(TAG, "ignoring $LEGACY_ENV_SWITCH: any app with storage access can write there; " +
"the file now lives at ${envSwitchFile(this)?.path ?: "(not available before Android 11)"}")
}
val file = envSwitchFile(this)?.takeIf { it.isFile } ?: return emptyList()
val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList()
val lines = FileUtils.readString(file)?.lines().orEmpty()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') && !it.startsWith("=") }
if (lines.isNotEmpty()) Log.i(TAG, "extra environment keys from ${file.path}: ${lines.map { it.substringBefore('=') }}")
if (lines.isNotEmpty()) Log.i(TAG, "extra environment from $ENV_SWITCH: $lines")
return lines
}
@@ -324,7 +320,6 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
@@ -424,7 +419,7 @@ class SessionService : Service() {
}
guest.add(program)
guest.addAll(SessionState.programArgs)
Log.i(TAG, "run: $program (${SessionState.programArgs.size} arguments) under gamescope")
Log.i(TAG, "run: $program ${SessionState.programArgs.joinToString(" ")} under gamescope")
}
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
@@ -654,14 +649,11 @@ class SessionService : Service() {
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
// Anything else, for a device that cannot be reached with a debugger: droiddeck-env in the
// app's own external files (envSwitchFile) holds KEY=VALUE lines that go into the session's
// environment as written, after ours, so a line here wins. Zink and Turnip tunables
// (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's, the client's - whatever the experiment
// needs, without a build per attempt.
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
// the client's - whatever the experiment needs, without a build per attempt.
extraEnv().forEach { guest.add(it) }
// The agent bridge's experiment lines (AgentEnv), after the user's so an agent's win.
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
// Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is
// on, even naming every core: it exists to undo the pin Steam applies to its own interface
// renderer, and the scheduler's default is exactly what that pin takes away. A game's is
@@ -1407,9 +1399,6 @@ class SessionService : Service() {
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
com.droiddeck.launcher.agent.AgentGuest.stop()
runCatching { com.droiddeck.launcher.agent.AgentEnv.endSession(this) }
.onFailure { Log.w(TAG, "clearing the agent's session environment", it) }
releaseLocks()
if (status == 0) {
SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status))
@@ -1711,25 +1700,8 @@ class SessionService : Service() {
private const val TRACER_NICE = -6
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
/**
* Where droiddeck-env (KEY=VALUE lines added to the session environment verbatim) used to
* be. Ignored now: a line there reaches the Steam client's environment as written
* (LD_PRELOAD, VK_ICD_FILENAMES, PATH...), and any app with storage access can write to
* Download - code of its choosing inside our sandbox, next to the client's saved login.
*/
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
/**
* The session's extra-environment file: Android/data/<package>/files/droiddeck-env. Only
* this app, adb and the user (through the app's own file manager) can write there - from
* Android 11 on. Before that other apps with storage access can reach Android/data too, so
* there is no such file at all.
*/
fun envSwitchFile(context: Context): File? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
context.getExternalFilesDir(null)?.let { File(it, ENV_SWITCH_NAME) }
else null
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
private const val ENV_SWITCH = "Download/droiddeck-env"
private const val CHANNEL_ID = "session"
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
@@ -80,9 +80,6 @@ object SessionState {
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** The session screen's virtual pad, for the agent bridge's pad input (AgentInput). */
@Volatile var padBridge: com.droiddeck.launcher.input.PadBridge? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@@ -44,6 +44,12 @@ object SteamDeckPad {
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
// EXPERIMENT (docs/development/steam-touch-controller.md): Steam's Mobile Touch controller,
// 0000:11fb, as a second local device. libfakeinput serves the node only with
// FAKE_EVDEV_TOUCHCTL=1 (fakeinput_steam.cpp: TOUCH_HIDRAW_*).
private const val TOUCH_MINOR = 17
private const val TOUCH_DEVICES = 2
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
private val REPORT_DESCRIPTOR = intArrayOf(
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
@@ -51,6 +57,14 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Must match libfakeinput's kTouchReportDescriptor. */
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
@@ -107,6 +121,42 @@ object SteamDeckPad {
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
// EXPERIMENT: Mobile Touch controllers beside the Deck, two of them (hidraw17, 18).
for (index in 0 until TOUCH_DEVICES) {
val minor = TOUCH_MINOR + index
val node = "hidraw$minor"
val bus = 2 + index
val usb = "$GUEST_DEVICES/usb$bus"
val iface = "$usb/$bus-1:1.0"
val hid = "$iface/0003:0000:11FB.000${2 + index}"
val hidraw = "$hid/hidraw/$node"
val serial = "MT-DROIDDECK%04d".format(index + 1)
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
write(usb, "idVendor", "0000\n")
write(usb, "idProduct", "11fb\n")
write(usb, "bcdDevice", "0000\n")
write(usb, "manufacturer", "Valve Software\n")
write(usb, "product", "Mobile Touch Control\n")
write(usb, "serial", "$serial\n")
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
write(iface, "bInterfaceNumber", "00\n")
write(iface, "bInterfaceClass", "03\n")
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-$bus/input0\n" +
"HID_UNIQ=$serial\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
File(dir(hid), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
write(hidraw, "dev", "$MAJOR:$minor\n")
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
link(File(dir(hidraw), "device"), hid)
link(File(hidrawClass, node), hidraw)
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
}
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.LocalIndication
@@ -72,7 +71,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
if (!s.shortcutPicker) Rise(2) {
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp).testTag("play-steam"), onClick = a.onPlay) }
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
GameFileFolderActions(s, a)
}
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.focus.focusRequester
@@ -163,7 +162,6 @@ private fun RailItem(
contentAlignment = Alignment.Center,
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.testTag("rail-$key")
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
@@ -91,8 +91,6 @@ class FrontEndState(
val frameGenLabel: String,
val romsDir: String?,
val logsEnabled: Boolean,
/** Setup's Debugging tools toggle (AgentAccess): off unless the user turns it on. */
val agentCommands: Boolean = false,
val steamGames: List<Library.SteamGame>,
val emulators: List<Library.Emulator>,
val running: String?,
@@ -168,7 +166,6 @@ class FrontEndActions(
val onFiles: () -> Unit,
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onAgentCommands: () -> Unit = {},
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
val onOffline: () -> Unit,
@@ -307,7 +307,6 @@ internal fun SetupPanel(
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.alpha
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
@@ -311,7 +310,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
modifier = Modifier.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize().exposeTestTags(), contentAlignment = Alignment.CenterEnd) {
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
// A 4:3 or near-square screen (under 440dp tall) gets tighter padding and a shorter tab row,
// so the settings keep most of the height; a narrow one keeps some of the game in view.
val short = maxHeight < 440.dp
@@ -622,7 +621,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit
val stopDescription = stringResource(R.string.stop_session)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }.testTag("drawer-stop")
modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = stopDescription }
.clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
@@ -733,7 +732,6 @@ private fun DrawerPageTabs(page: SessionDrawerPage, pages: List<SessionDrawerPag
)
.glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground)
.semantics { contentDescription = pageDescription }
.testTag("drawer-tab-${item.name.lowercase()}")
.hoverable(source)
.clickable(interactionSource = source, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
@@ -1174,14 +1172,14 @@ private fun StopConfirm(
ConfirmChoice(
stringResource(R.string.common_cancel), danger = false, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() }
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }.testTag("drawer-stop-cancel"),
.focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused },
onClick = onCancel,
)
ConfirmChoice(
stringResource(R.string.drawer_stop), danger = true, enabled = open,
modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() }
.onFocusChanged { stopFocused = it.isFocused }
.onGloballyPositioned { stopCoords = it }.testTag("drawer-stop-confirm"),
.onGloballyPositioned { stopCoords = it },
) {
val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) }
onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat()))
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import android.view.KeyEvent
@@ -187,7 +186,6 @@ internal fun TabStrip(
color = if (on) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
modifier = Modifier
.bringIntoViewRequester(reveal)
.testTag("tab-$i")
.then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(tabShape)
.background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
@@ -458,7 +456,7 @@ fun <T> ChoiceRow(
val open = host.open == key
SettingsRow(label, hint, highlighted = open, hintLines = hintLines) {
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key }
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
options.forEachIndexed { index, (value, text) ->
MenuItem(text, checked = value == selected, focusRequester = if (index == 0) firstItemFocus else null) {
@@ -474,7 +472,7 @@ fun <T> ChoiceRow(
@Composable
fun ToggleRow(host: MenuHost, key: String, label: String, hint: String?, checked: Boolean, enabled: Boolean = true, chipModifier: Modifier = Modifier, onChange: (Boolean) -> Unit) =
SettingsRow(label, hint) {
ToggleSwitch(checked, enabled, label, chipModifier.testTag("setting-$key")) { host.open = null; onChange(it) }
ToggleSwitch(checked, enabled, label, chipModifier) { host.open = null; onChange(it) }
}
/** An on/off switch: one tap or one A press flips it, where a menu of On and Off took three. */
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -92,7 +91,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
Spacer(Modifier.height(18.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
// Enabled without a runtime: the session's loading screen installs it first.
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay)
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay)
Cog(size = 54.dp, onClick = a.onSteamSettings)
}
}
@@ -1,14 +0,0 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
/**
* Compose test tags as Android resource ids below this node, so UI Automator (`uiautomator dump`,
* droiddeckctl ui-dump) sees stable ids such as `rail-steam` or `setting-logs` rather than only
* labels that change with the language. Tags are named in docs/agent-control.md.
*/
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.exposeTestTags(): Modifier = semantics { testTagsAsResourceId = true }
+5 -6
View File
@@ -79,7 +79,7 @@
<string name="game_env_no_fsync">Pon 1 para que este juego no use droiddeck-fsync; usa droiddeck-esync si hay un paquete y, si no, droiddeck-ntsync.</string>
<string name="game_env_fsync_first">Pon 1 para que este juego use droiddeck-fsync, o 0 para dar prioridad a droiddeck-esync cuando haya un paquete para su Proton.</string>
<string name="game_env_xalia">Pon 0 para desactivar el asistente de navegación con mando de Proton en este juego.</string>
<string name="game_env_hud">Superposición de DXVK para D3D9–11. fps muestra la tasa de fotogramas; sin definir la oculta.</string>
<string name="game_env_hud">Overlay de DXVK para D3D9–11. fps muestra la tasa de fotogramas; sin definir la oculta.</string>
<string name="game_env_dxvk_config">Ajustes de DXVK separados por punto y coma. Las versiones recientes usan dxvk.maxFrameRate para limitar fotogramas; para el uso normal prefiere el límite de fotogramas de la sesión.</string>
<string name="game_env_vkd3d_limit">Límite de fotogramas de D3D12. 0 desactiva el límite.</string>
<string name="game_env_glthread">Subprocesos de OpenGL de Mesa. true puede ayudar a juegos OpenGL limitados por la CPU; pruébalo en cada juego.</string>
@@ -287,7 +287,7 @@
<string name="mode_client">Cliente</string>
<string name="mode_deck_mode">Modo Steam Deck</string>
<string name="mode_deck_mode_hint">La interfaz Deck de Steam y los controles de rendimiento de Quick Access. Se aplica en la próxima sesión.</string>
<string name="mode_mangoapp">Superposición de rendimiento</string>
<string name="mode_mangoapp">Overlay de rendimiento</string>
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
<string name="mode_branch">Rama del cliente</string>
<string name="mode_branch_deck">Beta pública de Steam Deck (requerida por el modo Deck).</string>
@@ -344,13 +344,13 @@
<string name="mode_mic">Micrófono</string>
<string name="mode_mic_hint">Para el chat de voz.</string>
<string name="mode_network">Red</string>
<string name="mode_wifi_discovery">Detección de redes Wi-Fi</string>
<string name="mode_wifi_discovery">Detección de Wi-Fi</string>
<string name="mode_wifi_discovery_hint">Muestra en Steam los nombres de Wi-Fi y las redes cercanas.</string>
<string name="mode_wifi_location_off">Los servicios de ubicación están desactivados.</string>
<string name="mode_wifi_permission_denied">No se concedió el permiso de ubicación.</string>
<string name="mode_wifi_open_settings">Abrir ajustes</string>
<string name="mode_wifi_continue">Continuar</string>
<string name="mode_wifi_explain_title">¿Activar la detección de redes Wi-Fi?</string>
<string name="mode_wifi_explain_title">¿Activar la detección de Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android exige el permiso de ubicación para leer los nombres de Wi-Fi y detectar redes cercanas. DroidDeck usa este acceso para mostrar esas redes en Steam. No rastreamos tu ubicación. El acceso a internet funciona sin este permiso.</string>
<string name="mode_storage">Almacenamiento de juegos</string>
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente, y tus carpetas de juegos</string>
@@ -392,7 +392,7 @@
<string name="comp_display_driver_hint">Dibuja los fotogramas en pantalla en ambos modos. Reinicia la app para aplicarlo.</string>
<string name="comp_import_adrenotools">Importar un zip de AdrenoTools…</string>
<string name="comp_never">nunca</string>
<string name="comp_nightlies_checked">Última comprobación de Nightlies: %1$s</string>
<string name="comp_nightlies_checked">Nightlies comprobadas %1$s</string>
<string name="comp_title">Componentes</string>
<string name="comp_about">Acerca de Componentes</string>
<string name="comp_check_drivers">Buscar controladores nuevos</string>
@@ -613,7 +613,6 @@
<string name="setup_session">Sesión</string>
<string name="setup_logs">Registros de sesión</string>
<string name="setup_logs_hint">Se guardan tras cada sesión</string>
<string name="setup_agent_commands">Herramientas de depuración</string>
<string name="setup_latest_logs">Últimos registros de sesión</string>
<string name="setup_saved_logs">Registros de sesión guardados</string>
<string name="setup_saved_logs_hint">Conserva los %1$d más recientes</string>
-1
View File
@@ -514,7 +514,6 @@
<string name="setup_session">Session</string>
<string name="setup_logs">Journaux de session</string>
<string name="setup_logs_hint">Enregistré après chaque fin de session</string>
<string name="setup_agent_commands">Outils de débogage</string>
<string name="setup_latest_logs">Derniers journaux de session</string>
<string name="setup_saved_logs">Journaux de session enregistrés</string>
<string name="setup_saved_logs_hint">Conserver les %1$d plus récents</string>
+2 -3
View File
@@ -278,12 +278,12 @@
<string name="mode_decky_installed">インストール済み · %1$s</string>
<string name="mode_decky_update">アップデートあり · %1$s</string>
<string name="mode_decky_current">最新です · %1$s</string>
<string name="mode_decky_loader">Loader</string>
<string name="mode_decky_loader">ローダー</string>
<string name="mode_decky_install_latest">最新バージョンをインストール</string>
<string name="mode_decky_on">Steamと共に起動します。有効にすると、このデバイス上の他のアプリがSteamを制御できる場合があります。</string>
<string name="mode_decky_off">オフ。Steamのリモートデバッグポートは閉じたままになります。</string>
<string name="mode_decky_remove_title">Decky Loaderをアンインストールしますか?</string>
<string name="mode_decky_remove_text">Loaderを削除します。プラグインと設定は保持されます。</string>
<string name="mode_decky_remove_text">ローダーを削除します。プラグインと設定は保持されます。</string>
<string name="mode_client">クライアント</string>
<string name="mode_deck_mode">Steam Deckモード</string>
<string name="mode_deck_mode_hint">Steam DeckのUIとクイックアクセスパフォーマンスコントロール。次回のセッションで有効になります。</string>
@@ -611,7 +611,6 @@
<string name="setup_session">セッション</string>
<string name="setup_logs">セッションログ</string>
<string name="setup_logs_hint">各セッション終了後に保存</string>
<string name="setup_agent_commands">デバッグツール</string>
<string name="setup_latest_logs">最新のセッションログ</string>
<string name="setup_saved_logs">保存されたセッションログ</string>
<string name="setup_saved_logs_hint">最新の%1$d件を保持</string>
-1
View File
@@ -611,7 +611,6 @@
<string name="setup_session">세션</string>
<string name="setup_logs">세션 로그</string>
<string name="setup_logs_hint">각 세션 종료 후 저장</string>
<string name="setup_agent_commands">디버깅 도구</string>
<string name="setup_latest_logs">최신 세션 로그</string>
<string name="setup_saved_logs">저장된 세션 로그</string>
<string name="setup_saved_logs_hint">최신 %1$d개 유지</string>
+148 -248
View File
@@ -4,31 +4,31 @@
<string-array name="loading_hints">
<item>При первом запуске будет загружен Steam. Это может занять несколько минут.</item>
<item>По умолчанию при выходе из приложения Steam приостанавливается; при возвращении работа возобновляется с того же места.</item>
<item>Ваш контроллер отображается как геймпад Xbox 360. Если контроллера нет, используйте экранные элементы управления.</item>
<item>Ваш контроллер будет отображаться как геймпад Xbox 360. В противном случае используйте экранные элементы управления.</item>
</string-array>
<string-array name="loading_hints_desktop">
<item>При первом запуске будет настроен рабочий стол. Это может занять несколько минут.</item>
<item>По умолчанию при выходе из приложения сессия приостанавливается; при возвращении работа возобновляется с того же места.</item>
<item>Сенсорный ввод работает как тачпад: перетаскивайте для перемещения курсора, нажимайте для клика.</item>
<item>Ваш контроллер отображается как геймпад Xbox 360. Если контроллера нет, используйте экранные элементы управления.</item>
<item>Ваш контроллер будет отображаться как геймпад Xbox 360. В противном случае используйте экранные элементы управления.</item>
</string-array>
<string name="session_channel">Сессия</string>
<string name="session_channel_description">Отображается, пока открыта сессия Steam или рабочего стола</string>
<string name="session_channel_description">Отображается при открытии сессии Steam или рабочего стола</string>
<string name="session_notification">Steam запущен</string>
<string name="session_notification_paused">Сессия приостановлена — нажмите «Продолжить»</string>
<string name="session_notification_paused">Сессия приостановлена - нажмите, чтобы продолжить</string>
<string name="resume_session">Продолжить</string>
<string name="stop_session">Остановить сессию</string>
<string name="stop_session">Завершить сессию</string>
<string name="frame_gen_title">Генерация кадров</string>
<string name="frame_gen_hint">Выберите режим генерации кадров</string>
<string name="frame_gen_off">Выкл.</string>
<string name="frame_gen_winfg">Win-FG %1$d×</string>
<string name="frame_gen_lsfg">LSFG %1$d×</string>
<string name="frame_gen_lsfg_adaptive">LSFG Adaptive %1$d</string>
<string name="frame_gen_lsfg_adaptive">LSFG Адаптивный %1$d</string>
<string name="lsfg_needs_steam">Требуется Lossless Scaling из Steam</string>
<string name="lsfg_needs_install">Установите Lossless Scaling в Steam или импортируйте Lossless.dll</string>
<string name="lsfg_import">Импорт Lossless.dll…</string>
<string name="lsfg_using_steam">Используется копия из Steam</string>
<string name="lsfg_using_import">Используется импортированная копия</string>
<string name="lsfg_using_steam">Используется версия из Steam</string>
<string name="lsfg_using_import">Используется импортированная версия</string>
<string name="lsfg_pick_title">Выберите файл Lossless.dll</string>
<string name="lsfg_imported">Lossless.dll импортирован</string>
<string name="lsfg_import_unchanged">Этот Lossless.dll уже используется</string>
@@ -36,14 +36,14 @@
<string name="lsfg_import_invalid">Этот файл не является Lossless.dll от Lossless Scaling</string>
<string name="lsfg_import_failed">Не удалось подготовить LSFG из этого Lossless.dll</string>
<string name="lsfg_problem_missing">Для LSFG требуется Lossless Scaling из Steam или импортированный файл Lossless.dll</string>
<string name="lsfg_problem_failed">LSFG не удалось подготовить шейдеры из Lossless.dll</string>
<string name="lsfg_problem_failed">LSFG не может подготовить свои шейдеры из этого Lossless.dll</string>
<string name="hud_fg_unsupported">%1$s: не поддерживается</string>
<string name="hud_fg_failed">%1$s: сбой движка</string>
<string name="hud_fg_needs_lossless">%1$s: требуется Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · запуск</string>
<string name="added_games_import_hint">Папка, где каждая подпапка — отдельная игра для Windows. Игры из вашей библиотеки Steam после перезапуска Steam используют свои записи, остальные добавляются как ярлыки сторонних игр. Если для параметра «Вторая библиотека» выбрано «Авто», Steam также может устанавливать игры в эту папку.</string>
<string name="second_library_import_hint">Добавляет библиотеку Steam и ищет в ней игры для Windows. Игры из вашей библиотеки Steam используют свои записи Steam, остальные добавляются как ярлыки сторонних игр.</string>
<string name="added_games_forget_hint">«Забыть» удалит ярлыки в следующей сессии. Файлы останутся на диске.</string>
<string name="hud_fg_starting">%1$s %2$s · Запуск</string>
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. Если для параметра «Вторая библиотека» выбрано «Авто», Steam также может устанавливать игры в эту папку.</string>
<string name="second_library_import_hint">Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam.</string>
<string name="added_games_forget_hint">«Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске.</string>
<string name="game_env_title">Игровая среда</string>
<string name="game_env_hint">Переменные среды для игр Proton.</string>
<string name="game_env_edit">Изменить</string>
@@ -59,7 +59,7 @@
<string name="game_env_remove">Удалить</string>
<string name="game_env_restore">Восстановить наследование</string>
<string name="game_env_reset">Сбросить профиль</string>
<string name="game_env_unset">Не задавать в этом профиле</string>
<string name="game_env_unset">Снять настройку для профиля</string>
<string name="game_env_empty">Переменные не заданы.</string>
<string name="game_env_inherited">Унаследовано</string>
<string name="game_env_custom">Пользовательская переменная</string>
@@ -71,29 +71,19 @@
<string name="game_env_shader">Переопределение модели шейдеров D3D12. 6_9 требует достаточно нового VKD3D и соответствующих функций GPU. Оставьте пустым для автоопределения.</string>
<string name="game_env_cache">Поддерживать кэш шейдеров Mesa включенным для уменьшения повторной компиляции. Установите false, чтобы включить кэш.</string>
<string name="game_env_cache_size">Максимальный размер кэша шейдеров Mesa, например 1G. Больший кэш занимает больше места.</string>
<string name="game_env_vkd3d_config">Параметры VKD3D для D3D12. nodxr отключает трассировку лучей; используйте только для игры, которой это нужно.</string>
<string name="game_env_vkd3d_config">Параметры VKD3D для D3D12. nodxr отключает трассировку лучей; используйте только при необходимости в игре.</string>
<string name="game_env_proton_log">Установите 1 для создания диагностического журнала Proton. Ведение журнала может снизить производительность и занять место.</string>
<string name="game_env_wined3d">Установите 1, чтобы использовать WineD3D на базе OpenGL вместо DXVK для тестирования совместимости с D3D9–11.</string>
<string name="game_env_no_ntsync">Установите 1, чтобы не использовать ntsync (droiddeck-ntsync или драйвер ядра) для этой игры; тогда используется droiddeck-esync, если есть пакет, иначе droiddeck-fsync.</string>
<string name="game_env_no_esync">Установите 1, чтобы отключить droiddeck-esync для этой игры; вместо него используется droiddeck-fsync или droiddeck-ntsync, если fsync тоже отключен.</string>
<string name="game_env_no_fsync">Установите 1, чтобы не использовать droiddeck-fsync для этой игры; тогда используется droiddeck-esync, если есть пакет, иначе droiddeck-ntsync.</string>
<string name="game_env_fsync_first">Установите 1, чтобы запускать эту игру на droiddeck-fsync, или 0, чтобы ставить droiddeck-esync первым, когда для ее Proton есть пакет.</string>
<string name="game_env_xalia">Установите 0, чтобы отключить помощник навигации геймпада Proton для этой игры.</string>
<string name="game_env_hud">Оверлей DXVK для D3D9–11. fps показывает частоту кадров; если не задано — оверлей скрыт.</string>
<string name="game_env_hud">Оверлей DXVK для D3D9–11. fps показывает частоту кадров; снимите настройку, чтобы скрыть оверлей.</string>
<string name="game_env_dxvk_config">Настройки DXVK, разделенные точкой с запятой. В последних версиях используется dxvk.maxFrameRate для ограничения частоты кадров; для повседневной игры рекомендуется использовать ограничитель частоты кадров сессии.</string>
<string name="game_env_vkd3d_limit">Ограничение частоты кадров D3D12. 0 отключает ограничение.</string>
<string name="game_env_glthread">Многопоточность Mesa OpenGL. Для игр OpenGL с ограничением CPU значение true может помочь; тестируйте отдельно для каждой игры.</string>
<string name="sync_backend_title">Синхронизация Wine</string>
<string name="sync_backend_hint">Как игры синхронизируют свои потоки. Вступит в силу в следующей сессии.</string>
<string name="sync_backend_esync">esync</string>
<string name="sync_backend_ntsync">ntsync</string>
<string name="sync_backend_fsync">fsync</string>
<string name="sync_backend_wineserver">wineserver</string>
<string name="fex_preset_title">Пресет FEX</string>
<string name="fex_preset_title">Пресеты FEX</string>
<string name="fex_next_launch">Вступит в силу при следующем запуске игры.</string>
<string name="game_settings_title">Игры</string>
<string name="game_settings_title">Игра</string>
<string name="fex_default_label">FEX по умолчанию</string>
<string name="fex_default_detail">Настройки FEX по умолчанию.</string>
<string name="fex_default_detail">Использовать настройки FEX по умолчанию.</string>
<string name="fex_stability_label">Стабильность</string>
<string name="fex_stability_detail">Самые безопасные и медленные настройки.</string>
<string name="fex_compatibility_label">Совместимость</string>
@@ -101,17 +91,17 @@
<string name="fex_intermediate_label">Средний</string>
<string name="fex_intermediate_detail">Сбалансированные настройки TSO со сниженной точностью x87.</string>
<string name="fex_performance_label">Производительность</string>
<string name="fex_performance_detail">Весь TSO отключен; multiblock.</string>
<string name="fex_performance_detail">Отключить все TSO; включить multiblock.</string>
<string name="fex_performance_tso_label">Производительность + TSO</string>
<string name="fex_performance_tso_detail">Настройки производительности с включенным основным TSO.</string>
<string name="fex_extreme_label">Экстрим</string>
<string name="fex_extreme_detail">Отключает проверки самомодифицирующегося кода. Игры с JIT и DRM могут сломаться.</string>
<string name="fex_extreme_detail">Отключить проверку самомодифицирующегося кода. Игры JIT и DRM могут не работать.</string>
<string name="fex_extreme_tso_label">Экстрим + TSO</string>
<string name="fex_extreme_tso_detail">Настройки экстрим с включенным основным TSO.</string>
<string name="fex_extreme_gn_label">Экстрим-gn</string>
<string name="fex_extreme_gn_detail">Настройки производительности с включенными TSC и подсказками метаданных. Рекомендуется сначала попробовать этот режим, а затем «Экстрим».</string>
<string name="fex_denuvo_label">Denuvo</string>
<string name="fex_denuvo_detail">Настройки производительности с полной проверкой кода и скрытым битом гипервизора.</string>
<string name="fex_denuvo_detail">Настройки производительности с включенной полной проверкой кода и скрытием битов гипервизора.</string>
<string name="game_env_custom_value">Пользовательское значение</string>
<string name="game_env_select_value">Выберите значение</string>
<string name="user_apps_section">Эмуляторы и приложения</string>
@@ -123,14 +113,14 @@
<string name="user_apps_progress">%1$s · %2$s</string>
<string name="user_apps_progress_percent">%1$s · %2$s · %3$d%%</string>
<string name="user_apps_starting">Запуск…</string>
<string name="user_apps_added">Добавлено: %1$s</string>
<string name="user_apps_removed">Удалено: %1$s</string>
<string name="user_apps_added">%1$s добавлено</string>
<string name="user_apps_removed">%1$s удалено</string>
<string name="user_apps_back">Рабочий стол</string>
<string name="user_apps_gone">Это приложение больше недоступно. Выберите его заново на рабочем столе.</string>
<string name="user_apps_gone">Приложение не существует. Выберите его заново на рабочем столе.</string>
<string name="user_apps_open">Открыть %1$s</string>
<string name="user_apps_remove">Удалить</string>
<string name="user_apps_remove_confirm">Нажмите еще раз для удаления</string>
<string name="user_apps_stop_session">Чтобы удалить, остановите сессию</string>
<string name="user_apps_stop_session">Сначала завершите сессию</string>
<string name="user_apps_script_linked">Запускает %1$s из своей папки и связывает эту папку с сессией. Удаление приложения не повлияет на папку.</string>
<string name="user_apps_script_copied">Запускает %1$s из папки, скопированной в DroidDeck. Удаление приложения удалит копию, но не оригинал.</string>
<string name="user_apps_runtime_required">Сначала установите среду выполнения Linux</string>
@@ -140,18 +130,18 @@
<string name="user_apps_failed">Ошибка добавления приложения</string>
<string name="user_apps_github_checking">Поиск выпусков %1$s</string>
<string name="user_apps_github_unreachable">Не удалось прочитать выпуски %1$s</string>
<string name="user_apps_github_no_appimage">Ни в одном выпуске %1$s нет AppImage для ARM64 или x86_64</string>
<string name="user_apps_github_no_appimage">%1$s не предоставляет выпуск ARM64 AppImage</string>
<string name="user_apps_downloading">Загрузка %1$s</string>
<string name="user_apps_download_failed">Ошибка загрузки</string>
<string name="user_apps_update_failed">Ошибка обновления приложения</string>
<string name="user_apps_icon_failed">Не удалось прочитать иконку</string>
<string name="user_apps_saved">Сохранено: %1$s</string>
<string name="user_apps_saved">%1$s сохранено</string>
<string name="user_apps_edit">Изменить</string>
<string name="user_apps_check_updates">Проверить обновления</string>
<string name="user_apps_checking_updates">Проверка обновлений…</string>
<string name="user_apps_update_to">Обновить до %1$s</string>
<string name="user_apps_up_to_date">Актуально · %1$s</string>
<string name="user_apps_updated">Обновлено: %1$s</string>
<string name="user_apps_updated">%1$s обновлено</string>
<string name="user_apps_github_version">%1$s · %2$s</string>
<string name="add_app_title">Добавить приложение</string>
<string name="add_app_tab_script">Скрипт</string>
@@ -159,18 +149,9 @@
<string name="add_app_tab_github">GitHub</string>
<string name="add_app_tab_flatpak">Flatpak</string>
<string name="add_app_script_hint">Файл .sh. Запускается из своей папки и отображается в полноэкранном режиме через gamescope.</string>
<string name="add_app_appimage_hint">AppImage для ARM64 (aarch64) или для x86_64 — тогда он работает через FEX. Распаковывается в среду выполнения Linux один раз.</string>
<string name="add_app_github_hint">Устанавливает ARM64 AppImage из последнего выпуска репозитория или x86_64-версию через FEX.</string>
<string name="app_fex_title">Использовать FEX</string>
<string name="app_fex_auto">Авто</string>
<string name="app_fex_on">Вкл.</string>
<string name="app_fex_off">Выкл.</string>
<string name="app_fex_hint">FEX запускает x86-программы Linux на этом ARM64-устройстве. «Авто» использует его, если программа собрана для x86.</string>
<string name="app_fex_x86">%1$s через FEX</string>
<string name="app_fex_not_ready">Эта программа собрана для ПК на %1$s и работает через FEX, который еще не настроен. Откройте Steam один раз, войдя в аккаунт: DroidDeck скачает через него FEX и нужную Steam Linux Runtime, после чего программа откроется.</string>
<string name="session_end_program">Эта программа</string>
<string name="session_end_fex_missing">%1$s — сборка для x86-ПК, которая работает через FEX, а он еще не установлен. Запустите Steam один раз, войдя в аккаунт, и дайте ему скачать FEX и Steam Linux Runtime, затем запустите программу снова.</string>
<string name="add_app_flatpak_hint">ID приложения Flathub или ссылка на его страницу. Если Flatpak еще не настроен, сначала будет выполнена его настройка.</string>
<string name="add_app_appimage_hint">ARM64 (aarch64) AppImage. Распаковывается в среду выполнения Linux при первом использовании.</string>
<string name="add_app_github_hint">Установить ARM64 AppImage из последнего выпуска репозитория.</string>
<string name="add_app_flatpak_hint">ID приложения Flathub или его страница Flathub. Если Flatpak не настроен, он будет настроен сначала.</string>
<string name="add_app_file">Файл</string>
<string name="add_app_no_file">Файл не выбран</string>
<string name="add_app_choose_file">Выбрать файл</string>
@@ -185,7 +166,7 @@
<string name="add_app_flatpak_placeholder">org.ppsspp.PPSSPP</string>
<string name="add_app_flatpak_invalid">Недействительный ID приложения Flathub</string>
<string name="add_app_source">Источник</string>
<string name="add_app_appearance">На рабочем столе</string>
<string name="add_app_appearance">Показывать на рабочем столе</string>
<string name="add_app_preview_name">Новое приложение</string>
<string name="add_app_name">Название</string>
<string name="add_app_name_placeholder">Из приложения</string>
@@ -203,11 +184,8 @@
<string name="add_app_folder_copied">Эта папка содержит программы Linux, которые не могут работать из общего хранилища, поэтому она будет скопирована в DroidDeck (%1$s).</string>
<string name="add_app_confirm">Добавить</string>
<string name="add_app_cancel">Отмена</string>
<string name="add_app_runtime_required">Сначала установите среду выполнения Linux на странице «Настройка».</string>
<string name="add_app_runtime_required">Сначала установите среду выполнения Linux в «Настройках».</string>
<string name="add_app_busy">Подождите, пока завершится %1$s.</string>
<string name="steam_compat_label">Совместимо</string>
<string name="proton_compatible_note">Сборки с пометкой «%1$s» в меню совместимости Steam содержат исправления префикса от DroidDeck и запускают игры здесь. По умолчанию используется Proton Experimental ARM64 (%1$s) или Proton 11.0 ARM64 (%1$s), когда эта более новая сборка установлена. Собственные записи Proton от Valve без пометки не могут запустить игру на этом устройстве.</string>
<string name="proton_installed_compatible">Установлено %1$s · %2$s</string>
<string name="common_cancel">Отмена</string>
<string name="common_auto">Авто</string>
<string name="common_never">Никогда</string>
@@ -219,7 +197,7 @@
<string name="common_working">Обработка…</string>
<string name="common_forget">Забыть</string>
<string name="common_add_ellipsis">Добавить…</string>
<string name="common_use">Применить</string>
<string name="common_use">Использовать</string>
<string name="common_applies_next_session">Вступит в силу в следующей сессии.</string>
<string name="mode_steam_title">Сессия Steam</string>
<string name="mode_desktop_title">Сессия рабочего стола</string>
@@ -230,14 +208,14 @@
<string name="mode_res_1080">До 1080p</string>
<string name="mode_res_native">Нативное</string>
<string name="mode_res_default">%1$s (по умолчанию)</string>
<string name="mode_res_custom_value">Свое · %1$d×%2$d</string>
<string name="mode_res_custom">Свое…</string>
<string name="mode_res_custom_value">Пользовательское · %1$d×%2$d</string>
<string name="mode_res_custom">Пользовательское…</string>
<string name="mode_res_note">720p может ускорить отклик меню.</string>
<string name="mode_ratio">Соотношение сторон</string>
<string name="mode_ratio_custom">Задается пользовательским разрешением.</string>
<string name="mode_ratio_auto">Автоматический режим использует не менее 16:9.</string>
<string name="mode_fps">Ограничение частоты кадров</string>
<string name="mode_fps_note">Ограничивает частоту кадров всей сессии. Экран переключится на частоту обновления, кратную этому пределу.</string>
<string name="mode_fps_note">Ограничивает частоту кадров всей сессии. Монитор переключится на частоту обновления, кратную этому пределу.</string>
<string name="mode_hdr">HDR</string>
<string name="mode_hdr10">Вывод HDR10</string>
<string name="mode_hdr_unavailable">Недоступно: %1$s.</string>
@@ -246,7 +224,7 @@
<string name="mode_gpu_drivers">Драйверы GPU</string>
<string name="mode_gpu_drivers_hint">Общие для Steam и рабочего стола. Настройте на странице «Компоненты».</string>
<string name="mode_touch_controls">Сенсорный ввод и управление</string>
<string name="mode_touch">Сенсорный ввод</string>
<string name="mode_touch">Сенсорный</string>
<string name="mode_touch_touchpad">Тачпад</string>
<string name="mode_touch_direct">Прямой</string>
<string name="mode_touch_note">Авто: тачпад для рабочего стола, прямой сенсорный ввод для Steam.</string>
@@ -263,9 +241,7 @@
<string name="mode_suspend">Поведение в фоне</string>
<string name="mode_suspend_hint">Поведение при скрытии приложения или выключении экрана.</string>
<string name="mode_suspend_manual">Вручную</string>
<string name="mode_suspend_native">Штатный</string>
<string name="mode_suspend_steam_note">Авто: пауза и возобновление при показе. Штатный: сначала подготавливает Steam к переходу в режим сна. Вручную: ждет нажатия «Продолжить». Никогда: сессия продолжает работать.</string>
<string name="mode_suspend_note">Авто: пауза в фоне и возобновление при показе. Вручную: ждет нажатия «Продолжить». Никогда: сессия продолжает работать.</string>
<string name="mode_suspend_note">Автоматический режим приостанавливает работу в фоне и возобновляет при отображении. Ручной режим требует нажатия «Продолжить». Никогда не остается запущенным.</string>
<string name="mode_startup">Запуск</string>
<string name="mode_steam_startup">Запускать Steam при открытии DroidDeck</string>
<string name="mode_steam_startup_hint">Открывает сессию Steam при запуске DroidDeck.</string>
@@ -277,8 +253,8 @@
<string name="mode_decky_update">Доступно обновление · %1$s</string>
<string name="mode_decky_current">Актуально · %1$s</string>
<string name="mode_decky_loader">Загрузчик</string>
<string name="mode_decky_install_latest">Установить последнюю</string>
<string name="mode_decky_on">Запускается вместе со Steam. Пока включено, другие приложения на этом устройстве могут управлять Steam.</string>
<string name="mode_decky_install_latest">Установить последнюю версию</string>
<string name="mode_decky_on">Запускается вместе со Steam. После включения другие приложения на этом устройстве могут управлять Steam.</string>
<string name="mode_decky_off">Выкл. Порт удаленной отладки Steam остается закрытым.</string>
<string name="mode_decky_remove_title">Удалить Decky Loader?</string>
<string name="mode_decky_remove_text">Удаляет загрузчик. Ваши плагины и настройки сохранятся.</string>
@@ -286,41 +262,37 @@
<string name="mode_deck_mode">Режим Steam Deck</string>
<string name="mode_deck_mode_hint">Интерфейс Steam Deck и управление производительностью через быстрый доступ. Вступит в силу в следующей сессии.</string>
<string name="mode_mangoapp">Оверлей производительности</string>
<string name="mode_mangoapp_hint">Отключите, если игры вылетают или показывают черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
<string name="mode_mangoapp_hint">Отключите, если игры падают или появляется черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
<string name="mode_branch">Ветка клиента</string>
<string name="mode_branch_deck">Публичная бета Steam Deck (требуется для режима Deck).</string>
<string name="mode_branch_deck">Бета-версия Steam Deck (требуется для режима Deck).</string>
<string name="mode_branch_hint">Вступит в силу в следующей сессии. Steam может обновиться самостоятельно один раз.</string>
<string name="mode_branch_public">Публичная бета</string>
<string name="mode_branch_deck_beta">Публичная бета Steam Deck</string>
<string name="mode_branch_note">Режим Deck всегда использует публичную бету Steam Deck.</string>
<string name="mode_steam_repair">Восстановить клиент Steam</string>
<string name="mode_steam_repair_hint">При следующем запуске Steam клиент будет скачан заново. Используйте, если Steam зависает на экране загрузки. Игры, вход и настройки сохраняются.</string>
<string name="mode_steam_repair_queued">Клиент Steam будет скачан заново при следующем запуске Steam.</string>
<string name="mode_steam_repair_button">Восстановить</string>
<string name="mode_branch_public">Бета</string>
<string name="mode_branch_deck_beta">Бета-версия Steam Deck</string>
<string name="mode_branch_note">Режим Deck всегда использует бета-версию Steam Deck.</string>
<string name="mode_added_games">Добавленные игры</string>
<string name="mode_added_none">папок с играми (.exe) не найдено</string>
<string name="mode_added_none">Папки с играми .exe не найдены</string>
<string name="mode_games_folder">Папка с играми</string>
<string name="mode_games_folder_another">Другая папка с играми</string>
<string name="mode_added_art">Обложки из Steam</string>
<string name="mode_added_art">Изображения из Steam</string>
<string name="mode_added_art_hint">Дополняет отсутствующие изображения по имени папки. Файлы cover.jpg, hero.jpg, logo.png или icon.png в папке с игрой имеют приоритет.</string>
<string name="mode_added_launches">Запускает %1$s</string>
<string name="mode_added_launches_in">Запускает %1$s · в %2$s</string>
<string name="mode_added_launches">Запустить %1$s</string>
<string name="mode_added_launches_in">Запустить %1$s · в %2$s</string>
<string name="mode_added_choose">Выбрать другой файл…</string>
<string name="mode_added_exe_note">По умолчанию используется .exe с именем папки, иначе самый большой .exe.</string>
<string name="mode_stretch169">Растянуть игры 16:9</string>
<string name="mode_stretch169_hint">На экранах уже 16:9 растягивает игры на весь экран вместо черных полос. Вступит в силу в следующей сессии.</string>
<string name="mode_stretch169_hint">На экранах с соотношением сторон менее 16:9 растягивает игру на весь экран вместо черных полос. Вступит в силу в следующей сессии.</string>
<string name="mode_audio">Аудио</string>
<string name="mode_directaudio">DirectAudio для игр</string>
<string name="mode_directaudio">Использовать DirectAudio в игре</string>
<string name="mode_directaudio_hint">Обходит PulseAudio для снижения задержки.</string>
<string name="mode_client_audio">Аудио клиента Steam</string>
<string name="mode_client_audio_hint">Вступит в силу в следующей сессии.</string>
<string name="mode_client_audio_classic">Классическое</string>
<string name="mode_client_audio_classic">Классический</string>
<string name="mode_client_audio_direct">DirectAudio</string>
<string name="mode_mic">Микрофон</string>
<string name="mode_mic_hint">Используется для голосового чата.</string>
<string name="mode_network">Сеть</string>
<string name="mode_wifi_discovery">Обнаружение Wi-Fi</string>
<string name="mode_wifi_discovery_hint">Показывает названия сетей Wi-Fi и сети поблизости в Steam.</string>
<string name="mode_wifi_discovery_hint">Показывает названия Wi-Fi и ближайшие сети в Steam.</string>
<string name="mode_wifi_location_off">Службы геолокации отключены.</string>
<string name="mode_wifi_permission_denied">Разрешение на определение местоположения не предоставлено.</string>
<string name="mode_wifi_open_settings">Открыть настройки</string>
@@ -328,21 +300,19 @@
<string name="mode_wifi_explain_title">Включить обнаружение Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения.</string>
<string name="mode_storage">Хранилище игр</string>
<string name="mode_storage_auto">Авто — SD-карта (если есть) и ваши папки с играми</string>
<string name="mode_storage_auto_none">Авто — ваши папки с играми и SD-карта, если есть (сейчас SD-карты нет)</string>
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии, а также ваши папки с играми</string>
<string name="mode_storage_auto_none">Авто - Использовать ваши папки с играми, а также SD-карту при наличии (сейчас нет SD-карты)</string>
<string name="mode_storage_internal">Только внутреннее хранилище</string>
<string name="mode_storage_folder">Папка: %1$s</string>
<string name="mode_second_library">Вторая библиотека</string>
<string name="mode_storage_note">Игры, читающие ресурсы с SD-карты или из общего хранилища, могут подтормаживать.</string>
<string name="mode_storage_diagnostics">Диагностика хранилища</string>
<string name="mode_storage_diagnostics_hint">Записывает результаты выделения места и выборочные замеры скорости хранилища в журналы. Пока включено, установка может идти медленнее. Вступит в силу в следующей сессии.</string>
<string name="mode_choose_folder">Выбрать папку…</string>
<string name="mode_renderer">Рендерер</string>
<string name="mode_desktop_renderer">Рендерер рабочего стола</string>
<string name="mode_renderer_hint">Отвечает за отрисовку рабочего стола.</string>
<string name="mode_renderer_vulkan">vulkan — GPU</string>
<string name="mode_renderer_gles2">gles2 — GPU (экспериментально)</string>
<string name="mode_renderer_pixman">pixman — программный</string>
<string name="mode_renderer_vulkan">vulkan - GPU</string>
<string name="mode_renderer_gles2">gles2 - GPU (экспериментально)</string>
<string name="mode_renderer_pixman">pixman - Программный</string>
<string name="mode_renderer_note">Vulkan использует GPU для запуска программ рабочего стола, при сбое переключается на pixman. При использовании pixman игры по-прежнему запускаются в полноэкранном режиме на GPU.</string>
<string name="mode_custom_res_title">Пользовательское разрешение</string>
<string name="mode_custom_res_text">Заменяет предел разрешения и соотношение сторон. Размеры, не соответствующие экрану, будут отображаться с черными полосами.</string>
@@ -367,24 +337,17 @@
<string name="comp_display_driver">Драйвер дисплея</string>
<string name="comp_display_driver_hint">Отвечает за вывод изображения на экран в обоих режимах. Вступит в силу после перезапуска приложения.</string>
<string name="comp_import_adrenotools">Импорт AdrenoTools zip…</string>
<string name="comp_never">никогда</string>
<string name="comp_nightlies_checked">Nightlies проверены: %1$s</string>
<string name="comp_never">Никогда</string>
<string name="comp_nightlies_checked">Nightlies проверено %1$s</string>
<string name="comp_title">Компоненты</string>
<string name="comp_about">О компонентах</string>
<string name="comp_check_drivers">Проверить новые драйверы</string>
<string name="comp_check_nightlies">Проверить новые пакеты в Nightlies</string>
<string name="comp_proton">Proton</string>
<string name="comp_proton_note">Выбранный здесь Proton запускает все игры, для которых в Steam не выбран свой; изменение, сделанное в Steam, тоже отображается здесь. При обновлении Steam заменяет Proton от Valve; оригинальные файлы каждой сборки сохраняются.</string>
<string name="comp_default_live">%1$s теперь запускает все игры, для которых в Steam не выбран свой Proton.</string>
<string name="comp_default_next">%1$s будет запускать все игры, для которых не выбран свой Proton, со следующего запуска Steam.</string>
<string name="comp_default_not_runnable">%1$s не может запускать игры здесь, поэтому не может быть Proton по умолчанию.</string>
<string name="comp_default_failed">Не удалось сохранить Proton по умолчанию: %1$s</string>
<string name="comp_proton_note">При обновлении Steam заменяет Proton от Valve; оригинальные файлы каждой сборки сохраняются.</string>
<string name="comp_swapped">%1$s · заменено %2$d</string>
<string name="comp_all_original">%1$s · все оригинальные файлы</string>
<string name="comp_game_running_suffix">%1$s · игра запущена</string>
<string name="comp_esync_pack">%1$s · пакет droiddeck-esync</string>
<string name="comp_esync_wanted">%1$s · пакета droiddeck-esync пока нет</string>
<string name="comp_esync_builtin">%1$s · esync встроен, пакет не нужен</string>
<string name="comp_gpu_drivers">Драйверы GPU</string>
<string name="comp_reapplied">%1$s · повторно применено при запуске %2$s</string>
<string name="comp_next_queued">Далее: %1$s · после закрытия игры</string>
@@ -393,7 +356,7 @@
<string name="comp_applies_next_game">Вступит в силу при следующем запуске игры</string>
<string name="comp_import_zip">Импорт .zip</string>
<string name="comp_reading">Чтение Proton…</string>
<string name="comp_no_proton">Proton еще не установлен. Запустите Steam один раз, чтобы скачать ARM64 Proton, или добавьте GE-Proton или CachyOS на странице «Настройка».</string>
<string name="comp_no_proton">Proton не установлен. Запустите Steam один раз для загрузки ARM64 Proton или добавьте GE-Proton или CachyOS в «Настройках».</string>
<string name="comp_original">Оригинал · %1$s</string>
<string name="comp_this_build">%1$s · текущая сборка</string>
<string name="comp_earlier_build">%1$s · более ранняя сборка</string>
@@ -402,19 +365,19 @@
<string name="comp_waits_long">Игра использует этот Proton, поэтому нужно дождаться ее закрытия.</string>
<string name="comp_applies_long">Вступит в силу при следующем запуске игры.</string>
<string name="comp_swap_title">Заменить %1$s?</string>
<string name="comp_swap_body">Поместить %1$s в %2$s? Оригинальные файлы остаются сохраненными. %3$s</string>
<string name="comp_swap_body">Поместить %1$s в %2$s? Оригинальные файлы продолжат сохраняться. %3$s</string>
<string name="comp_restore_title">Восстановить %1$s?</string>
<string name="comp_restore_body">Вернуть оригинальные файлы %1$s в %2$s? %3$s</string>
<string name="comp_delete_package_body">Удаляет пакет из приложения. Его можно будет скачать снова.</string>
<string name="comp_delete_package_body">Удалить пакет из приложения. Его можно будет скачать снова.</string>
<string name="comp_delete_original_title">Удалить оригинальную версию?</string>
<string name="comp_delete_original_body">Эта оригинальная версия %1$s из более ранней сборки %2$s. Оригинальная версия текущей сборки всегда сохраняется.</string>
<string name="comp_nightlies_count">В Nightlies · %1$d</string>
<string name="comp_refresh_to_list">Обновите, чтобы увидеть пакеты в Nightlies.</string>
<string name="comp_refresh_to_list">Обновить, чтобы показать пакеты в Nightlies.</string>
<string name="comp_nothing_new">С момента последней проверки нового нет.</string>
<string name="comp_import_wcp">Импорт .wcp</string>
<string name="comp_restore_original">Восстановить оригинальные файлы этого Proton</string>
<string name="comp_restore_original_body">Вернуть оригинальный %2$s для %1$s?</string>
<string name="comp_swap">Заменить</string>
<string name="comp_restore_original_body">Вернуть оригинальные файлы %2$s из %1$s?</string>
<string name="comp_swap">Переключить</string>
<string name="comp_about_text">FEX, DXVK и VKD3D-Proton: для каждого Proton, используемого Steam для запуска игр.\n\nВыберите установленный элемент, чтобы заменить его. Это вступит в силу при следующем запуске игры или после закрытия текущей запущенной игры.\n\nВаш выбор будет перепроверяться перед каждым запуском; если он был изменен, он будет восстановлен. Оригинальные файлы каждого Proton сохраняются, поэтому обновления Steam никогда их не потеряют.</string>
<string name="comp_tag_original">Оригинал</string>
<string name="comp_tag_in_use">Используется</string>
@@ -424,7 +387,7 @@
<string name="perf_lede">Ядра CPU, исправления сессии и имя хоста. Вступит в силу в следующей сессии.</string>
<string name="perf_client_cores_group">Ядра клиента Steam</string>
<string name="perf_override">Переопределить выбор ядер Steam</string>
<string name="perf_override_hint">Закрепляет Steam, его помощник интерфейса и gamescope за ядрами ниже.</string>
<string name="perf_override_hint">Закрепить Steam, помощник интерфейса и gamescope за выбранными ниже ядрами.</string>
<string name="perf_client_cores">Ядра клиента</string>
<string name="perf_client_cores_on">Ядра, используемые Steam.</string>
<string name="perf_client_cores_off">Включите переопределение для выбора.</string>
@@ -437,7 +400,7 @@
<string name="perf_zink">Zink: отложенные дескрипторы</string>
<string name="perf_zink_hint">Рекомендуется для драйверов без буфера дескрипторов.</string>
<string name="perf_noglerror">Пропустить проверку ошибок GL</string>
<string name="perf_noglerror_hint">Отключает проверку ошибок GL при каждом вызове.</string>
<string name="perf_noglerror_hint">Отключить проверку GL для каждого вызова.</string>
<string name="perf_gsrealtime">gamescope: очередь GPU в реальном времени</string>
<string name="perf_gsrealtime_hint">Повышает приоритет работы GPU композитора. Может улучшить стабильность времени кадра, но потребляет часть производительности игры.</string>
<string name="perf_gpu">GPU</string>
@@ -447,19 +410,13 @@
<string name="perf_sysmem">Turnip: рендеринг sysmem</string>
<string name="perf_sysmem_hint">Steam и большинство игр будут быстрее. Требуется для Adreno 710/720/722.</string>
<string name="perf_xalia">Пропустить помощник xalia в Steam</string>
<string name="perf_fastsync">droiddeck-ntsync (экспериментально)</string>
<string name="perf_fastsync_hint">Замена драйвера ntsync Linux в пространстве пользователя, для тестирования. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии.</string>
<string name="perf_esync">droiddeck-esync</string>
<string name="perf_fsync">droiddeck-fsync в приоритете</string>
<string name="perf_fsync_hint">Запускает собственный fsync Proton на стандартных файлах Proton, а futex_waitv предоставляется в пространстве пользователя: пакет не нужен, и все продолжает работать сразу после обновления Proton в Steam. Пока включено, игры используют его раньше droiddeck-esync, кроме тех немногих, для которых Proton отключает fsync. Вступит в силу в следующей сессии.</string>
<string name="perf_esync_hint">Потоки Windows в играх ждут друг друга через eventfd и общую память вместо обращений к wineserver. Использует пакет droiddeck-esync, собранный под конкретную сборку Proton; пакеты скачиваются автоматически. Пока для новой сборки Proton пакета нет, его заменяет droiddeck-fsync. Вступит в силу в следующей сессии.</string>
<string name="perf_xalia_hint">Отключает помощник навигации геймпада Proton, который потребляет CPU в каждой игре.</string>
<string name="perf_seccomp">Запускать proot без seccomp</string>
<string name="perf_seccomp_hint">Может исправить ошибки отсутствующих системных вызовов на некоторых ядрах, но может снизить производительность.</string>
<string name="perf_fastpath">Быстрый путь proot</string>
<string name="perf_fastpath_needs_seccomp">Требуется фильтр seccomp для proot.</string>
<string name="perf_fastpath_hint">Быстрый поиск файлов, меньше подтормаживаний. Отключите, если программа не находит файлы.</string>
<string name="perf_identity">Имя сессии</string>
<string name="perf_identity">Идентификация сессии</string>
<string name="perf_hostname">Имя хоста</string>
<string name="perf_hostname_hint">Имя, под которым Steam видит это устройство. Оставьте пустым, чтобы вернуть %1$s.</string>
<string name="perf_hostname_invalid">Только буквы, цифры и дефисы посередине, до 63 символов.</string>
@@ -467,16 +424,14 @@
<string name="driver_installed">Установлен</string>
<string name="driver_available">Доступен для скачивания</string>
<string name="driver_nothing_new">С момента последней проверки нового нет.</string>
<string name="driver_restore_builtin">Восстановить встроенные драйверы</string>
<string name="driver_delete_builtin">Встроен в приложение: распакованные файлы будут удалены, а драйвер скрыт. Вернуть его можно кнопкой «Восстановить встроенные драйверы».</string>
<string name="driver_restore_builtin">Восстановить встроенный драйвер</string>
<string name="driver_delete_builtin">Встроенный драйвер приложения: удалит распакованные файлы и скроет драйвер. Используйте «Восстановить встроенный драйвер» для восстановления.</string>
<string name="driver_delete_bundle">Половина пакета Android + Linux: обе части будут удалены. Используемый драйвер вернется к драйверу по умолчанию.</string>
<string name="driver_delete_plain">Файлы будут удалены из приложения.</string>
<string name="driver_delete_in_use">Файлы будут удалены. Поскольку драйвер используется, будет применен драйвер по умолчанию.</string>
<string name="drawer_page_display">Экран</string>
<string name="drawer_page_effects">Эффекты</string>
<string name="drawer_page_controls">Управление</string>
<string name="drawer_page_components">Компоненты</string>
<string name="drawer_page_controller">Контроллер</string>
<string name="drawer_page_settings">Настройки</string>
<string name="drawer_page_desc">Страница %1$s</string>
<string name="drawer_steam">Steam</string>
@@ -487,39 +442,11 @@
<string name="drawer_prev_page">Предыдущая страница</string>
<string name="drawer_next_page">Следующая страница</string>
<string name="drawer_hud">HUD производительности</string>
<string name="drawer_scaling">Режим масштабирования</string>
<string name="drawer_scaling_sharpness">Резкость масштабирования</string>
<string name="drawer_effects">Экранные эффекты</string>
<string name="drawer_effects_hint">Применяется сразу. Пока включен любой эффект, немного нагружает GPU.</string>
<string name="drawer_look">Пресеты</string>
<string name="drawer_look_custom">Свой</string>
<string name="drawer_effects_advanced">Дополнительно</string>
<string name="widgets_expanded">Развернуто</string>
<string name="widgets_collapsed">Свернуто</string>
<string name="drawer_cas">Резкость (CAS)</string>
<string name="drawer_cas_level">Уровень резкости</string>
<string name="drawer_fake_hdr">Псевдо-HDR</string>
<string name="drawer_deband">Удаление полос (debanding)</string>
<string name="drawer_deband_strength">Сила удаления полос</string>
<string name="drawer_brightness">Яркость</string>
<string name="drawer_contrast">Контраст</string>
<string name="drawer_gamma">Гамма</string>
<string name="drawer_saturation">Насыщенность</string>
<string name="drawer_fxaa">FXAA (сглаживание краев)</string>
<string name="drawer_toon">Контуры (toon)</string>
<string name="drawer_crt">CRT</string>
<string name="drawer_ntsc">NTSC</string>
<string name="drawer_texture">Фильтрация текстур</string>
<string name="drawer_texture_hint">Игры DirectX 9–11, со следующего их запуска.</string>
<string name="drawer_anisotropy">Анизотропная фильтрация</string>
<string name="drawer_texture_sharpness">Резкость текстур</string>
<string name="drawer_texture_sharpness_note">Отрицательное смещение мип-уровней: текстуры резче, возможно мерцание. «Авто» подбирает значение по тому, насколько изображение сессии увеличено до размера экрана.</string>
<string name="drawer_touch_auto">Авто (%1$s)</string>
<string name="drawer_keyboard">Клавиатура</string>
<string name="drawer_pc_keyboard">Клавиатура ПК</string>
<string name="drawer_android_keyboard">Android</string>
<string name="drawer_second_screen">Второй экран</string>
<string name="drawer_second_screen_mode">Режим</string>
<string name="drawer_second_screen_shows">Показывать</string>
<string name="drawer_android_apps">Приложения Android</string>
<string name="drawer_launch_app">Запустить приложение</string>
@@ -531,16 +458,16 @@
<string name="drawer_next_session">Следующая сессия</string>
<string name="drawer_support">Поддержка</string>
<string name="drawer_logs">Журналы сессии</string>
<string name="drawer_logs_hint">Приложите к отчету об ошибке</string>
<string name="drawer_logs_hint">Прикрепить к отчету об ошибке</string>
<string name="drawer_share_logs">Поделиться журналами</string>
<string name="drawer_background">В фон</string>
<string name="drawer_background">Фон</string>
<string name="drawer_stop">Остановить</string>
<string name="drawer_stop_title">Остановить сессию?</string>
<string name="drawer_running_now">Сейчас выполняется</string>
<string name="drawer_no_game">Сейчас нет игр, запущенных через Proton</string>
<string name="drawer_no_proton">Proton не установлен.</string>
<string name="drawer_game_running">игра запущена</string>
<string name="drawer_reapplied">повторно применено при запуске %1$s</string>
<string name="drawer_game_running">Игра запущена</string>
<string name="drawer_reapplied">Повторно применено при запуске %1$s</string>
<string name="drawer_original">Оригинал</string>
<string name="drawer_components_note">Скачивайте, импортируйте и удаляйте на странице «Компоненты».</string>
<string name="setup_check_done">Готово</string>
@@ -589,27 +516,23 @@
<string name="setup_session">Сессия</string>
<string name="setup_logs">Журналы сессии</string>
<string name="setup_logs_hint">Сохраняется после каждой сессии</string>
<string name="setup_agent_commands">Инструменты отладки</string>
<string name="setup_latest_logs">Последние журналы сессии</string>
<string name="setup_saved_logs">Сохраненные журналы сессии</string>
<string name="setup_saved_logs_hint">Хранятся последние %1$d</string>
<string name="setup_saved_logs_hint">Хранить последние %1$d</string>
<string name="setup_clear_logs">Очистить журналы</string>
<string name="setup_offline">Автономный режим</string>
<string name="setup_sign_in_first">Сначала войдите в Steam</string>
<string name="setup_launcher">Лаунчер</string>
<string name="setup_app_scale">Масштаб приложения</string>
<string name="setup_app_scale_hint">Текст и элементы управления DroidDeck</string>
<string name="setup_app_scale_default">%1$d%% (по умолчанию)</string>
<string name="setup_theme">Тема</string>
<string name="setup_theme_hint">Внешний вид лаунчера</string>
<string name="setup_home">Использовать как главный экран</string>
<string name="setup_home_on">DroidDeck может использоваться как приложение главного экрана</string>
<string name="setup_home_off">DroidDeck не будет доступен как приложение главного экрана</string>
<string name="setup_fullscreen">Полноэкранный режим</string>
<string name="setup_fullscreen_on">Скрывает строку состояния и панель навигации Android</string>
<string name="setup_fullscreen_off">Показывает строку состояния и панель навигации Android</string>
<string name="setup_default_home">Главный экран по умолчанию</string>
<string name="setup_choose_home">Выбрать главный экран</string>
<string name="setup_fullscreen_on">Скрыть строку состояния и навигации Android</string>
<string name="setup_fullscreen_off">Показать строку состояния и навигации Android</string>
<string name="setup_default_home">Приложение главного экрана по умолчанию</string>
<string name="setup_choose_home">Выбрать приложение главного экрана</string>
<string name="setup_choose">Выбрать</string>
<string name="setup_linux_apps">Приложения Linux (бета)</string>
<string name="setup_store">Магазин Flathub</string>
@@ -621,12 +544,12 @@
<string name="setup_tool_protons_hint">Установить сборки ARM64 Proton</string>
<string name="setup_tool_performance">Производительность</string>
<string name="setup_tool_performance_hint">Распределение ядер CPU</string>
<string name="setup_tool_roms">Папка ROM</string>
<string name="setup_tool_roms">Папки ROM</string>
<string name="setup_tool_roms_hint">Место хранения игр эмулятора</string>
<string name="setup_card_components">Компоненты</string>
<string name="setup_card_components_hint">FEX, DXVK, VKD3D</string>
<string name="setup_card_controls">Управление</string>
<string name="setup_card_controls_hint">Назначение кнопок</string>
<string name="setup_card_controls_hint">Сопоставление клавиш</string>
<string name="store_tab_discover">Обзор</string>
<string name="store_tab_search">Поиск</string>
<string name="store_tab_installed">Установлено</string>
@@ -634,7 +557,7 @@
<string name="store_title">Магазин</string>
<string name="store_chip_flathub">Flathub · ARM64</string>
<string name="store_chip_ready">● Flatpak готов</string>
<string name="store_needs_runtime">Сначала установите среду выполнения Linux на странице «Настройка».</string>
<string name="store_needs_runtime">Сначала установите среду выполнения Linux в «Настройках».</string>
<string name="store_setup_title">Настройка Flatpak</string>
<string name="store_setup_text">Добавление Flatpak и подключение к Flathub (около 20 МБ). Первое приложение также загрузит свою среду выполнения.</string>
<string name="store_setting_up">Настройка…</string>
@@ -667,7 +590,7 @@
<string name="store_installing">Установка…</string>
<string name="store_install">Установить</string>
<string name="store_setup_first">Сначала настройте Flatpak</string>
<string name="store_download_size">Загрузка: %1$s</string>
<string name="store_download_size">Скачать %1$s</string>
<string name="store_installed_chip">● Установлено</string>
<string name="store_details_failed">Не удалось загрузить страницу Flathub этого приложения.</string>
<string name="store_loading">Загрузка из Flathub…</string>
@@ -684,30 +607,30 @@
<string name="upd_title">Обновления</string>
<string name="upd_build">DroidDeck %1$s · %2$s</string>
<string name="upd_checked">Проверено %1$s</string>
<string name="upd_not_checked">Еще не проверялось</string>
<string name="upd_perm_title">Разрешить DroidDeck обновляться самостоятельно</string>
<string name="upd_not_checked">Не проверено</string>
<string name="upd_perm_title">Разрешить DroidDeck самообновление</string>
<string name="upd_perm_text">Включите «Разрешить из этого источника» на следующей странице и вернитесь. Обновление продолжится автоматически.</string>
<string name="upd_open_settings">Открыть настройки</string>
<string name="upd_not_now">Не сейчас</string>
<string name="upd_signed_status">Другая подпись</string>
<string name="upd_signed_headline">Невозможно обновить на месте</string>
<string name="upd_signed_detail">Эта копия подписана не ключом релизов DroidDeck, поэтому обновление не может установиться поверх нее. Чтобы перейти, удалите эту копию.</string>
<string name="upd_signed_detail">Эта копия подписана не ключом выпуска DroidDeck, поэтому ее нельзя перезаписать напрямую. Удалите и переключитесь.</string>
<string name="upd_available">Доступно обновление</string>
<string name="upd_blocked_status">Невозможно установить</string>
<string name="upd_switch">Готово к переключению</string>
<string name="upd_switch">Можно переключить</string>
<string name="upd_ahead_status">Опережает Stable</string>
<string name="upd_ahead_headline">Вы опережаете Stable</string>
<string name="upd_ahead_detail">Эта сборка новее последней Stable. Вы переключитесь на Stable после выпуска следующей версии Stable.</string>
<string name="upd_gone_status">Тест завершен</string>
<string name="upd_gone_headline">Этот тест завершен</string>
<string name="upd_gone_detail">Исправление влито в основную ветку или отклонено. Следите за Preview, чтобы получать новейшие исправления.</string>
<string name="upd_gone_detail">Исправление было объединено или отменено. Следите за Preview, чтобы получать последние исправления.</string>
<string name="upd_current_status">Актуально</string>
<string name="upd_current_headline">У вас последняя версия: %1$s</string>
<string name="upd_current_headline">У вас последняя версия %1$s</string>
<string name="upd_dot_ago">· %1$s</string>
<string name="upd_stage_percent">%1$s… %2$d%%</string>
<string name="upd_stage">%1$s…</string>
<string name="upd_update_button">Обновить%1$s</string>
<string name="upd_install_button">Установить%1$s</string>
<string name="upd_update_button">Обновить %1$s</string>
<string name="upd_install_button">Установить %1$s</string>
<string name="upd_install_anyway">Все равно установить Stable %1$s</string>
<string name="upd_follow_preview">Следить за Preview</string>
<string name="upd_stop_session">Остановите текущую сессию перед обновлением.</string>
@@ -717,9 +640,9 @@
<string name="upd_new_preview">Новая сборка Preview</string>
<string name="upd_new_test">Тестовая сборка PR #%1$d</string>
<string name="upd_mb">%1$d МБ</string>
<string name="upd_channel">КАНАЛ ОБНОВЛЕНИЙ</string>
<string name="upd_channel">Канал обновлений</string>
<string name="upd_stable">Stable</string>
<string name="upd_stable_hint">Проверенные выпуски, для большинства</string>
<string name="upd_stable_hint">Протестированная версия, подходит большинству</string>
<string name="upd_version_ago">%1$s · %2$s</string>
<string name="upd_preview">Preview</string>
<string name="upd_preview_hint">Последние исправления перед Stable</string>
@@ -729,10 +652,10 @@
<string name="upd_tests_none">Сейчас нет</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Не собрано для этой копии DroidDeck</string>
<string name="upd_updated">Обновлено %1$s</string>
<string name="upd_test_name">тестовая сборка PR #%1$d</string>
<string name="upd_while_ago">некоторое время назад</string>
<string name="upd_just_now">только что</string>
<string name="upd_updated">%1$s обновлено</string>
<string name="upd_test_name">Тестовая версия PR #%1$d</string>
<string name="upd_while_ago">Недавно</string>
<string name="upd_just_now">Только что</string>
<plurals name="upd_tests_count">
<item quantity="one">%1$d доступный тест</item>
<item quantity="few">%1$d доступных теста</item>
@@ -740,10 +663,10 @@
<item quantity="other">%1$d доступных тестов</item>
</plurals>
<plurals name="upd_preview_behind">
<item quantity="one">Изменения с момента вашей сборки · отставание на %1$d сборку</item>
<item quantity="few">Изменения с момента вашей сборки · отставание на %1$d сборки</item>
<item quantity="many">Изменения с момента вашей сборки · отставание на %1$d сборок</item>
<item quantity="other">Изменения с момента вашей сборки · отставание на %1$d сборки</item>
<item quantity="one">Изменения с момента вашей сборки · Отставание на %1$d сборку</item>
<item quantity="few">Изменения с момента вашей сборки · Отставание на %1$d сборки</item>
<item quantity="many">Изменения с момента вашей сборки · Отставание на %1$d сборок</item>
<item quantity="other">Изменения с момента вашей сборки · Отставание на %1$d сборки</item>
</plurals>
<plurals name="upd_preview_recent">
<item quantity="one">Недавние изменения · %1$d сборка</item>
@@ -754,12 +677,12 @@
<string name="gate_title">Одна настройка Android, которую нужно изменить</string>
<string name="gate_eyebrow">Steam</string>
<string name="gate_lede">Android останавливает приложения, запускающие множество фоновых процессов, а Steam запускает десятки. До изменения этой настройки игры могут закрываться без сообщения об ошибке.</string>
<string name="gate_fix_for_me">Исправить автоматически</string>
<string name="gate_toggle_steps">Включите «Отключить ограничение дочерних процессов» и вернитесь. Оставьте раздел «Для разработчиков» включенным, иначе переключатель снова отключится.</string>
<string name="gate_open_dev">Открыть «Для разработчиков»</string>
<string name="gate_fix_for_me">Исправить за меня</string>
<string name="gate_toggle_steps">Включите «Отключить ограничение дочерних процессов» и вернитесь. Оставьте параметры разработчика включенными, иначе переключатель снова отключится.</string>
<string name="gate_open_dev">Открыть параметры разработчика</string>
<string name="gate_no_toggle">В этой версии Android нет такого переключателя, поэтому DroidDeck внесет изменения через отладку по Wi-Fi. Это займет около минуты, компьютер не нужен.</string>
<string name="gate_needs_wifi">Для отладки по Wi-Fi требуется Wi-Fi. Подойдет любая сеть, интернет не нужен.</string>
<string name="gate_needs_notifications">Код сопряжения придет в уведомлении. Разрешите DroidDeck отправлять уведомления или используйте «Другие способы».</string>
<string name="gate_needs_notifications">Код сопряжения отобразится в уведомлении. Разрешите DroidDeck отправлять уведомления или используйте «Другой способ».</string>
<string name="gate_allow_notifications">Разрешить уведомления</string>
<string name="gate_step1">Откройте Настройки → Для разработчиков → Отладка по Wi-Fi и включите ее</string>
<string name="gate_step2">Нажмите «Сопряжение устройства с помощью кода»</string>
@@ -770,12 +693,12 @@
<string name="gate_polling">Проверка каждые 2 секунды. После отключения этого переключателя страница закроется автоматически.</string>
<string name="gate_other_ways">Другие способы</string>
<string name="gate_other_toggle">Отладка по Wi-Fi, ручной ввод адреса, ADB на ПК</string>
<string name="gate_other_no_toggle">Ручной ввод адреса, ADB на ПК, «Для разработчиков»</string>
<string name="gate_other_no_toggle">Ручной ввод адреса, ADB на ПК, параметры разработчика</string>
<string name="gate_use_wireless_instead">Использовать отладку по Wi-Fi</string>
<string name="gate_some_roms">В некоторых прошивках переключатель все же есть. Поищите «дочерние процессы» в разделе «Для разработчиков».</string>
<string name="gate_some_roms">Некоторые ROM все еще предоставляют этот переключатель. Найдите «дочерние процессы» в параметрах разработчика.</string>
<string name="gate_manual_hint">Если способ с уведомлением не работает, введите адрес и код сопряжения из окна вручную.</string>
<string name="gate_enter_manually">Ввести адрес вручную</string>
<string name="gate_from_computer">С компьютера через ADB:</string>
<string name="gate_from_computer">Использовать ADB с компьютера:</string>
<string name="gate_copy_command">Копировать команду</string>
<string name="gate_choose_display">Выберите экран для открытия настроек Android.</string>
<string name="gate_main_screen">Главный экран</string>
@@ -800,17 +723,17 @@
<string name="wadb_pin_placeholder">6 цифр</string>
<string name="wadb_pair">Сопряжение</string>
<string name="wadb_apply_group">2 · Применение через отладку по Wi-Fi</string>
<string name="wadb_connect_address">IP-адрес и порт подключения</string>
<string name="wadb_connect_address">Подключить IP-адрес и порт</string>
<string name="wadb_pair_again">Сопряжение заново</string>
<string name="wadb_turn_off">Отключить ограничение</string>
<string name="wadb_other_ways">Другие способы изменения</string>
<string name="wadb_dev_try_first">1 · Для разработчиков (сначала попробуйте это)</string>
<string name="wadb_dev_try_first">1 · Для разработчиков (попробовать первым)</string>
<string name="wadb_restrict_on">Включите «Ограничение дочерних процессов».</string>
<string name="wadb_restrict_off">Отключите «Ограничение дочерних процессов».</string>
<string name="wadb_computer_last">3 · ADB на ПК (крайняя мера)</string>
<string name="wadb_only_if">Используйте только если отладка по Wi-Fi недоступна.</string>
<string name="wadb_computer_group">3 · ADB на ПК (крайняя мера)</string>
<string name="ctrl_osc_hint">Когда показывать экранные элементы управления в сессии</string>
<string name="ctrl_osc_hint">При показе экранных элементов управления в сессии</string>
<string name="ctrl_color">Цвет</string>
<string name="ctrl_color_hint">Оттенок экранных кнопок</string>
<string name="ctrl_custom">Пользовательский</string>
@@ -820,30 +743,30 @@
<string name="ctrl_size_hint" formatted="false">100% — стандартный размер</string>
<string name="ctrl_stick_click">Нажатие стика</string>
<string name="ctrl_stick_click_hint">Дважды нажмите и удерживайте стик для L3 или R3</string>
<string name="ctrl_adaptive">Адаптивные стики</string>
<string name="ctrl_rumble">Вибрация в играх</string>
<string name="ctrl_adaptive">Адаптивный стик</string>
<string name="ctrl_rumble">Вибрация игры</string>
<string name="ctrl_steam_button">Кнопка Steam</string>
<string name="ctrl_qam_button">Кнопка QAM</string>
<string name="ctrl_keyboard_button">Кнопка клавиатуры</string>
<string name="setup_animations">Анимации</string>
<string name="ctrl_adaptive_hint">Стики появляются в месте касания и исчезают после отпускания</string>
<string name="ctrl_adaptive_hint">Стик появится в месте касания и скроется после отпускания</string>
<string name="ctrl_layout">Раскладка</string>
<string name="ctrl_layout_custom">Сохранено свое расположение</string>
<string name="ctrl_layout_custom">Сохранена пользовательская позиция</string>
<string name="ctrl_layout_default">Адаптировано под этот экран</string>
<string name="ctrl_edit">Изменить</string>
<string name="ctrl_reset">Сбросить</string>
<string name="ctrl_mapping">Назначение кнопок</string>
<string name="ctrl_mapping_default">Каждая кнопка отправляет свой ввод</string>
<string name="ctrl_mapping_count">Переназначено кнопок: %1$d из %2$d</string>
<string name="ctrl_mapping">Сопоставление кнопок</string>
<string name="ctrl_mapping_default">Каждая кнопка отправляет соответствующий исходный ввод</string>
<string name="ctrl_mapping_count">%1$d/%2$d кнопок переназначено</string>
<string name="ctrl_configure">Настроить</string>
<string name="ctrl_reset_all">Сбросить контроллер</string>
<string name="ctrl_reset_all_hint">Восстанавливает все настройки контроллера и раскладку</string>
<string name="ctrl_mapping_lede">Что каждая экранная кнопка отправляет в игру. Значение «Скрыто» убирает кнопку.</string>
<string name="ctrl_reset_all_hint">Восстановить все настройки и раскладки контроллера</string>
<string name="ctrl_mapping_lede">Настройте ввод, отправляемый каждой экранной кнопкой в игру. «Скрыть» удалит кнопку.</string>
<string name="ctrl_onscreen_buttons">Экранные кнопки</string>
<string name="ctrl_defaults">По умолчанию</string>
<string name="ctrl_reset_mapping">Сбросить назначение</string>
<string name="ctrl_reset_mapping">Сбросить сопоставление</string>
<string name="content_android_apps">Приложения Android</string>
<string name="content_apps_count">Приложений: %1$d</string>
<string name="content_apps_count">%1$d приложений</string>
<string name="content_no_android_apps">Не найдено приложений Android для запуска.</string>
<string name="content_desktop_installed">● Рабочий стол установлен</string>
<string name="content_desktop_first_open">Устанавливается при первом открытии</string>
@@ -854,15 +777,15 @@
<string name="content_games">Игры</string>
<string name="content_choose_roms">Выберите папку ROM.</string>
<string name="content_retroarch_roms">В RetroArch перейдите в /root/ROMs.</string>
<string name="content_add_roms">Добавьте игры %1$s в ROMs/%2$s.</string>
<string name="content_add_roms">Добавлено %1$s игр в ROMs/%2$s.</string>
<string name="content_install_named">Установить %1$s</string>
<string name="content_stop_to_install">Остановите сессию для установки</string>
<string name="content_runtime_required">Требуется среда выполнения</string>
<string name="content_loading_details">Загрузка подробностей установки…</string>
<string name="content_details_unavailable">Подробности установки недоступны. Повторите попытку позже.</string>
<string name="content_needs_runtime">Сначала установите среду выполнения Linux на странице «Настройка».</string>
<string name="content_needs_runtime">Сначала установите среду выполнения Linux в «Настройках».</string>
<string name="content_stop_first">Остановите текущую сессию перед установкой приложения рабочего стола.</string>
<string name="content_install_into">Устанавливает %1$s в среду рабочего стола Linux.</string>
<string name="content_install_into">Установить %1$s на рабочий стол Linux.</string>
<string name="store_busy_percent_short">%1$s · %2$d%%</string>
<string name="content_hide_note">Скрытие удалит его с рабочего стола. Файлы останутся в среде выполнения Linux.</string>
<string name="content_rom_gone">Этой игры больше нет в папке ROM.</string>
@@ -882,8 +805,8 @@
<string name="games_prefix">Префикс Proton</string>
<string name="games_saves_looking">Поиск сохранений…</string>
<string name="games_saves_none">Папка сохранений не найдена по имени. При экспорте будет включена вся папка пользователя.</string>
<string name="games_saves_folders">Папок сохранений: %1$d</string>
<string name="games_saves_summary">%1$s · файлов: %2$d · %3$s</string>
<string name="games_saves_folders">%1$d папок сохранений</string>
<string name="games_saves_summary">%1$s · %2$d файлов · %3$s</string>
<string name="games_kb">%1$d КБ</string>
<string name="games_manage_saves">Управление сохранениями</string>
<string name="games_saves">Сохранения игры</string>
@@ -897,13 +820,13 @@
<string name="games_never_played">Никогда не запускалась</string>
<string name="games_runtime_busy_percent">Среда выполнения занята · %1$d%%</string>
<string name="games_runtime_busy">Среда выполнения занята</string>
<string name="games_runtime_first_play">Среда выполнения установится при первом запуске игры</string>
<string name="games_runtime_first_play">Установка среды выполнения при первом запуске игры</string>
<string name="games_runtime_update">Доступно обновление среды выполнения</string>
<string name="games_runtime_ready">● Среда выполнения готова</string>
<string name="games_last_played">Последний запуск %1$s</string>
<string name="games_internal">Внутреннее хранилище</string>
<string name="games_added">Добавленная игра</string>
<string name="gpu_auto_hint">Лучшая пара для этого GPU, обновляется автоматически</string>
<string name="games_added">Добавленные игры</string>
<string name="gpu_auto_hint">Выбирает лучшую комбинацию драйверов для этого GPU и поддерживает ее в актуальном состоянии</string>
<string name="gpu_manual_hint">Выбрать комбинацию драйверов вручную</string>
<string name="gpu_checking">Проверка драйверов…</string>
<string name="gpu_none_listed">Драйверы пока не перечислены. Обновите, чтобы найти драйверы.</string>
@@ -913,16 +836,16 @@
<string name="gpu_set_with_display">Настраивается вместе с драйвером дисплея: %1$s включает оба.</string>
<string name="gpu_set_with_runtime">Настраивается вместе с драйвером среды выполнения: %1$s включает оба.</string>
<string name="gpu_runtime_hint">Используется играми и рабочим столом.</string>
<string name="gpu_runtime_default">Стандартный драйвер среды выполнения</string>
<string name="gpu_runtime_default">По умолчанию среды выполнения</string>
<string name="gpu_display_hint">Отвечает за вывод изображения на экран. Вступит в силу после перезапуска DroidDeck.</string>
<string name="gpu_auto_by_gpu">Авто — по GPU</string>
<string name="gpu_auto_by_gpu">Авто - Выбор по GPU</string>
<string name="gpu_manage">Скачать, импортировать или удалить…</string>
<string name="gpu_in_use_caps">ИСПОЛЬЗУЕТСЯ</string>
<string name="gpu_builtin">Драйверы, встроенные в DroidDeck</string>
<string name="gpu_in_use_caps">Используется</string>
<string name="gpu_builtin">Встроенный драйвер DroidDeck</string>
<string name="gpu_matched_pair">Драйвер среды выполнения и драйвер дисплея настраиваются вместе как совместимая пара.</string>
<string name="gpu_recommended_for">Рекомендуется для этого GPU: %1$s.</string>
<string name="gpu_refresh_hint">Обновите, чтобы найти драйверы, подходящие для этого GPU.</string>
<string name="gpu_recommended_caps">РЕКОМЕНДУЕТСЯ</string>
<string name="gpu_recommended_caps">Рекомендуется</string>
<string name="gpu_unavailable">Недоступно</string>
<string name="load_runtime">Среда выполнения Linux</string>
<string name="load_runtime_detail">Подготовка среды выполнения Linux</string>
@@ -946,11 +869,11 @@
<string name="deck_tab_both">Оба</string>
<string name="deck_close">Закрыть управление вторым экраном</string>
<string name="deck_open_steam_menu">Открыть меню Steam</string>
<string name="deck_left_upper">Слева · сверху</string>
<string name="deck_left_lower">Слева · снизу</string>
<string name="deck_right_upper">Справа · сверху</string>
<string name="deck_right_lower">Справа · снизу</string>
<string name="deck_click">Клик</string>
<string name="deck_left_upper">Слева · Сверху</string>
<string name="deck_left_lower">Слева · Снизу</string>
<string name="deck_right_upper">Справа · Сверху</string>
<string name="deck_right_lower">Справа · Снизу</string>
<string name="deck_click">Нажать</string>
<string name="deck_right_trackpad">Правый тачпад</string>
<string name="deck_left_trackpad">Левый тачпад</string>
<string name="second_trackpad">Тачпад</string>
@@ -976,11 +899,11 @@
<string name="proton_lede">Установите сборку ARM64 Proton, затем выберите ее для каждой игры в Steam → Свойства → Совместимость.</string>
<string name="proton_stop_first">Остановите текущую сессию перед установкой или удалением инструментов совместимости.</string>
<string name="proton_available">Доступные сборки</string>
<string name="proton_installed">Установлено: %1$s</string>
<string name="proton_queued">В очереди по предыдущему запросу</string>
<string name="proton_installed">%1$s установлено</string>
<string name="proton_queued">Ожидающий элемент от предыдущего запроса</string>
<string name="proton_not_installed">Не установлено</string>
<string name="proton_install_now">Установить сейчас</string>
<string name="proton_cancel_queued">Убрать из очереди</string>
<string name="proton_cancel_queued">Отменить очередь</string>
<string name="pckb_android">Клавиатура Android</string>
<string name="pckb_hide">Скрыть ✕</string>
<string name="main_launch_named">Запустить %1$s</string>
@@ -1025,7 +948,7 @@
<string name="setup_retry_removal">Повторить удаление</string>
<string name="runtime_removing">Удаление</string>
<string name="runtime_removal_incomplete">Удаление не завершено</string>
<string name="game_file_export">Экспорт файла игры</string>
<string name="game_file_export">Экспорт файлов игры</string>
<string name="game_file_sync">Синхронизировать все файлы игры в папку</string>
<string name="game_file_stop_sync">Остановить синхронизацию файлов игры</string>
<string name="game_file_folder">Выбрать папку для файлов игры фронтенда</string>
@@ -1033,27 +956,4 @@
<string name="game_frontend_files">Файлы фронтенда</string>
<string name="fm_up_one_level">На уровень вверх</string>
<string name="fm_drive_linux">Система Linux</string>
<string name="display_session">Экран сессии</string>
<string name="display_resolution">Разрешение</string>
<string name="display_game_fit">Игры 16:9</string>
<string name="display_game_fit_hint">Как игры 16:9 вписываются в этот экран. Вступит в силу в следующей сессии.</string>
<string name="display_game_fit_unavailable">Выберите «По размеру экрана» или свой размер уже 16:9.</string>
<string name="display_preserve">Сохранять пропорции</string>
<string name="display_stretch">Растянуть на экран</string>
<string name="display_image_scaling">Масштабирование изображения</string>
<string name="display_filter">Фильтр масштабирования</string>
<string name="display_filter_hint">Общий для Steam и рабочего стола.</string>
<string name="display_filter_live">Применяется сразу. Общий для Steam и рабочего стола.</string>
<string name="display_filter_note">FSR, GSR и NIS работают при увеличении изображения сессии. «Sharpen only» работает и без увеличения.</string>
<string name="display_sharpness">Резкость фильтра</string>
<string name="display_frame_rate">Частота кадров</string>
<string name="display_look_filter">%1$s Устанавливает масштабирование: %2$s.</string>
<string name="display_match_screen">По размеру экрана (%1$d×%2$d)</string>
<string name="mode_tab_steam">Steam</string>
<string name="device_info_show">Показать сведения об устройстве</string>
<string name="device_info_hide">Скрыть сведения об устройстве</string>
<string name="device_info_loading">Чтение данных устройства…</string>
<string name="device_info_copy">Копировать</string>
<string name="device_info_copy_hint">Скопируйте эти сведения в отчет об ошибке.</string>
<string name="device_info_copied">Скопировано.</string>
</resources>
@@ -611,7 +611,6 @@
<string name="setup_session">会话</string>
<string name="setup_logs">会话日志</string>
<string name="setup_logs_hint">每次会话后保存</string>
<string name="setup_agent_commands">调试工具</string>
<string name="setup_latest_logs">最新会话日志</string>
<string name="setup_saved_logs">已保存的会话日志</string>
<string name="setup_saved_logs_hint">保留最新的 %1$d 个</string>
@@ -611,7 +611,6 @@
<string name="setup_session">會話</string>
<string name="setup_logs">會話記錄</string>
<string name="setup_logs_hint">每次會話後儲存</string>
<string name="setup_agent_commands">偵錯工具</string>
<string name="setup_latest_logs">最新的會話記錄</string>
<string name="setup_saved_logs">已儲存的會話記錄</string>
<string name="setup_saved_logs_hint">保留最新的 %1$d 個</string>
@@ -611,7 +611,6 @@
<string name="setup_session">工作階段</string>
<string name="setup_logs">工作階段日誌</string>
<string name="setup_logs_hint">每次工作階段結束後儲存</string>
<string name="setup_agent_commands">偵錯工具</string>
<string name="setup_latest_logs">最新工作階段日誌</string>
<string name="setup_saved_logs">已儲存的工作階段日誌</string>
<string name="setup_saved_logs_hint">保留最新的 %1$d 個</string>
-1
View File
@@ -616,7 +616,6 @@
<string name="setup_session">Session</string>
<string name="setup_logs">Session logs</string>
<string name="setup_logs_hint">Saved after each session</string>
<string name="setup_agent_commands">Debugging tools</string>
<string name="setup_latest_logs">Latest session logs</string>
<string name="setup_saved_logs">Saved session logs</string>
<string name="setup_saved_logs_hint">Keeps the newest %1$d</string>
@@ -1,266 +0,0 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Bundle
import android.os.Process
import android.util.Base64
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.text.AnnotatedString
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowBinder
import org.robolectric.shadows.ShadowLog
import java.io.File
import java.nio.file.Files
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class AgentSecurityTest {
private lateinit var context: Context
@Before fun prepare() {
context = RuntimeEnvironment.getApplication()
AgentAccess.setCommandsEnabled(context, false)
ShadowBinder.setCallingUid(Process.myUid())
ShadowLog.clear()
}
@After fun cleanup() {
ShadowBinder.setCallingUid(Process.myUid())
AgentAccess.setCommandsEnabled(context, false)
}
private fun refused(code: String, action: () -> Unit): AgentException {
try {
action()
throw AssertionError("Expected $code")
} catch (e: AgentException) {
assertEquals(code, e.code)
return e
}
}
private fun call(provider: AgentBridgeProvider, method: String, request: JSONObject = JSONObject()): JSONObject {
val extras = Bundle().apply {
putString("request", Base64.encodeToString(request.toString().toByteArray(), Base64.NO_WRAP))
}
return JSONObject(provider.call(method, null, extras).getString("json")!!)
}
@Test fun commandsRequireTheToggleInEveryBuild() {
assertFalse(AgentAccess.commandsAllowed(context))
refused("AGENT_COMMANDS_DISABLED") { AgentAccess.requireCommands(context) }
AgentAccess.setCommandsEnabled(context, true)
assertTrue(AgentAccess.commandsAllowed(context))
AgentAccess.requireCommands(context)
AgentAccess.setCommandsEnabled(context, false)
assertFalse(AgentAccess.commandsAllowed(context))
}
@Test fun runCannotBypassTheToggle() {
refused("AGENT_COMMANDS_DISABLED") { AgentAccess.requireStart(context, JSONObject().put("mode", "run")) }
AgentAccess.requireStart(context, JSONObject().put("mode", "steam"))
AgentAccess.requireStart(context, JSONObject().put("mode", "desktop"))
val blocked = Robolectric.buildActivity(AgentStartActivity::class.java).get()
val start = AgentStartActivity::class.java.getDeclaredMethod("startSession", JSONObject::class.java).apply { isAccessible = true }
val request = JSONObject().put("mode", "run").put("program", "/usr/bin/sh")
start.invoke(blocked, request)
assertTrue(blocked.isFinishing)
assertNull(shadowOf(blocked).nextStartedActivity)
AgentAccess.setCommandsEnabled(context, true)
AgentAccess.requireStart(context, JSONObject().put("mode", "run"))
val allowed = Robolectric.buildActivity(AgentStartActivity::class.java).get()
start.invoke(allowed, request)
assertEquals("/usr/bin/sh", shadowOf(allowed).nextStartedActivity.getStringExtra("program"))
}
@Test fun providerRefusesPrivilegedMethodsBeforePerformingThem() {
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
for (method in listOf("guest", "cdp", "override", "prefs", "env")) {
assertEquals(method, "AGENT_COMMANDS_DISABLED", call(provider, method).getJSONObject("error").getString("code"))
}
assertFalse(call(provider, "access").getBoolean("commands"))
assertTrue(call(provider, "state").getBoolean("ok"))
}
@Test fun providerChecksCallerPermissionBeforeDecoding() {
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
ShadowBinder.setCallingUid(Process.myUid() + 10001)
try {
provider.call("access", null, Bundle().apply { putString("request", "invalid") })
throw AssertionError("Expected permission refusal")
} catch (e: SecurityException) {
assertTrue(e.message!!.contains("DUMP"))
}
}
@Test fun oversizedRequestsAreRefused() {
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
val response = provider.call("access", null, Bundle().apply { putString("request", "A".repeat(256 * 1024 + 1)) })
assertEquals("INVALID_REQUEST", JSONObject(response.getString("json")!!).getJSONObject("error").getString("code"))
}
@Test fun oversizedResponsesReturnAnErrorInsteadOfOverflowingBinder() {
AgentAccess.setCommandsEnabled(context, true)
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putString("large", "x".repeat(400 * 1024)).commit()
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
val response = call(provider, "prefs", JSONObject().put("file", "session"))
assertEquals("RESPONSE_TOO_LARGE", response.getJSONObject("error").getString("code"))
}
@Test fun invalidTimeoutsAreRefusedBeforeDispatchingToTheGuest() {
AgentAccess.setCommandsEnabled(context, true)
val provider = Robolectric.buildContentProvider(AgentBridgeProvider::class.java).create().get()
for (value in listOf("NaN", "Infinity", "invalid", "0", "601")) {
val response = call(provider, "guest", JSONObject().put("timeout", value))
assertEquals(value, "INVALID_REQUEST", response.getJSONObject("error").getString("code"))
}
}
@Test fun directEnvironmentAndPreferenceCallsAlsoRequireConsent() {
refused("AGENT_COMMANDS_DISABLED") { AgentEnv.list(context) }
refused("AGENT_COMMANDS_DISABLED") { AgentEnv.set(context, listOf("TEST=value"), false) }
refused("AGENT_COMMANDS_DISABLED") { AgentEnv.clear(context, "all") }
refused("AGENT_COMMANDS_DISABLED") { AgentEnv.override(context, "gamescope") }
refused("AGENT_COMMANDS_DISABLED") { AgentPrefs.get(context, "session") }
refused("AGENT_COMMANDS_DISABLED") { AgentPrefs.set(context, "session", "test", "bool", "true") }
}
@Test fun disablingDiscardsEnvironmentAndBinaryOverrides() {
AgentAccess.setCommandsEnabled(context, true)
AgentEnv.set(context, listOf("NEXT=one"), false)
AgentEnv.set(context, listOf("PERSISTENT=two"), true)
File(AgentEnv.inbox(context).apply { mkdirs() }, "gamescope").writeText("test binary")
AgentEnv.override(context, "gamescope")
val installed = File(LinuxRuntime.rootDir(context), "opt/droiddeck-agent/bin/gamescope")
assertTrue(installed.isFile)
AgentAccess.setCommandsEnabled(context, false)
assertFalse(installed.exists())
assertTrue(AgentEnv.beginSession(context).isEmpty())
AgentAccess.setCommandsEnabled(context, true)
val state = AgentEnv.list(context)
for (key in listOf("pending", "persistent", "active", "pendingOverrides", "activeOverrides")) {
assertEquals(key, 0, state.getJSONArray(key).length())
}
}
@Test fun oldPersistedExperimentsCannotApplyWhileDisabled() {
File(context.filesDir, "agent-env.json").writeText("""{"pending":["TEST=one"],"persistent":["TEST=two"]}""")
assertTrue(AgentEnv.beginSession(context).isEmpty())
AgentAccess.setCommandsEnabled(context, true)
assertEquals(0, AgentEnv.list(context).getJSONArray("persistent").length())
}
@Test fun authorisedExperimentsKeepTheirSessionLifecycle() {
AgentAccess.setCommandsEnabled(context, true)
AgentEnv.set(context, listOf("TEST=old", "STICKY=1"), true)
AgentEnv.set(context, listOf("TEST=new"), false)
assertEquals(listOf("TEST=old", "STICKY=1", "TEST=new"), AgentEnv.beginSession(context))
AgentEnv.endSession(context)
assertEquals(listOf("TEST=old", "STICKY=1"), AgentEnv.beginSession(context))
}
@Test fun environmentValuesAreNotLogged() {
AgentAccess.setCommandsEnabled(context, true)
AgentEnv.set(context, listOf("CUSTOM=synthetic-private-value"), false)
AgentEnv.beginSession(context)
assertFalse(ShadowLog.getLogsForTag("AgentEnv").any { it.msg.contains("synthetic-private-value") })
assertTrue(ShadowLog.getLogsForTag("AgentEnv").any { it.msg.contains("CUSTOM") })
}
@Test fun passwordSemanticsNeverReturnTextOrDescriptions() {
val config = SemanticsConfiguration().apply {
this[SemanticsProperties.Password] = Unit
this[SemanticsProperties.Text] = listOf(AnnotatedString("synthetic-secret"))
this[SemanticsProperties.EditableText] = AnnotatedString("synthetic-secret")
this[SemanticsProperties.ContentDescription] = listOf("synthetic-secret")
this[SemanticsProperties.StateDescription] = "synthetic-secret"
}
val result = AgentUi.textFields(config)
for (key in listOf("text", "editableText", "description", "state")) assertEquals("<redacted>", result.getString(key))
assertFalse(result.toString().contains("synthetic-secret"))
}
@Test fun ordinarySemanticsStayReadableForAgents() {
val config = SemanticsConfiguration().apply {
this[SemanticsProperties.Text] = listOf(AnnotatedString("Play Steam"))
this[SemanticsProperties.StateDescription] = "On"
}
val result = AgentUi.textFields(config)
assertEquals("Play Steam", result.getString("text"))
assertEquals("On", result.getString("state"))
}
@Test fun disablingDoesNotCrashOrLeaveQueuedValuesWhenCleanupIsUnsafe() {
AgentAccess.setCommandsEnabled(context, true)
AgentEnv.set(context, listOf("TEST=value"), true)
val outside = File(context.filesDir, "cleanup-outside").apply { mkdirs() }
val privateFile = File(outside, "keep").apply { writeText("private") }
val link = File(LinuxRuntime.rootDir(context).apply { mkdirs() }, "opt")
Files.createSymbolicLink(link.toPath(), outside.toPath())
try {
AgentAccess.setCommandsEnabled(context, false)
assertFalse(AgentAccess.commandsAllowed(context))
val state = JSONObject(File(context.filesDir, "agent-env.json").readText())
assertEquals(0, state.getJSONArray("persistent").length())
assertEquals("private", privateFile.readText())
} finally {
Files.delete(link.toPath())
}
}
@Test fun invalidEnvironmentErrorsDoNotEchoValues() {
AgentAccess.setCommandsEnabled(context, true)
for (line in listOf("=synthetic-private-value", "TEST=synthetic-private-value\nOTHER=x", "TEST=synthetic-private-value\u0000")) {
val error = refused("INVALID_ENV") { AgentEnv.set(context, listOf(line), false) }
assertFalse(error.message!!.contains("synthetic-private-value"))
}
}
@Test fun preferencesCannotWriteTheGateOrUsePathAliases() {
AgentAccess.setCommandsEnabled(context, true)
refused("PROTECTED_PREFS") { AgentPrefs.set(context, "agent", "commands", "bool", "true") }
for (name in listOf("../agent", "./agent", "/agent", "agent/../agent")) {
refused("INVALID_PREF") { AgentPrefs.set(context, name, "commands", "bool", "true") }
refused("INVALID_PREF") { AgentPrefs.get(context, name) }
}
assertTrue(AgentPrefs.set(context, "session", "test", "bool", "true").getBoolean("value"))
val error = refused("INVALID_PREF") { AgentPrefs.set(context, "session", "test", "int", "synthetic-private-value") }
assertFalse(error.message!!.contains("synthetic-private-value"))
}
@Test fun overrideRefusesLinksAndTraversalNames() {
AgentAccess.setCommandsEnabled(context, true)
for (name in listOf(".", "..", "../gamescope")) refused("INVALID_NAME") { AgentEnv.override(context, name) }
val target = File(context.filesDir, "private-source").apply { writeText("private") }
val link = File(AgentEnv.inbox(context).apply { mkdirs() }, "linked")
Files.createSymbolicLink(link.toPath(), target.toPath())
refused("INVALID_NAME") { AgentEnv.override(context, "linked") }
}
@Test fun overrideNeverFollowsItsInstallDirectoryLink() {
AgentAccess.setCommandsEnabled(context, true)
val outside = File(context.filesDir, "outside").apply { mkdirs() }
val root = LinuxRuntime.rootDir(context).apply { mkdirs() }
val link = File(root, "opt")
Files.createSymbolicLink(link.toPath(), outside.toPath())
try {
File(AgentEnv.inbox(context).apply { mkdirs() }, "gamescope").writeText("test binary")
refused("OVERRIDE_FAILED") { AgentEnv.override(context, "gamescope") }
assertTrue(outside.listFiles()!!.isEmpty())
} finally {
Files.delete(link.toPath())
}
}
}
@@ -42,34 +42,6 @@ class LogRedactorTest {
assertEquals(line, LogRedactor.redact(line))
}
@Test fun macAddressesAreRedactedInAnyLog() {
assertEquals(" mac <redacted:mac>", LogRedactor.redact(" mac 02:ab:cd:ef:12:34"))
assertEquals("controller <redacted:mac> connected", LogRedactor.redact("controller A2-B3-C4-D5-E6-F7 connected"))
assertEquals("mac=<redacted:mac>, peer=<redacted:mac>", LogRedactor.redact("mac=02:ab:cd:ef:12:34, peer=a2:b3:c4:d5:e6:f7"))
assertEquals("MAC:<redacted:mac>", LogRedactor.redact("MAC:02:ab:cd:ef:12:34"))
assertEquals("MacAddress:<redacted:mac>", LogRedactor.redact("MacAddress:A2-B3-C4-D5-E6-F7"))
assertEquals("{\"mac_address\":\"<redacted:mac>\"}", LogRedactor.redact("{\"mac_address\":\"02:ab:cd:ef:12:34\"}"))
}
@Test fun macRedactionPreservesOtherDiagnosticValues() {
val line = "[12:34:56] server ab:cd:ef:12:34:56:78:90 partial=ab:cd:ef:12:34 mixed=ab:cd-ef:12:34:56"
assertEquals(line, LogRedactor.redact(line))
}
@Test fun labelledDeviceIdentifiersAreRedacted() {
for (label in listOf("Serial number", "serial_number", "serialNumber", "serial", "device_serial", "Controller Serial", "ANDROID_ID", "build.serial")) {
assertEquals("$label: <redacted:serial>", LogRedactor.redact("$label: controller-123456"))
}
assertEquals("Serial number: <redacted:serial> connected", LogRedactor.redact("Serial number: 12345678901234567 connected"))
assertEquals("{\"serial_number\":\"<redacted:serial>\",\"result\":0}", LogRedactor.redact("{\"serial_number\":\"controller 123456\",\"result\":0}"))
assertEquals("device_serial = '<redacted:serial>'", LogRedactor.redact("device_serial = 'controller 123456'"))
}
@Test fun serialRedactionPreservesUnlabelledDiagnostics() {
val line = "serial port initialized; serial_number_count=2; controller=12345678901234567"
assertEquals(line, LogRedactor.redact(line))
}
@Test fun redactingTwiceChangesNothing() {
val lines = listOf(
"Using JWT 25484942796017334",
@@ -77,10 +49,6 @@ class LogRedactorTest {
"user 76561198012341234 [U:1:52075506]",
"external address 2607:f8b0:4005:80a::200e",
"OnLoginStateChange someaccount 2 1 0 0",
"mac 02:ab:cd:ef:12:34 controller A2-B3-C4-D5-E6-F7",
"MAC:02:ab:cd:ef:12:34",
"Serial number: 12345678901234567",
"{\"ANDROID_ID\":\"0123456789abcdef\"}",
)
for (line in lines) {
val once = LogRedactor.redact(line)
@@ -66,18 +66,6 @@ class SessionLogShareTest {
assertFalse(zip(folder).getValue("session.log").contains(leaked))
}
@Test fun olderRuleRecordReScrubsDeviceIdentifiers() {
val folder = tmp.newFolder("2026-10-07-01-steam")
File(folder, "network.txt").writeText("mac 02:ab:cd:ef:12:34\n")
File(folder, "steam").mkdirs()
File(folder, "steam/controller_support.txt").writeText("Serial number: controller-123456\n")
record(folder, "network.txt", "steam/controller_support.txt", rules = 1)
val out = zip(folder)
assertEquals("mac <redacted:mac>\n", out.getValue("network.txt"))
assertEquals("Serial number: <redacted:serial>\n", out.getValue("steam/controller_support.txt"))
}
@Test fun withoutARecordEveryFileIsScrubbed() {
val folder = tmp.newFolder("2026-10-06-04-steam")
File(folder, "session.log").writeText("$secret\n")
+26 -176
View File
@@ -1,191 +1,41 @@
# Agent control
`tools/droiddeckctl` drives DroidDeck on a real device over ADB, for agents and scripts that test
the app end to end: start and stop sessions, launch and quit Steam games, wait for a game to have
focus, send input, inspect the app's UI and gamescope's windows, run commands inside the session,
and collect logs, screenshots and recordings. `tools/droiddeck-scenario` repeats a scripted test
and reports every run.
The debug APK exposes a ContentProvider at `content://com.droiddeck.launcher.agent` and an Activity for starting sessions. Android's shell-only `DUMP` permission protects both. They are absent from release APKs.
## How it works
- **The provider** `content://com.droiddeck.launcher.agent` (`agent/AgentBridgeProvider.kt`)
answers each command with JSON. It and the session-start Activity (`AgentStartActivity`) are in
every build and require `android.permission.DUMP`, held by the ADB shell and privileged or
explicitly granted callers. The provider checks the calling permission before decoding requests.
- **The guest agent** `droiddeck-agent` runs inside every session (started by
`droiddeck-session`). It runs commands with the session's own environment, publishes gamescope's
focus, and talks to the Steam client's DevTools, which only accepts guest processes. The app and
the agent exchange request and answer files under the session root (`files/session/agent`).
- **The UI dump** reads the app's Compose semantics from inside the app (`agent/AgentUi.kt`), so it
works on every display and window. UI Automator, by contrast, dumps whichever window has focus,
which on a dual-screen device is often another app's.
## Access
Player controls and observations work over authorized ADB on any build: `state`, `start`, `stop`,
`resume`, `wait`, `launch`, `quit`, `focus`, `input`, `ui`, `logs`, `screenshot`, `record`, `displays`,
`wake`, and `access`.
**Setup > Session > Debugging tools** is off by default in every build, including debug builds.
It enables `run`, `guest`, `cdp`, all `env` operations, `override`, and `prefs`. The toggle switches
directly without a confirmation dialog. The bridge refuses to change it through `ui` or preference
writes. The existing preference key, `setting-agent-commands` test tag, JSON command names, and
`AGENT_COMMANDS_DISABLED` error code are unchanged. Refusals exit 7; `droiddeckctl access` reports
whether commands are enabled.
Disabling the toggle clears queued and persistent agent environment settings and installed binary
overrides. Disabled settings cannot apply at the next session start. Already running commands and
effects already applied to a session are not undone; stop and restart that session to discard them.
Authorized command execution runs inside the runtime containing Steam's saved login. CDP evaluates
JavaScript in the authenticated Steam client, and preferences can contain private values. These
capabilities remain unrestricted when enabled: the toggle is an explicit opt-in, not credential
isolation or a sandbox against the authorized caller. Main already supported arbitrary guest
programs through `run`; restricting credential access would require addressing that route too.
ADB access remains a trust boundary, and ADB input can operate the device's own toggle.
The helper exchanges private files with the app and adds no network listener. The guarded Steam
DevTools port is bound to loopback and accepts only peers with a fresh private registration for a
live socket descriptor, matching socket inode, destination port, and address family. The existing
x86_64 Decky loader exception remains: that mode does not enable the guest-only acceptance guard.
Requests are limited to 256 KiB of encoded JSON. Guest responses and DevTools messages are limited
to 1 MiB; provider replies above 384 Ki characters return `RESPONSE_TOO_LARGE` to avoid overflowing
Binder. Command output retains at most 256 KiB per stream while continuing to drain both streams.
Timeouts must be finite, positive, and no greater than 600 seconds, and terminate the command's
process group. Override installation refuses path traversal and symbolic links.
Automatic process logs record environment keys and argument counts rather than values or full
argument lists. UI nodes marked as passwords redact text, editable text, descriptions, and state
text. This does not redact arbitrary authorized command results, CDP data, preferences, or screen
captures. Scenario directories are owner-only and JSON reports are written with mode 0600. Review
artifacts before sharing; a live `logs` pull copies the current folder without a fresh export scrub.
## Device selection and output
The CLI resolves one authorized device using `ADB_SERIAL` or `ANDROID_SERIAL` when set. Otherwise it
de-duplicates transports that report the same device serial and asks for an explicit serial if
multiple devices remain. Pass `--serial` before the command to choose directly; set `ADB` (or pass
`--adb`) to choose an adb executable. `tools/deploy_local.sh` uses the same resolver.
Every command writes one JSON object to stdout and its resolved serial to stderr. Exit codes: 0
success, 2 invalid or rejected command, 3 ADB or device error, 4 session error, 5 timeout, 6
artifact or file error, 7 debugging tools disabled.
## Commands
Build and install the debug APK:
```sh
tools/droiddeckctl state # schema 2, below
tools/droiddeckctl start steam --wait # --ui desktop, --url steam://..., --timeout
tools/droiddeckctl start steam --wait --reuse # keep a Steam session that is already up
./gradlew assembleDebug --console=plain
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
The provider can also be called directly for state, stop, and resume:
```sh
adb shell content call --uri content://com.droiddeck.launcher.agent --method state
adb shell content call --uri content://com.droiddeck.launcher.agent --method stop
```
Start requests go through `tools/droiddeckctl`, which launches the protected debug Activity from the ADB shell. Calling the provider's `start` method directly returns an error because a background provider cannot reliably open the session screen.
The host CLI resolves one authorized device using `ADB_SERIAL` or `ANDROID_SERIAL` when set. Otherwise it deduplicates transports that report the same device serial and asks for an explicit serial if multiple devices remain.
```sh
tools/droiddeckctl state --json
tools/droiddeckctl start steam
tools/droiddeckctl wait ready --timeout 90
tools/droiddeckctl start desktop
tools/droiddeckctl run /usr/bin/foo -- arg1 arg2
tools/droiddeckctl wait ready|idle|failed # --timeout (default 90 s)
tools/droiddeckctl stop
tools/droiddeckctl resume
tools/droiddeckctl launch 489830 --wait # Steam game by app id; --wait = until it has focus
tools/droiddeckctl wait focused 489830 # gamescope focuses it AND Steam lists it focusable
tools/droiddeckctl wait game # any app but the Steam client (769) has focus
tools/droiddeckctl quit 489830 # Steam's TerminateApp, then SIGTERM/SIGKILL
tools/droiddeckctl focus # focus properties and every Xwayland server's windows
tools/droiddeckctl input key ENTER # names (ESC, F1, UP, A...) or evdev codes; --mod CTRL
tools/droiddeckctl input text 'hello world' # as US-layout keys; --commit for text-input
tools/droiddeckctl input tap 640 360 # session output pixels; --space fraction for 0..1
tools/droiddeckctl input swipe 100 600 100 100 --duration 400
tools/droiddeckctl input pad a # a b x y lb rb start select guide qam up down left right
tools/droiddeckctl input stick left 0 -1 --hold 500
tools/droiddeckctl input trigger right 1.0
tools/droiddeckctl ui dump # tagged, labelled and clickable nodes; --all for every node
tools/droiddeckctl ui click rail-steam # by test tag
tools/droiddeckctl ui click --match "Play" # by text or description; --index for the nth match
tools/droiddeckctl ui set-text some-field --text abc
tools/droiddeckctl guest -- xprop -root GAMESCOPE_FOCUSED_APP # --timeout --env K=V --cwd --stdin --check
tools/droiddeckctl cdp targets
tools/droiddeckctl cdp eval 'SteamClient.Apps.GetMyRunningApps?.()' # --target, --no-await
tools/droiddeckctl env set DXVK_HUD=fps # next session only; --persistent until cleared
tools/droiddeckctl env list
tools/droiddeckctl env clear [next|persistent|all]
tools/droiddeckctl override gamescope ./gamescope # first on the next session's PATH, removed after it
tools/droiddeckctl prefs get session
tools/droiddeckctl prefs set session some_key true --type bool # returns the previous value
tools/droiddeckctl logs latest ./session-artifacts
tools/droiddeckctl screenshot ./screen.png --display 4
tools/droiddeckctl record ./clip.mp4 --seconds 8 --display 0
tools/droiddeckctl displays # logical ids, names, sizes, capture ids
tools/droiddeckctl wake # wake the screen, dismiss the keyguard
tools/droiddeckctl screenshot ./screen.png
```
A sleeping device suspends the session and hides the app's windows from `ui dump`; `wake` first.
Every command writes JSON to stdout and reports its resolved ADB serial to stderr. Exit codes are 0 for success, 2 for invalid or rejected commands, 3 for ADB/device errors, 4 for session failures, 5 for timeouts, and 6 for artifact or file errors. Set `ADB` to select an adb executable (or pass `--adb`); pass `--serial` before the command to select a device directly.
`input` needs a session that has drawn its first frame. Pad input goes through the session screen's
virtual pad, the same one the on-screen controls drive, so games that ignore touch still respond.
`launch`, `quit` and `cdp` need a Steam session; `focus` and `wait focused` need gamescope (every
mode except the desktop).
`start` accepts `steam` or `desktop`. Steam starts in Big Picture by default; `--ui desktop` selects the client's desktop UI, and `--url steam://...` passes a client URL. Use `--wait` to wait for `READY` as part of `start`. `run` accepts a program path and optional guest arguments.
## State (schema 2)
The `state` response uses schema 1 and includes the build label, runtime version, session ID and phase, mode, requested program, suspend and first-frame state, output size, guest PID, failure details, and artifact paths. Phases are `IDLE`, `PREPARING`, `INSTALLING_RUNTIME`, `STARTING_COMPOSITOR`, `STARTING_GUEST`, `STARTING_STEAM`, `READY`, `SUSPENDED`, `STOPPING`, and `FAILED`. Each session also writes `events.jsonl` alongside its existing artifacts.
`state` returns `schema`, `build`, `appVersion`, `runtime {installed, version}`,
`agent {guest, guestFocus, commands}` and `session`:
- `id`, `phase`, `running`, `mode`, `program`, `steamUi`, `steamUrl`, `suspended`, `pip`,
`firstFrame`, `output` [w, h], `refreshHz`, `lastTransitionAt`, `guestPid`, `installing`
- `logDir`, `eventsFile`, `artifactsAvailable`, `artifactsComplete`, `failure {code, message, status}`
- `focus`: gamescope's focus as the guest agent last published it, or null:
`focusedApp`, `focusedWindow`, `focusableApps`, `baselayerAppIds`, `seq`, `t`.
Phases: `IDLE`, `PREPARING`, `INSTALLING_RUNTIME`, `STARTING_COMPOSITOR`, `STARTING_GUEST`,
`STARTING_STEAM`, `READY`, `SUSPENDED`, `STOPPING`, `FAILED`. Schema 2 only added `agent` and
`session.focus`; every schema 1 field is unchanged.
A game the player can see is `focusedApp == appId` **and** `appId in focusableApps`.
`GAMESCOPE_FOCUSABLE_WINDOWS` is what the Steam client orders the base layer from: a game missing
there stays behind the client's loading screen however healthy its window looks
(docs/development/steam-focus-debugging.md). `focus` returns the full snapshot, including each
server's windows with map state, override-redirect, app id, `WM_STATE`, pid, class, opacity and
`_NET_WM_STATE`.
## Events
Each session writes `events.jsonl` beside its other artifacts: the lifecycle (`session.created`,
`compositor.started`, `guest.exited`, ...) plus `agent.ready` (the guest agent is up),
`focus.changed` (the focused app, focusable apps or base-layer order changed) and
`agent.launch_requested`.
## UI test tags
Compose test tags are exposed as resource ids (`ui/TestTags.kt`), so both `ui dump` and UI
Automator show them:
| Tag | Where |
|---|---|
| `rail-steam`, `rail-games`, `rail-desktop`, `rail-store`, `rail-components`, `rail-android-apps`, `rail-setup`, `rail-updates` | the front end's rail |
| `play-steam` | Play on the Steam and Games pages |
| `setting-<key>` | every toggle and choice row, by its key (`setting-logs`, `setting-agent-commands`, ...) |
| `tab-<n>` | a page's tab strip (Setup's Overview, Controller, Session, Launcher...), by position |
| `drawer-tab-controller`, `-second_screen`, `-display`, `-effects`, `-games`, `-session` | the session drawer's pages |
| `drawer-stop`, `drawer-stop-confirm`, `drawer-stop-cancel` | stopping a session from the drawer |
## Scenarios
`tools/droiddeck-scenario` runs a JSON scenario: `setup` once, `steps` `repeat` times, `teardown`
once. Each run starts with `before` (typically `start steam --wait --reuse`, so a session that died
in one run, to a crash or the low-memory killer, is started again for the next) and ends with
`after`, whether it passed or not, so a failed run cannot leave a game running into the next. A list step is a droiddeckctl command line; `{"assert": "dotted.path", "equals"|"in"|
"contains"|"not"|"exists": ...}` checks the last answer; `{"sleep"}`, `{"screenshot"}` and
`{"record"}` do what they say. A failing run keeps a screenshot, the focus snapshot and the
session's logs in its folder, and `report.json` sums up the runs.
```sh
tools/droiddeck-scenario tools/scenarios/steam-game-focus.json --var appId=489830 --repeat 10 --keep-going
```
`tools/scenarios/steam-game-focus.json` launches a game, checks it has focus the way a player
sees it, and quits it: the test that catches a game left behind Steam's loading screen, which only
shows up in some fraction of launches. A game with a launcher of its own needs the launcher pressed
through first, since the launcher window carries the game's app id too:
`tools/scenarios/skyrim-launcher-focus.json` taps Skyrim's Play before checking.
`tools/deploy_local.sh` uses the same device resolver. If multiple distinct devices are connected, select one with `ADB_SERIAL` or `ANDROID_SERIAL`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

+3 -3
View File
@@ -61,7 +61,7 @@ Why the current choices were made, from the history:
All runs on the AYN Thor (SD 8 Gen 2, Adreno 740, 1920x1080 top panel), Deck mode on, Force
fullscreen off, with a spike build of `feat/steam-native-output`. Each run sets
`Android/data/com.droiddeck.launcher/files/droiddeck-env` and starts the session with
`Download/droiddeck-env` and starts the session with
`am start -n com.droiddeck.launcher/.SessionActivity --es mode steam [--es steamUrl ...]`.
### Run 1: baseline (Resolution = Match screen, 1920x1080)
@@ -181,7 +181,7 @@ game. `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1` went through `Downlo
## Two Xwayland servers by default (`feat/steam-dual-xwayland`)
Steam sessions now start gamescope with `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1`.
`BL_XWAYLAND_COUNT=1` in `Android/data/com.droiddeck.launcher/files/droiddeck-env` goes back to one. The session script's
`BL_XWAYLAND_COUNT=1` in `Download/droiddeck-env` goes back to one. The session script's
root-window requests that are meant for the game (the drawer's live Force fullscreen,
`GAMESCOPE_FORCE_WINDOWS_FULLSCREEN`, and the resume watcher's `GAMESCOPE_RESTORE_FOCUS_WINDOW`)
go to both displays, `$DISPLAY` and `STEAM_GAME_DISPLAY_0`. gamescope reads both per server, so
@@ -222,7 +222,7 @@ reason "Force game windows fullscreen" existed. What it still did was make any g
Resolution differs from what it draws fight it (runs 4 and 5). The switch is gone from Steam
settings (Games) and the session drawer (Display), along with its preference, the
`BL_GAMESCOPE_FORCE_FULLSCREEN` flag, the `~/.droiddeck-fill` live watcher and the device report
line. `BL_GAMESCOPE_ARGS=--force-windows-fullscreen` in `Android/data/com.droiddeck.launcher/files/droiddeck-env` still passes the
line. `BL_GAMESCOPE_ARGS=--force-windows-fullscreen` in `Download/droiddeck-env` still passes the
flag for an experiment.
Device check (Thor): Alan Wake's American Nightmare with its 1280x720 Game Resolution runs on `:1`
+1 -1
View File
@@ -81,7 +81,7 @@ $R/usr/lib/ld-linux-aarch64.so.1 --library-path $R/usr/lib $R/usr/bin/xprop -dis
nothing.
- To test gamescope with a candidate binary without rebuilding the apk, put it in the rootfs (for
example `/opt/gstest/gamescope`, owned by the app's uid) and add
`PATH=/opt/gstest:/usr/local/bin:/usr/bin:/bin` to `Android/data/com.droiddeck.launcher/files/droiddeck-env`. The session log's
`PATH=/opt/gstest:/usr/local/bin:/usr/bin:/bin` to `Download/droiddeck-env`. The session log's
first lines name the gamescope that ran. Remove the line afterwards: the app stages its own
gamescope over `/usr/local/bin` at every session start.
- Never `pkill -f <pattern>` inside `su -c '…'`: the pattern matches that shell's own command line.
+402
View File
@@ -0,0 +1,402 @@
# Steam's touch controller in a DroidDeck session
Research notes, started 2026-10-07. The question: instead of DroidDeck drawing its own on-screen
pad and keeping its own profiles, can the session use the touch controller Steam already has for
the Steam Link app, so that touch layouts are per game, synced to the Steam account and shared
through Steam's community configs?
This page records what the Steam client contains, the candidate approaches, and the experiments
run on the Thor to decide between them; details are in the [Experiment log](#experiment-log).
## Conclusion
**Yes, and without streaming anything.** Steam's touch controller is a Steam Input controller type
(Mobile Touch, `controller_mobile_touch`) that Steam builds for any HID device with the ids
0000:11fb. Steam Link only supplies that device remotely; libfakeinput can supply it locally, the
way it already supplies the Deck pad. Shown on the Thor with an experimental build:
- Steam opens a local `/dev/hidraw17` 0000:11fb, builds the touch controller (type 43, the same
attributes as Steam Link's), loads the game's touch config for it (Valve's touch templates,
official per-game touch configs, the user's own synced ones, community ones), and games get
its input through Steam Input's virtual pad. A touch-controller stick report flew the ship in
Geometry Wars.
- Steam tells the device which app and action set are active (output report 4) and when action
set layers come and go (5, 6), live, plus rumble (1). That is what decides which layout to show.
- Big Picture treats it as a touch controller: the in-game menu shows the touch layout preview,
bindings are edited in Steam's own configurator.
- It can be plugged and unplugged mid-session (injected udev events), so it can step aside when
a physical pad is used, as Steam Link does.
- The layout is part of the config file (`touch_layout`, a `CVirtualControllerLayouts` protobuf),
so it syncs and is shared with the config. Steam loads a touch config written to disk.
**Recommended: approach D below.** DroidDeck draws the controls (from Steam's layout) and sends
the device's 40-byte report; Steam does everything else. It needs no stream, no pairing, no video
and no patching of Steam, and the parts it depends on are the ones Valve has to keep stable for
the Steam Link apps already installed on phones and TVs: the device's ids and report format.
Not settled yet: saving an edited layout goes through a file write rather than a Steam call (Steam
loads the written file and queues it for Steam Cloud like its own autosaves); the overlay must
reproduce Steam Link's default layout and its hiding of unbound controls; touch menus and gyro
were not exercised.
## How Steam Link's touch controller works
Sources: the arm64 client DroidDeck installs (`steam_client_steamdeck_publicbeta_linuxarm64`,
manifest version 1791249696), the protobuf definitions in
[SteamDatabase/Protobufs](https://github.com/SteamDatabase/Protobufs)
(`steam/steammessages_remoteplay.proto`, `steam/steammessages_virtualcontroller.proto`) and Valve's
[touch controller guide](https://steamcommunity.com/app/353380/discussions/4/1735462352489233412).
The work is split between the streaming client (what runs on the phone) and the Steam client being
streamed from (the host):
- **Client: drawing and editing.** `streaming_client` holds `CVirtualController`, built from
`streaming/client/virtualcontroller.cpp`: it draws the controls over the video, runs the layout
editor (tray, colour picker, move/scale), and sends raw touches to the host as
`CInputTouchFingerDownMsg` / `Motion` / `Up` (finger id, x and y normalised to the video).
- **Host: the controller.** `steamui.so` holds the stream server (`CStreamServer`,
`IStreamServerInputDelegate`). `steamclient.so` holds `CMobileTouchControllerAbstraction`, a
Steam Input controller backend next to `CSteamOSHandheldAbstraction` (the Deck),
`CSteamControllerAbstraction` and `CGenericGamepadControllerAbstraction`. Its display name is
"Steam Link Touch Controller" and its config type is `controller_mobile_touch`.
- **Host: the config.** The host owns the touch config and pushes it to the client:
`k_EStreamControlTouchConfigActive` (appid, revision, creator),
`GetTouchConfigData`/`SetTouchConfigData` (config data and layout blobs),
`SaveTouchConfigLayout` (the client sends back an edited layout), `Get`/`SetTouchIconData`,
`TouchActionSetActive` and `TouchActionSetLayerAdded`/`Removed`. Touch configs are Steam Input
configs like any other: per game, saved to the account, publishable. SteamInputDB counted
10,540 community configs for Mobile Touch in March 2026.
Every piece of this is in the arm64 package DroidDeck already installs: the host side in
`steamrtarm64/steamclient.so` and `steamui.so`, and the full renderer and editor in
`steamrtarm64/streaming_client`. The package also carries an `androidarm64/libsteamclient.so`
with the same touch strings.
## Where DroidDeck is today
The session's pad is presented to the Steam client as a Steam Deck controller (28de:1205 through a
served `hidraw` node and a written sysfs, see `SteamDeckPad.kt` and `fakeinput_steam.cpp`), so Deck
configs, per-game bindings and Deck community configs already apply. What Steam's touch controller
would add: control placement, icons, touch menus and on-device layout editing defined by Steam, and
the touch-specific community configs.
## Candidate approaches
| | How | Verdict |
|---|---|---|
| **A. Local Remote Play** | DroidDeck streams from its own Steam client the way the Steam Link app does (pairing, start stream, touch input negotiated). | **Works, not worth it.** The official Steam Link app on the Thor pairs with the session's Steam over loopback and its touch controls drive the session (experiment 2). But the host captures and software-encodes video (libx264, 72-85 % CPU, frames froze), the Steam Link app cannot turn video off, and an input-only client of our own means reimplementing the Remote Play transport and crypto. The protocol does have `enable_video_streaming` in the streaming request. Kept as a fallback only. |
| **B. In-process injection** | Feed touches into the path between the stream server (`steamui.so`) and `CMobileTouchControllerAbstraction` (`steamclient.so`). | **Not needed.** That path is a HID device; D supplies the device instead of patching Steam. |
| **C. Data only** | Read Steam's touch configs and layouts, draw them ourselves, keep feeding the Deck pad. | **Rejected.** Touch configs bind straight to keys, mouse and actions; honouring them would mean reimplementing Steam Input. (D still reads the layout from the config, but Steam does the bindings.) |
| **D. Local Mobile Touch device** | libfakeinput serves 0000:11fb as a local hidraw node; DroidDeck draws Steam's layout and writes the 40-byte report. | **Works (experiments 3, 4). Recommended.** |
## Experiments
1. **Host side alive?** Yes: discovery answers on loopback and Steam Link pairs.
2. **Touch controller over a stream?** Yes, end to end, with Steam Link on the same device.
3. **Loopback cost.** Too high: software video encode (part of 2).
4. **Input without video.** Not run: needs our own Remote Play client; superseded by D.
5. **Valve's renderer as an overlay.** Not run: `streaming_client` draws over decoded video and
lives behind the stream; superseded by D.
6. **Injection points.** The touch controller enters Steam as a HID device (0000:11fb), which is
approach D.
7. **Local 0000:11fb device.** Works: controller built, config loaded, games driven (section 3).
8. **Hotplug.** Works with injected udev events (section 4).
9. **Layout written to disk.** Steam loads it (section 5).
## Experiment log
### 1. Host side alive: yes (2026-10-07, Thor, Steam 1791249696)
- With a Steam session READY, the client listens on TCP 27036 and UDP 27036 (`/proc/net`, app
uid). A discovery probe built from `steammessages_remoteclient_discovery.proto` sent to
`127.0.0.1:27036` from the device gets a `CMsgRemoteClientBroadcastStatus` back: hostname
`DroidDeck`, `enabled_services` 98 (game streaming on), the signed-in user, `steam_deck`
unset, `gaming_device_type` 541.
- The official Steam Link app (Play Store, on the Thor's bottom screen while the session runs on
the top one) finds "DroidDeck" by itself, shows a PIN, and the session's Big Picture shows
**Authorize Device**. After the PIN the app runs its network test and lists DroidDeck as
paired. Pairing to the session's own client over loopback works with no changes.
- Launching Steam Link on display 0 instead puts DroidDeck's session activity into PiP; use
`am start --display 4`. On first start Steam Link sits on its splash until BLUETOOTH_CONNECT
is answered, and the permission dialog does not render on the second screen (granted with
`pm grant`).
### 2. Touch controller over the stream: works end to end
Starting a stream (`streaming_log.txt`, `console_log.txt` in the session's Steam `logs/`):
```
CLIENT: Sending HID device 2020/0112/-1 Xbox Wireless Controller at sdl://3
CLIENT: Sending HID device 0000/11fb/-1 Mobile Touch Control at touch://0
...
Remote Device Found type: 0000 11fb path: touch://0 Product: Mobile Touch Control
!! Steam controller device opened for index 1.
Controller device closed after hid_read failure
Controller 1 disconnected
```
- **The touch controller reaches the host as a remote HID device, 0000:11fb**, opened by the
same controller code that opens the Deck pad, and turned into a virtual controller. The
streaming client is what makes the reports; Steam on the host only sees a HID device.
- Video fails on the host: `PipeWire: Could not connect PipeWire context` (the session runs no
PipeWire), so the app gets audio but a black screen and keeps its loading spinner.
- The app then re-sends only the Thor's built-in pad and drops the touch device; it seems to
withdraw touch controls when a physical controller is present, or until video arrives.
With video fixed (below), **Steam's touch controller runs against the session's own client**:
- Steam Link withholds its touch controls while a physical pad is attached (the Thor's own pad
shows up as "Xbox Wireless Controller"). A four-finger tap opens its stream menu, which has
**Enable Touch Controls** (injected with `sendevent` on `/dev/input/event5`, the bottom panel).
- The client then re-sends `touch://0`; the host opens it as controller type 43, product 0x11fb,
serial `MT-<Steam Link device id>`, capabilities `0000007f83045bff`, creates a virtual controller
for it, and sends the client `TouchConfigActive`, `TouchActionSetActive` and
`SetTouchConfigData`. The app draws Big Picture's touch layout (d-pad, ABXY, Steam button,
keyboard, `...`).
- Touching the controls drives the session: the touch Steam button opens Big Picture's main
menu on the Thor's top screen, touch B closes it, and the session's glyphs switch to the touch
controller's. (D-pad taps from `input tap` did not register; not chased.)
- Cost: the host encodes with libx264 in software (`/dev/video-enc0` missing), 4 threads,
1240x698. CPU sat at 72-85 % in the overlay and `k_EStreamControlVideoOverflow` flooded the log;
the app's picture froze within a minute while input kept working. **Video loopback is not a
shippable path**; only the input half of the stream is useful.
Video fix used for this test only: PipeWire and WirePlumber are in the rootfs and gamescope is
built with PipeWire, so a `gamescope` wrapper on `PATH` (via `Download/droiddeck-env`) that starts
`pipewire` and `wireplumber` first gives gamescope a capture node (`stream available on node ID`)
and the client a picture. (Editing `droiddeck-session` on the device does not stick: the app
re-syncs the overlay each session.)
### Where Steam keeps touch configs
In the session's Steam, `steamapps/common/Steam Controller Configs/<account id>/config/`:
- `<appid>/controller_mobile_touch.vdf` for each game with a saved touch config (dozens already
there for this account, synced down from Steam Cloud), plus named copies like
`<appid>/default touch_0.vdf`;
- `configset_controller_mobile_touch.vdf`: which config each app uses (`autosave` = the per-app
file above, otherwise a template or workshop id);
- `configset_MT-<serial>.vdf`, `preferences_MT-<serial>.vdf` and `config/MT-<serial>_gyro.vdf`:
per touch device.
A touch config is an ordinary `controller_mappings` VDF (`controller_type` `controller_mobile_touch`,
bindings per action set and layer) with one extra key, **`touch_layout`: the on-screen layout as
a hex-encoded `CVirtualControllerLayouts` protobuf**: one layout per action set, each a list of
elements (`EControllerElementType`, visible, x/y position normalised to the screen, x/y scale)
and a colour, plus input mode, mouse mode and pinch-zoom settings. Bindings and layout travel
together in the one file that syncs and is shared as a community config.
### The touch device's HID protocol
From Steam Link for Android (`libmain.so`, exported symbols; `CVirtualController` is both the
renderer and the HID device behind `touch://0`):
- **Input report: 40 bytes** (`CVirtualController::Read`), kept at `this+200` and marked dirty
at `this+240` by every setter. Bytes 0-7: button/touch bits (e.g. bit 27, 19, 20: trackpads
0, 1, 2 touched). Bytes 16, 20, 24: trackpads 0-2 as int16 x (centred, `x*65535 ^ 0x8000`) and
y (inverted). Remaining fields (sticks, triggers, gyro) not yet mapped.
`CHIDDeviceReportGenerator::BInjectGamepadStateMobileTouch` is a stub; the report is the
controller's own format, not the generic SDL gamepad state.
- **Output reports from Steam** (`Write` → `QueueFeatureReport` → `HandleFeatureReports`),
at least 17 bytes, first byte the type:
- 1: rumble (u16 low, u16 high, u32 duration);
- 3: setting `0x30` with a u16 value (a flag at `this+1488`, likely gyro);
- **4: action set change** (u32 appid, u32 action set id): switches the layout shown;
- **5 / 6: action set layer added / removed** (u32 appid, u32 layer id).
- **Feature report 2**: battery (level, from `SDL_GetPowerInfo`), 17 bytes.
So Steam drives action sets, layers and rumble over HID. The only things that come solely over
the stream are the config and layout blobs (`SetTouchConfigData`), the active config's
appid/revision/creator (`TouchConfigActive`) and custom icons (`SetTouchIconData`); the first two
are on disk in the VDF above.
### 3. Without a stream: Steam takes a local 0000:11fb device (works)
Experimental build on this branch: `FAKE_EVDEV_TOUCHCTL=1` (in `Download/droiddeck-env`) makes
libfakeinput serve `/dev/hidraw17` as 0000:11fb "Mobile Touch Control" next to the Deck pad, with
its sysfs written by `SteamDeckPad.prepare` (a second USB device, `usb2`). The 40-byte input report
is read from `/tmp/touchctl.report` in the session; every output and feature report the client
sends is logged to `pad.log` and appended to `/tmp/touchctl.out`.
- With a vendor-page report descriptor (`06 ff ff 09 01`, as the Deck's) the client walks the
whole sysfs tree, reads the descriptor and never opens the node. **With Generic Desktop / Game
Pad (`05 01 09 05`) it opens it**: for a non-Valve vendor id the client wants a gamepad usage.
- Steam then builds the touch controller exactly as it does for Steam Link:
```
Local Device Found type: 0000 11fb path: /dev/hidraw17 serial_number: MT-DROIDDECK0001
!! Controller 0 attributes: Type: 43 ProductID: 4603 Serial: MT-MT-DROIDDECK0001
Capabilities: 0000007f83045bff
```
and speaks the touch protocol to it: `get-feature 02` (battery), output `03 30 ..` (the setting
report), and **output `04 01 03 00 00 01 00 00 00`: action set 1 of app 769** (Big Picture).
- Writing button bits into the report drives the client: Steam (`0x2000`) opens Big Picture's
main menu, B (`0x20`) closes it. Opening the menu made Steam send `04` with app 443510 (the
Steam menu's own touch config; it is one of the app folders in the config directory) and
closing it `04` with 769 / 1 again: **the active app and action set arrive over HID, live.**
Button bits (Steam Link's `CVirtualController` table at `0x463e70`, indexed by
`EControllerElementType - 1`), the same layout as the Deck report's low word:
| Element | Bit | | Element | Bit |
|---|---|---|---|---|
| A | 7 | | Select | 12 |
| B | 5 | | Steam | 13 |
| X | 6 | | Start | 14 |
| Y | 4 | | Left stick click | 22 |
| Left bumper | 3 | | Right stick click | 26 |
| Right bumper | 2 | | Macro 0-7 | 32-39 |
| Left trigger | 1 | | 1-finger / 2-finger macro | 48, 49 |
| Right trigger | 0 | | Trackpad 0 / 1 / 2 touched | 27 / 19 / 20 |
Report layout, all little-endian (Steam Link `CVirtualController`: `TouchControl_JoyButton`,
`TouchControl_JoyStick`, `SetTrackpad`, `UpdateControllerState`):
| Bytes | Field |
|---|---|
| 0-7 | u64 buttons and touch flags (table above; d-pad as the Deck's: up 8, right 9, left 10, down 11; left / right stick touched 46 / 47) |
| 8-15 | int16 left X, left Y, right X, right Y: `x * 32767`, Y stored inverted (`~(y * 32767)`) |
| 16-27 | three trackpads, int16 x (`x * 65535 ^ 0x8000`) and y (`0x7fff - y * 65535`) |
| 28-33 | accelerometer xyz, int16, ±2 g (19.61 m/s²) to ±32767 |
| 34-39 | gyro xyz, int16 |
**Games, through Steam Input, with no stream.** With the session started on Geometry Wars:
Retro Evolved (8400) and the game launched by pressing A twice on the local device, the client
loaded `controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf` for app 8400 on the
touch controller (Valve's stock touch template), told the device `04 d0 20 00 00 ...` (app 8400),
and a left-stick report flew the ship across the screen. Steam's own path end to end:
DroidDeck → `/dev/hidraw17` → Mobile Touch controller → the game's touch config → Steam Input's
virtual Xbox pad → the game. D-pad bits navigated the Steam menu the same way.
What the stream gives that the local device does not:
- **The active config and its layout.** Steam Link asks for it (`GetTouchConfigData`) and the host
answers with the config and the layout. Locally the device is told only the app id and action
set (report 4), so the overlay has to resolve the config itself: `configset_controller_mobile_touch.vdf`
per app (`autosave` = `<appid>/controller_mobile_touch.vdf`, otherwise a template or workshop
config), and the console log line `Loaded Config for ... App ID <n>, Controller <i>: <path>`
names the file Steam actually loaded. Stock templates carry no `touch_layout`; Steam Link draws
its built-in default layout for them (`CVirtualController::ResetLayoutToDefault`), hiding
elements the config leaves unbound (`UpdateElementAvailability`).
- **Saving an edited layout.** Steam Link sends `SaveTouchConfigLayout`; the host writes it into
the app's autosave config, which is what syncs. The local device has no such message. Big
Picture's `SteamClient.Input` API (seen in `steamui/sp.js`) has the config editing calls
(`StartEditingControllerConfigurationForAppIDAndControllerIndex`,
`SetEditingControllerConfigurationMiscSetting`, `SaveEditingControllerConfiguration`,
`GetConfigForAppAndController`, `QueryControllerConfigsForApp`, `SetSelectedConfigForApp`,
`GetTouchMenuIconsForApp`, `RegisterForTouchMenuMessages`) but no layout call; whether
`touch_layout` goes through the misc-setting call is untested. The session's devtools port only
admits guest processes (`BL_CDP_GUARD`), so this needs the agent bridge's guest commands rather
than a probe from adb.
- **Custom icons** (`SetTouchIconData`).
Everything else (bindings, action sets, layers, touch menus, rumble, the configurator, community
configs, cloud sync) is Steam's own and works with the local device.
### 4. Two controllers, and hotplug
With the touch device present beside the Deck pad, Steam creates a virtual pad for each
(`Created virtual controller at slot 0 for controller 0` / `slot 1 for controller 1`): a game
sees two players. Steam Link avoids this by withdrawing its touch controls while a physical pad is
in use, and a local device has to do the same, which needs hotplug.
- **Unplug works as is.** Ending the device's report stream (`/tmp/touchctl.off` in the experiment)
gives `Controller device closed after hid_read failure`, `Controller 0 disconnected`, and the
virtual pad is destroyed. Steam re-enumerates at once, and an open refused then keeps it off.
- **Plugging back in needs an event.** The client watches only `/dev/input` with inotify (already
redirected by libfakeinput) and never rescans hidraw on its own; an inotify event in
`/dev/input` did not trigger a HID rescan. Its HID discovery is SDL's, driven by a udev monitor,
and the session's monitor is `udevmon.c`'s silent stand-in (the sandbox refuses netlink).
- **Injected udev events work.** The experiment extends the stand-in: a file in `/tmp/udev-inject`
(`ACTION`, `DEVPATH`, `SUBSYSTEM=hidraw`, `DEVNAME`, `MAJOR`, `MINOR`) is sent by every process
with a stand-in as udevd's message format (`libudev` header, magic `0xfeedcafe`, properties plus
`SEQNUM`), and the stand-in's `recvmsg` (through `ntsync.c`, which owns the symbol) reports the
udev multicast group as the sender and root credentials, as libudev requires. An injected `add`
for `hidraw17` made the client open the node again within a second and rebuild the touch
controller (type 43) and its virtual pad, mid-session. (The sender address has to be written
from the room `msg_namelen` had before the call; a unix socket sets it to 0.)
So DroidDeck can show and hide Steam's touch controller the way Steam Link does: plug the device
in when the on-screen controls are shown, unplug it when a physical pad takes over.
### 5. Configs and layouts written by DroidDeck
With the session stopped, a `config/8400/controller_mobile_touch.vdf` was written (Valve's gamepad
template plus a `touch_layout` taken from another config) and `"8400" { "autosave" "1" }` added to
`configset_controller_mobile_touch.vdf`. Launching the game:
```
Loaded Config for Local Selection Path for App ID 8400, Controller 0:
.../Steam Controller Configs/392297941/config/8400/controller_mobile_touch.vdf
```
Steam used it as the game's touch config and left the file (and its `touch_layout`) as written.
**Steam Cloud picked it up too**: Steam Controller Configs sync as app 241100's cloud files, and
`userdata/<account>/241100/remotecache.vdf` gained an entry for
`392297941/config/8400/controller_mobile_touch.vdf` (`syncstate` 3, `remotetime` 0: queued for
upload), the same bookkeeping as the account's other touch configs. Both files were put back
afterwards.
Also seen: pressing A on the touch device logged `Seating controller 1 in slot 0`. Steam moves the
controller in use to the first player slot, which softens the two-controller problem even before
the device is unplugged.
### 6. Steam Link's default layout
Stock templates carry no `touch_layout`. Steam Link then places each control from a built-in table
(`CVirtualController::BInitializeDefaultElement`, table at `0x463d48`, positions on a 1280x720
reference):
| Element | Position (of 1280x720) |
|---|---|
| Thumb (menu) | 75, 75 |
| Select / Steam / Start | 516, 75 / 636, 75 / 756, 75 |
| Paste / Keyboard | 1115, 75 / 1205, 75 |
| Magnifying glass / record / playback | 1205, 165 / 255 / 345 |
| D-pad | 200, 525 |
| X / Y / B / A | 1003, 527 / 1083, 447 / 1163, 527 / 1083, 607 |
Sticks, triggers, bumpers and macros are not in the table; they appear when the config binds them
(`UpdateElementAvailability`), and the overlay has to reproduce that rule.
## What building D would take
- **libfakeinput:** the 0000:11fb node as in the experiment (Generic Desktop / Game Pad report
descriptor, 40-byte input reports, output reports 1, 3-6 and feature report 2), fed from a
ring the app writes, like the Deck pad's.
- **SteamDeckPad.kt:** the second device's sysfs, as in the experiment.
- **udevmon.c:** event injection for plug and unplug, with a proper channel from the app instead of
the experiment's file drop.
- **The overlay (Kotlin):** read the active config (configset per app, else the template Steam
logged loading), decode `touch_layout` (or the default table above), show the elements of the
action set Steam names in report 4 and the layers in 5/6, hit-test touches into the report
(buttons, sticks, trackpads; motion from PadMotion into the gyro fields), rumble from report 1.
Hide the unbound controls as Steam Link does.
- **Editing:** move/scale in the overlay, saved by writing `touch_layout` into the app's autosave
config, as Steam does when Steam Link saves a layout.
- **Policy:** plug the device in while the on-screen controls are up and no physical pad is in use;
unplug it otherwise.
The experimental pieces on this branch (`FAKE_EVDEV_TOUCHCTL`, `/tmp/touchctl.*`,
`/tmp/udev-inject`) are research scaffolding to be replaced, not shipped.
### 7. Several controllers
`FAKE_EVDEV_TOUCHCTL=2` serves two touch devices (`/dev/hidraw17` and `18`, serials
`MT-DROIDDECK0001` and `0002`, each with its own sysfs `usb2`/`usb3` and report file). With the
Deck pad that is three controllers, and Steam takes all three:
```
Controller 15 Type 4 (Deck pad, /dev/hidraw16)
Controller 0 Type 43 Serial MT-MT-DROIDDECK0001
Controller 1 Type 43 Serial MT-MT-DROIDDECK0002
Created virtual controller at slot 0 / 1 / 2
uinput: "Microsoft X-Box 360 pad 0" / "pad 1" / "pad 2" is /dev/input/event16 / 17 / 18
```
Each gets its own Steam Input config and its own virtual pad, so a game sees three players; a
Steam press on the second device alone opened the Steam menu (Steam answered it with report 4 for
the menu's action set). Several touch controllers would allow, say, a second player on the Thor's
bottom screen. The other physical pads DroidDeck passes through (ring slots 1-3 as evdev pads) sit
beside these as ordinary gamepads; no external pad was connected for this test.
-64
View File
@@ -1,64 +0,0 @@
# DroidDeck 0.3.2
### Features
- Added **Keep downloads running** toggle, so Steam downloads can continue when Android sleeps. (@jmarti326, @xXJSONDeruloXx, [#335](https://github.com/Droid-Deck/DroidDeck/pull/335))
<img src="media/0.3.2/keep-downloads-running.gif" width="50%" alt="Closing the handheld mid-download and opening it again: Steam's download keeps going while asleep and finishes with the lid shut">
- Added **Native Steam sleep** with dynamic cloud save and wake movie support. (@xXJSONDeruloXx, [#291](https://github.com/Droid-Deck/DroidDeck/pull/291))
<img src="media/0.3.2/native-steam-sleep.gif" width="50%" alt="Octopath Traveler II on the title screen, the device going to sleep, then waking to Steam's wake movie and landing back in the game">
- Added **Adreno 6xx support**, including Adreno 650 (eg: Retroid Pocket 5). (@unbelievableflavour, @jmarti326, [#320](https://github.com/Droid-Deck/DroidDeck/pull/320), [#326](https://github.com/Droid-Deck/DroidDeck/pull/326))
- SD cards and external drives can now be used as full **Steam libraries**. (@maxjivi05, [#388](https://github.com/Droid-Deck/DroidDeck/pull/388))
- Added per-game **Windows components** with automatic dependency recommendations for Steam and non Steam games. (@The412Banner, [#287](https://github.com/Droid-Deck/DroidDeck/pull/287))
<img src="media/0.3.2/windows-components.jpg" width="50%" alt="Windows components for FlatOut 2: DirectX 9 (d3dx9) recommended for this game, above the full list of 66 components">
- Added XNA, PhysX, Games for Windows Live, MSXML and other Windows components without needing to run their original installers. (@The412Banner, [#347](https://github.com/Droid-Deck/DroidDeck/pull/347))
- Added optional **Force SSBS** for ARM64 Proton games on supported CPUs. (@The412Banner, [#328](https://github.com/Droid-Deck/DroidDeck/pull/328))
- Added a detailed **device report** with SoC, GPU, CPU, Vulkan, memory and driver information. (@The412Banner, [#295](https://github.com/Droid-Deck/DroidDeck/pull/295))
- Added **App scale** presets from 75% to 150%. (@xXJSONDeruloXx, [#272](https://github.com/Droid-Deck/DroidDeck/pull/272))
- File Manager can now browse DroidDeck’s Linux filesystem directly. (@The412Banner, [#392](https://github.com/Droid-Deck/DroidDeck/pull/392))
- Added a **Language** setting, with Steam automatically following the selected language. (@maxjivi05, [#284](https://github.com/Droid-Deck/DroidDeck/pull/284))
- Added opt-in **Debugging tools** for automated game/session control, input, UI inspection and repeatable test scenarios. (@xXJSONDeruloXx, [#406](https://github.com/Droid-Deck/DroidDeck/pull/406))
### Improvements
- Steam and games now run on separate Xwayland servers, closer to SteamOS behavior. (@xXJSONDeruloXx, [#344](https://github.com/Droid-Deck/DroidDeck/pull/344))
- DirectAudio is now the default on fresh installs, with major crackling and underrun improvements across both audio modes. (@maxjivi05, [#338](https://github.com/Droid-Deck/DroidDeck/pull/338))
- Steam now preserves edits made to added-game shortcuts, including Target, Start In, name and launch options. (@maxjivi05, [#393](https://github.com/Droid-Deck/DroidDeck/pull/393))
- Steam now uses the full session resolution on displays wider or larger than 1080p. (@xXJSONDeruloXx, [#391](https://github.com/Droid-Deck/DroidDeck/pull/391))
- Improved Snapdragon and Adreno detection on devices with incomplete hardware information. (@The412Banner, [#295](https://github.com/Droid-Deck/DroidDeck/pull/295))
- Improved layouts for small screens and longer translated text. (@xXJSONDeruloXx, @maxjivi05, [#268](https://github.com/Droid-Deck/DroidDeck/pull/268), [#284](https://github.com/Droid-Deck/DroidDeck/pull/284))
- File Manager and file pickers can now navigate upward from game folders, prefixes and remembered locations. (@The412Banner, [#377](https://github.com/Droid-Deck/DroidDeck/pull/377))
- Expanded and polished Spanish, Japanese, Korean, Chinese, French and Russian translations. (@maxjivi05, @starwishHi, @zeedif, @Tak-attack, @kmnkv1, [#284](https://github.com/Droid-Deck/DroidDeck/pull/284), [#286](https://github.com/Droid-Deck/DroidDeck/pull/286), [#234](https://github.com/Droid-Deck/DroidDeck/pull/234), [#314](https://github.com/Droid-Deck/DroidDeck/pull/314), [#324](https://github.com/Droid-Deck/DroidDeck/pull/324))
- Steam and Linux apps now use Android’s system fonts for Chinese, Japanese, Korean, Thai and other missing characters. (@maxjivi05, @0x1428571429, [#374](https://github.com/Droid-Deck/DroidDeck/pull/374), [#252](https://github.com/Droid-Deck/DroidDeck/pull/252))
- Sessions launched from Android frontends now return to that frontend when they close. (@xXJSONDeruloXx, [#300](https://github.com/Droid-Deck/DroidDeck/pull/300))
### Fixed
- Fixed MangoHud reporting roughly double the real battery power draw and incorrect remaining time. (Thanks Russ ❤️)(@xXJSONDeruloXx, [#270](https://github.com/Droid-Deck/DroidDeck/pull/270))
- Fixed slow Steam installs on SD cards and external storage, including long **Reserving space** / **Committing** stages and installs failing to begin. (@maxjivi05, [#388](https://github.com/Droid-Deck/DroidDeck/pull/388), [#410](https://github.com/Droid-Deck/DroidDeck/pull/410))
- Fixed some games staying behind Steam’s black** **loading screen while already running. (@xXJSONDeruloXx, [#401](https://github.com/Droid-Deck/DroidDeck/pull/401))
- Fixed a graphics-memory leak that could kill the session. (@The412Banner, @xXJSONDeruloXx, [#275](https://github.com/Droid-Deck/DroidDeck/pull/275), [#305](https://github.com/Droid-Deck/DroidDeck/pull/305))
- Fixed some added games starting in `C:\windows` instead of their own game folder. (@The412Banner, [#343](https://github.com/Droid-Deck/DroidDeck/pull/343))
- Fixed Firefox and LibreWolf tabs crashing on affected **Android 16** devices. (@The412Banner, [#280](https://github.com/Droid-Deck/DroidDeck/pull/280))
- Fixed resolution presets changing the screen shape, and fixed 16:9 stretch in Steam Deck mode. (@xXJSONDeruloXx, @magarto, [#337](https://github.com/Droid-Deck/DroidDeck/pull/337), [#133](https://github.com/Droid-Deck/DroidDeck/pull/133))
- Fixed several Steam sleep and wake edge cases. (@xXJSONDeruloXx, [#289](https://github.com/Droid-Deck/DroidDeck/pull/289))
- Fixed some new sessions starting with the wrong output size. (@xXJSONDeruloXx, [#357](https://github.com/Droid-Deck/DroidDeck/pull/357))
- Fixed Windows component installation failures and improved controller navigation in the component picker. (@The412Banner, [#347](https://github.com/Droid-Deck/DroidDeck/pull/347))
- Removed the old **Force game windows fullscreen** workaround now that games run on their own Xwayland server. (@xXJSONDeruloXx, [#349](https://github.com/Droid-Deck/DroidDeck/pull/349))
- Hardened the developer environment override so other apps with storage access cannot modify Steam session environment variables. (@The412Banner, [#413](https://github.com/Droid-Deck/DroidDeck/pull/413))
### New contributors
- @unbelievableflavour made their first contribution in [#320](https://github.com/Droid-Deck/DroidDeck/pull/320).
- @Lightfallingonionroom made their first contribution in [#232](https://github.com/Droid-Deck/DroidDeck/pull/232).
- @0x1428571429 made their first contribution in [#252](https://github.com/Droid-Deck/DroidDeck/pull/252).
- @zeedif made their first contribution in [#234](https://github.com/Droid-Deck/DroidDeck/pull/234).
- @Tak-attack made their first contribution in [#314](https://github.com/Droid-Deck/DroidDeck/pull/314).
- @kmnkv1 made their first contribution in [#324](https://github.com/Droid-Deck/DroidDeck/pull/324).
[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.1...0.3.2)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

-245
View File
@@ -1,245 +0,0 @@
#!/usr/bin/env python3
"""Run a device scenario through droiddeckctl, repeatedly, and report each run.
A scenario is JSON (stdlib only, no YAML):
{
"name": "skyrim-focus",
"vars": {"appId": 489830},
"repeat": 6,
"setup": [["wake"]],
"before": [["start", "steam", "--wait", "--reuse", "--timeout", "180"]],
"steps": [["launch", "${appId}", "--wait", "--timeout", "240"],
{"assert": "session.focus.focusedApp", "equals": "${appId}"},
{"screenshot": "in-game"},
["quit", "${appId}"]],
"after": [["quit", "${appId}"]],
"teardown": [["stop"]]
}
A list step is a droiddeckctl command line; its JSON answer becomes "last". An assert step checks
a dotted path in the last answer ("equals", "in", "contains", "exists", "not"); {"sleep": s},
{"screenshot": "name"} and {"record": "name", "seconds": s} do what they say. ${var} is replaced
from "vars" and --var KEY=VALUE. setup and teardown run once; steps run "repeat" times (or
--repeat), each preceded by "before" (a failure there fails the run) and followed by "after"
whether it passed or not, so a failed run cannot leave a game running into the next and a session
that died in one run is started again for the next. A failing run gets a screenshot, the focus state and the session's logs in
its folder.
tools/droiddeck-scenario tools/scenarios/steam-game-focus.json --var appId=489830 --out ./runs
Exit status: 0 when every run passed, 1 when any failed, 2 for a bad scenario or a failed setup.
"""
from __future__ import annotations
import argparse
import importlib.machinery
import importlib.util
import json
import os
import re
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
_loader = importlib.machinery.SourceFileLoader("droiddeckctl", str(HERE / "droiddeckctl"))
_spec = importlib.util.spec_from_loader("droiddeckctl", _loader)
ctl = importlib.util.module_from_spec(_spec)
_loader.exec_module(ctl)
class StepFailed(Exception):
pass
def write_json(path: Path, value) -> None:
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600), "w") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(value, stream, indent=2, sort_keys=True)
def substitute(value, variables: dict):
if isinstance(value, str):
whole = re.fullmatch(r"\$\{(\w+)\}", value)
if whole and whole.group(1) in variables:
return variables[whole.group(1)]
return re.sub(r"\$\{(\w+)\}", lambda m: str(variables.get(m.group(1), m.group(0))), value)
if isinstance(value, list):
return [substitute(v, variables) for v in value]
if isinstance(value, dict):
return {k: substitute(v, variables) for k, v in value.items()}
return value
def lookup(data, path: str):
for part in path.split("."):
if isinstance(data, list) and part.isdigit():
data = data[int(part)] if int(part) < len(data) else None
elif isinstance(data, dict):
data = data.get(part)
else:
return None
return data
def check(step: dict, last: dict) -> None:
path = step["assert"]
value = lookup(last, path)
if "equals" in step and value != step["equals"]:
raise StepFailed(f"{path} is {value!r}, expected {step['equals']!r}")
if "not" in step and value == step["not"]:
raise StepFailed(f"{path} is {value!r}")
if "in" in step and value not in step["in"]:
raise StepFailed(f"{path} is {value!r}, expected one of {step['in']!r}")
if "contains" in step and (not isinstance(value, (list, str)) or step["contains"] not in value):
raise StepFailed(f"{path} is {value!r}, expected it to contain {step['contains']!r}")
if step.get("exists") is True and value is None:
raise StepFailed(f"{path} is missing")
class Runner:
def __init__(self, adb: str, serial: str, out: Path, global_args: list[str]):
self.adb = adb
self.serial = serial
self.out = out
self.global_args = global_args
self.parser = ctl.build_parser()
def ctl(self, words: list) -> dict:
args = self.parser.parse_args(self.global_args + [str(w) for w in words])
try:
return ctl.execute(args, self.adb, self.serial)
except ctl.ControlError as exc:
raise StepFailed(f"droiddeckctl {' '.join(map(str, words))}: {exc.code}: {exc}") from exc
def run_steps(self, steps: list, folder: Path, log: list) -> None:
last: dict = {}
for index, step in enumerate(steps):
started = time.monotonic()
entry = {"step": index, "what": step}
try:
if isinstance(step, list):
last = self.ctl(step)
entry["result"] = last
elif "assert" in step:
check(step, last)
elif "sleep" in step:
time.sleep(float(step["sleep"]))
elif "screenshot" in step:
entry["result"] = ctl.screenshot(self.adb, self.serial, str(folder / f"{step['screenshot']}.png"),
step.get("display"))
elif "record" in step:
entry["result"] = ctl.record(self.adb, self.serial, str(folder / f"{step['record']}.mp4"),
float(step.get("seconds", 10)), step.get("display"))
else:
raise StepFailed(f"Unknown step {step!r}")
entry["ok"] = True
except (StepFailed, ctl.ControlError) as exc:
entry["ok"] = False
entry["error"] = str(exc)
raise StepFailed(f"step {index} {json.dumps(step)}: {exc}") from exc
finally:
entry["seconds"] = round(time.monotonic() - started, 2)
log.append(entry)
def evidence(self, folder: Path) -> dict:
"""What a failed run leaves behind: the screen, gamescope's focus, the session's logs."""
found = {}
for name, action in (
("screenshot", lambda: ctl.screenshot(self.adb, self.serial, str(folder / "failure.png"), None)),
("focus", lambda: self.ctl(["focus"])),
("logs", lambda: self.ctl(["logs", "latest", str(folder / "session")])),
):
try:
found[name] = action()
except Exception as exc: # evidence is best effort
found[name] = {"ok": False, "error": str(exc)}
write_json(folder / "evidence.json", found)
return {k: v.get("ok", True) if isinstance(v, dict) else True for k, v in found.items()}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("scenario")
parser.add_argument("--var", action="append", default=[], metavar="KEY=VALUE")
parser.add_argument("--repeat", type=int)
parser.add_argument("--out", default="scenario-runs")
parser.add_argument("--adb")
parser.add_argument("--serial")
parser.add_argument("--keep-going", action="store_true", help="run every repeat even after a failure")
args = parser.parse_args(argv)
try:
scenario = json.loads(Path(args.scenario).read_text())
except (OSError, ValueError) as exc:
print(json.dumps({"ok": False, "error": f"Cannot read scenario: {exc}"}))
return 2
variables = dict(scenario.get("vars", {}))
for item in args.var:
key, _, value = item.partition("=")
variables[key] = int(value) if value.lstrip("-").isdigit() else value
scenario = substitute(scenario, variables)
repeat = args.repeat or int(scenario.get("repeat", 1))
adb = ctl.adb_path(args.adb)
serial = ctl.resolve_serial(adb, args.serial, log=True)
out = Path(args.out).expanduser().resolve() / f"{scenario.get('name', 'scenario')}-{time.strftime('%Y%m%d-%H%M%S')}"
out.mkdir(parents=True, exist_ok=True, mode=0o700)
global_args = (["--adb", adb] if args.adb else []) + ["--serial", serial]
runner = Runner(adb, serial, out, global_args)
report = {"name": scenario.get("name"), "serial": serial, "vars": variables, "repeat": repeat,
"out": str(out), "runs": []}
status = 0
setup_log: list = []
try:
runner.run_steps(scenario.get("setup", []), out, setup_log)
for run in range(1, repeat + 1):
folder = out / f"run-{run:02d}"
folder.mkdir(mode=0o700)
log: list = []
started = time.monotonic()
result = {"run": run, "folder": str(folder)}
try:
runner.run_steps(scenario.get("before", []) + scenario.get("steps", []), folder, log)
result["ok"] = True
except StepFailed as exc:
result["ok"] = False
result["error"] = str(exc)
result["evidence"] = runner.evidence(folder)
status = 1
after: list = []
try:
runner.run_steps(scenario.get("after", []), folder, after)
except StepFailed as exc:
result["afterError"] = str(exc)
log.extend({**entry, "after": True} for entry in after)
result["seconds"] = round(time.monotonic() - started, 2)
write_json(folder / "steps.json", log)
report["runs"].append(result)
print(f"run {run}/{repeat}: {'PASS' if result['ok'] else 'FAIL ' + result['error']}", file=sys.stderr)
if not result["ok"] and not args.keep_going:
break
except StepFailed as exc:
report["setupError"] = str(exc)
status = 2
finally:
teardown_log: list = []
try:
runner.run_steps(scenario.get("teardown", []), out, teardown_log)
except StepFailed as exc:
report["teardownError"] = str(exc)
write_json(out / "setup.json", setup_log + teardown_log)
passed = sum(1 for r in report["runs"] if r["ok"])
report.update({"ok": status == 0, "passed": passed, "failed": len(report["runs"]) - passed})
write_json(out / "report.json", report)
print(json.dumps(report, indent=2, sort_keys=True))
return status
if __name__ == "__main__":
raise SystemExit(main())
+96 -470
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Control a DroidDeck build over ADB (docs/agent-control.md)."""
"""Control a debug DroidDeck build over ADB."""
from __future__ import annotations
@@ -7,21 +7,16 @@ import argparse
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from adb_device import DeviceResolutionError, resolve_serial # noqa: E402
from adb_device import DeviceResolutionError, resolve_serial
AUTHORITY = "content://com.droiddeck.launcher.agent"
PACKAGE = "com.droiddeck.launcher"
STEAM_CLIENT_APP = 769
REQUEST_CAP = 256 * 1024
class ControlError(RuntimeError):
@@ -51,7 +46,7 @@ def adb_path(configured: str | None) -> str:
raise ControlError("adb not found; set ADB, ANDROID_HOME, or ANDROID_SDK_ROOT", "ADB_NOT_FOUND")
def run_adb(adb: str, serial: str, *args: str, timeout: float = 30) -> subprocess.CompletedProcess[bytes]:
def run_adb(adb: str, serial: str, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[bytes]:
try:
return subprocess.run(
[adb, "-s", serial, *args], capture_output=True, check=False, timeout=timeout
@@ -64,7 +59,7 @@ def _content_result(output: str) -> dict:
marker = "json="
start = output.find(marker)
if start < 0:
detail = output.strip() or "The agent provider returned no result"
detail = output.strip() or "The debug agent provider returned no result"
raise ControlError(detail, "INVALID_PROVIDER_RESPONSE")
raw = output[start + len(marker):].lstrip()
try:
@@ -76,22 +71,14 @@ def _content_result(output: str) -> dict:
raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc
def encode_request(request: dict) -> str:
encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
if len(encoded) > REQUEST_CAP:
raise ControlError("The encoded request exceeds 256 KiB", "INVALID_REQUEST")
return encoded
def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None,
timeout: float = 30) -> dict:
def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None) -> dict:
command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method]
if arg is not None:
command.extend(["--arg", arg])
if request is not None:
encoded = encode_request(request)
encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
command.extend(["--extra", f"request:s:{encoded}"])
result = run_adb(adb, serial, *command, timeout=timeout)
result = run_adb(adb, serial, *command)
output = (result.stdout + result.stderr).decode(errors="replace")
response = _content_result(output)
if result.returncode != 0 and response.get("ok") is not False:
@@ -99,16 +86,8 @@ def provider_call(adb: str, serial: str, method: str, arg: str | None = None, re
return response
def checked(response: dict, default_code: str) -> dict:
"""A provider or guest answer, raised as a ControlError when it carries ok: false."""
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", default_code), error.get("code", default_code))
return response
def start_agent_activity(adb: str, serial: str, request: dict) -> None:
encoded = encode_request(request)
encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
result = run_adb(
adb,
serial,
@@ -117,7 +96,7 @@ def start_agent_activity(adb: str, serial: str, request: dict) -> None:
"start",
"-W",
"-n",
f"{PACKAGE}/.agent.AgentStartActivity",
"com.droiddeck.launcher/.agent.AgentStartActivity",
"--es",
"request",
encoded,
@@ -147,18 +126,23 @@ def confirm_start(adb: str, serial: str, request: dict, previous: dict) -> dict:
def get_state(adb: str, serial: str) -> dict:
return checked(provider_call(adb, serial, "state"), "STATE_FAILED")
response = provider_call(adb, serial, "state")
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", "state failed"), error.get("code", "STATE_FAILED"))
return response
def wait_until(adb: str, serial: str, describe: str, done, timeout: float, poll: float = 1.0) -> dict:
"""Poll state until done(state) is true. A failed session ends the wait at once."""
def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict:
deadline = time.monotonic() + timeout
last_state: dict | None = None
while True:
state = get_state(adb, serial)
session = state.get("session", {})
if done(state):
return state
if session.get("phase") == "FAILED":
last_state = get_state(adb, serial)
session = last_state.get("session", {})
phase = session.get("phase")
if phase == wanted:
return last_state
if phase == "FAILED" and wanted != "FAILED":
failure = session.get("failure") or {}
raise ControlError(
f"Session failed ({failure.get('code', 'FAILED')}): {failure.get('message', 'unknown error')}",
@@ -166,35 +150,10 @@ def wait_until(adb: str, serial: str, describe: str, done, timeout: float, poll:
)
remaining = deadline - time.monotonic()
if remaining <= 0:
focus = session.get("focus") or {}
raise ControlError(
f"Timed out waiting for {describe}; phase {session.get('phase')}, focused app {focus.get('focusedApp')}",
"TIMEOUT",
f"Timed out waiting for {wanted}; last phase was {phase}", "TIMEOUT"
)
time.sleep(min(poll, remaining))
def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict:
if wanted == "FAILED":
deadline = time.monotonic() + timeout
while True:
state = get_state(adb, serial)
if state.get("session", {}).get("phase") == "FAILED":
return state
if time.monotonic() > deadline:
raise ControlError("Timed out waiting for FAILED", "TIMEOUT")
time.sleep(1)
return wait_until(adb, serial, wanted, lambda s: s.get("session", {}).get("phase") == wanted, timeout)
def focused(state: dict, app_id: int | None) -> bool:
"""The game has focus the way a player sees it: gamescope focuses it and it is in the list the
Steam client orders the base layer from. app_id None means any game (not the client)."""
focus = state.get("session", {}).get("focus") or {}
current = focus.get("focusedApp")
if app_id is None:
return current not in (None, 0, STEAM_CLIENT_APP)
return current == app_id and app_id in (focus.get("focusableApps") or [])
time.sleep(min(1.0, remaining))
def wait_for_artifacts(adb: str, serial: str, state: dict, timeout: float) -> dict:
@@ -215,123 +174,14 @@ def add_serial(response: dict, serial: str) -> dict:
return response
# --- displays ------------------------------------------------------------------------------------
def displays(adb: str, serial: str) -> list[dict]:
"""Logical displays with the SurfaceFlinger (physical) id screencap and screenrecord want."""
result = run_adb(adb, serial, "shell", "dumpsys", "display", timeout=30)
text = result.stdout.decode(errors="replace")
devices = dict(re.findall(r'DisplayDeviceInfo\{"([^"]+)": uniqueId="local:(\d+)"', text))
out = []
for block in re.split(r"\n Display (?=\d+:)", text)[1:]:
logical = int(block.split(":", 1)[0])
device = re.search(r"mPrimaryDisplayDevice=(.+)", block)
size = re.search(r"real (\d+) x (\d+)", block)
name = device.group(1).strip() if device else None
out.append({
"display": logical,
"name": name,
"physicalId": devices.get(name) if name else None,
"size": [int(size.group(1)), int(size.group(2))] if size else None,
})
return out
def physical_display(adb: str, serial: str, logical: int | None) -> str | None:
if logical is None:
return None
for d in displays(adb, serial):
if d["display"] == logical:
if not d["physicalId"]:
raise ControlError(f"Display {logical} has no physical id to capture", "NO_SUCH_DISPLAY")
return d["physicalId"]
raise ControlError(f"No display {logical}; see `droiddeckctl displays`", "NO_SUCH_DISPLAY")
def screenshot(adb: str, serial: str, path: str, display: int | None) -> dict:
destination = Path(path).expanduser().resolve()
physical = physical_display(adb, serial, display)
command = ["exec-out", "screencap", "-p"] + (["-d", physical] if physical else [])
result = run_adb(adb, serial, *command, timeout=30)
if result.returncode != 0 or not result.stdout.startswith(b"\x89PNG"):
detail = result.stderr.decode(errors="replace").strip()
raise ControlError(detail or "screencap failed", "SCREENSHOT_FAILED")
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(result.stdout)
except OSError as exc:
raise ControlError(str(exc), "FILE_IO") from exc
return {"ok": True, "command": "screenshot", "path": str(destination), "display": display}
def record(adb: str, serial: str, path: str, seconds: float, display: int | None) -> dict:
destination = Path(path).expanduser().resolve()
remote = "/data/local/tmp/droiddeckctl-record.mp4"
physical = physical_display(adb, serial, display)
command = ["shell", "screenrecord", "--time-limit", str(max(1, min(180, int(seconds))))]
if physical:
command += ["--display-id", physical]
result = run_adb(adb, serial, *command, remote, timeout=seconds + 30)
if result.returncode != 0:
raise ControlError(result.stderr.decode(errors="replace").strip() or "screenrecord failed", "RECORD_FAILED")
try:
destination.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ControlError(str(exc), "FILE_IO") from exc
pulled = run_adb(adb, serial, "pull", remote, str(destination), timeout=120)
run_adb(adb, serial, "shell", "rm", "-f", remote)
if pulled.returncode != 0:
raise ControlError(pulled.stderr.decode(errors="replace").strip() or "adb pull failed", "RECORD_FAILED")
return {"ok": True, "command": "record", "path": str(destination), "seconds": seconds, "display": display}
# --- commands ------------------------------------------------------------------------------------
def start_session(adb: str, serial: str, request: dict) -> dict:
if request.get("mode") == "run":
require_commands(adb, serial)
current = get_state(adb, serial)
if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
start_agent_activity(adb, serial, request)
return confirm_start(adb, serial, request, current["session"])
def require_commands(adb: str, serial: str) -> dict:
access = checked(provider_call(adb, serial, "access"), "ACCESS_FAILED")
if access.get("commands") is not True:
raise ControlError("Debugging tools are off: turn on Setup > Session > Debugging tools on the device",
"AGENT_COMMANDS_DISABLED")
return access
def input_request(args: argparse.Namespace) -> dict:
kind = args.input_type
if kind == "key":
return {"type": "key", "key": args.key, "action": args.action, "modifiers": args.mod or [], "holdMs": args.hold}
if kind == "text":
return {"type": "text", "text": args.text, "commit": args.commit}
if kind == "tap":
return {"type": "tap", "x": args.x, "y": args.y, "space": args.space, "holdMs": args.hold}
if kind == "swipe":
return {"type": "swipe", "x": args.x, "y": args.y, "x2": args.x2, "y2": args.y2,
"space": args.space, "durationMs": args.duration}
if kind == "pad":
return {"type": "pad", "button": args.button, "action": args.action, "holdMs": args.hold}
if kind == "stick":
return {"type": "stick", "stick": args.stick, "x": args.x, "y": args.y, "holdMs": args.hold}
if kind == "trigger":
return {"type": "trigger", "trigger": args.trigger, "value": args.value, "holdMs": args.hold}
raise ControlError(f"Unknown input type {kind}", "INVALID_INPUT")
def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
command = args.command
if command == "state":
if args.command == "state":
return add_serial(get_state(adb, serial), serial)
if command == "start":
if args.command == "start":
current = get_state(adb, serial)
if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
request = {"mode": args.mode}
if args.mode == "steam":
if args.ui == "desktop":
@@ -340,152 +190,59 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
if not args.url.startswith("steam://"):
raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL")
request["steamUrl"] = args.url
if args.reuse:
# A scenario's per-run "before": keep a session of this mode that is already up, so only
# a run after one that ended (a crash, the low-memory killer) starts a new one.
current = get_state(adb, serial)["session"]
wanted = {"steam": "steam", "desktop": "lxqt"}[args.mode]
if current.get("running") and current.get("mode") == wanted and current.get("phase") not in {"STOPPING", "FAILED"}:
state = wait_for_phase(adb, serial, "READY", args.timeout) if args.wait else get_state(adb, serial)
return add_serial(dict(state, reused=True), serial)
started = start_session(adb, serial, request)
start_agent_activity(adb, serial, request)
started = confirm_start(adb, serial, request, current["session"])
if args.wait:
return add_serial(wait_for_phase(adb, serial, "READY", args.timeout), serial)
session = started["session"]
return add_serial({"ok": True, "command": "start", "mode": args.mode,
"sessionId": session.get("id"), "phase": session.get("phase")}, serial)
return add_serial({
"ok": True,
"command": "start",
"mode": args.mode,
"sessionId": session.get("id"),
"phase": session.get("phase"),
}, serial)
if command == "run":
if args.command == "run":
current = get_state(adb, serial)
if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
program_args = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
started = start_session(adb, serial, {"mode": "run", "program": args.program, "programArgs": program_args})
request = {"mode": "run", "program": args.program, "programArgs": program_args}
start_agent_activity(adb, serial, request)
started = confirm_start(adb, serial, request, current["session"])
session = started["session"]
return add_serial({"ok": True, "command": "start", "mode": "run",
"sessionId": session.get("id"), "phase": session.get("phase")}, serial)
return add_serial({
"ok": True,
"command": "start",
"mode": "run",
"sessionId": session.get("id"),
"phase": session.get("phase"),
}, serial)
if command == "wait":
if args.phase == "focused":
if args.app_id is None:
raise ControlError("wait focused needs an app id", "INVALID_ARGUMENTS")
return add_serial(wait_until(adb, serial, f"app {args.app_id} to have focus",
lambda s: focused(s, args.app_id), args.timeout, 0.5), serial)
if args.phase == "game":
return add_serial(wait_until(adb, serial, "a game to have focus",
lambda s: focused(s, None), args.timeout, 0.5), serial)
if args.command == "wait":
phases = {"ready": "READY", "idle": "IDLE", "failed": "FAILED"}
return add_serial(wait_for_phase(adb, serial, phases[args.phase], args.timeout), serial)
if command == "stop":
if args.command == "stop":
current = get_state(adb, serial)
if current["session"].get("phase") in {"IDLE", "FAILED"}:
return add_serial(wait_for_artifacts(adb, serial, current, args.artifact_timeout), serial)
checked(provider_call(adb, serial, "stop"), "STOP_FAILED")
return add_serial(wait_for_artifacts(adb, serial, current, args.timeout), serial)
response = provider_call(adb, serial, "stop")
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", "stop failed"), error.get("code", "STOP_FAILED"))
state = wait_for_phase(adb, serial, "IDLE", args.timeout)
return add_serial(wait_for_artifacts(adb, serial, state, args.artifact_timeout), serial)
return add_serial(wait_for_artifacts(adb, serial, state, args.timeout), serial)
if command == "resume":
return add_serial(checked(provider_call(adb, serial, "resume"), "RESUME_FAILED"), serial)
if command == "launch":
response = checked(provider_call(adb, serial, "launch", request={"appId": str(args.app_id)}), "LAUNCH_FAILED")
if args.wait:
state = wait_until(adb, serial, f"app {args.app_id} to have focus",
lambda s: focused(s, args.app_id), args.timeout, 0.5)
response["focus"] = state["session"].get("focus")
if args.command == "resume":
response = provider_call(adb, serial, "resume")
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", "resume failed"), error.get("code", "RESUME_FAILED"))
return add_serial(response, serial)
if command == "quit":
response = provider_call(adb, serial, "quit", request={"appId": args.app_id, "timeout": args.timeout},
timeout=args.timeout + 40)
return add_serial(checked(response, "QUIT_FAILED"), serial)
if command == "focus":
return add_serial(checked(provider_call(adb, serial, "focus", timeout=40), "FOCUS_FAILED"), serial)
if command == "guest":
argv = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
if not argv:
raise ControlError("guest needs a command after --", "INVALID_ARGUMENTS")
env = {}
for item in args.env or []:
key, sep, value = item.partition("=")
if not sep:
raise ControlError(f"--env takes KEY=VALUE, not {item}", "INVALID_ARGUMENTS")
env[key] = value
request = {"argv": argv, "timeout": args.timeout, "env": env}
if args.cwd:
request["cwd"] = args.cwd
if args.stdin is not None:
request["stdin"] = args.stdin
response = checked(provider_call(adb, serial, "guest", request=request, timeout=args.timeout + 40), "GUEST_FAILED")
if args.check and response.get("exitCode") != 0:
raise ControlError(f"{argv[0]} exited with {response.get('exitCode')}: "
f"{(response.get('stderr') or {}).get('text', '')[-400:]}", "GUEST_EXIT")
return add_serial(response, serial)
if command == "cdp":
if args.cdp_op == "targets":
request = {"op": "targets"}
else:
request = {"op": "eval", "expression": args.expression, "awaitPromise": not args.no_await,
"timeout": args.timeout}
if args.target:
request["target"] = args.target
timeout = getattr(args, "timeout", 15)
return add_serial(checked(provider_call(adb, serial, "cdp", request=request, timeout=timeout + 40),
"CDP_FAILED"), serial)
if command == "input":
return add_serial(checked(provider_call(adb, serial, "input", request=input_request(args), timeout=60),
"INPUT_FAILED"), serial)
if command == "ui":
if args.ui_op == "dump":
request = {"op": "dump", "all": args.all}
else:
request = {"op": args.ui_op, "index": args.index}
if args.match:
request["match"] = args.match
else:
request["tag"] = args.tag
if args.ui_op == "set-text":
request["text"] = args.text
return add_serial(checked(provider_call(adb, serial, "ui", request=request), "UI_FAILED"), serial)
if command == "env":
if args.env_op == "set":
request = {"op": "set", "lines": args.lines, "persistent": args.persistent}
elif args.env_op == "clear":
request = {"op": "clear", "scope": args.scope}
else:
request = {"op": "list"}
return add_serial(checked(provider_call(adb, serial, "env", request=request), "ENV_FAILED"), serial)
if command == "override":
access = require_commands(adb, serial)
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", args.name) or args.name in {".", ".."}:
raise ControlError("Override names must be simple filenames", "INVALID_NAME")
source = Path(args.path).expanduser()
if not source.is_file():
raise ControlError(f"No such file: {source}", "FILE_IO")
inbox = access["inbox"]
run_adb(adb, serial, "shell", "mkdir", "-p", inbox)
pushed = run_adb(adb, serial, "push", str(source), f"{inbox}/{args.name}", timeout=300)
if pushed.returncode != 0:
raise ControlError(pushed.stderr.decode(errors="replace").strip() or "adb push failed", "PUSH_FAILED")
return add_serial(checked(provider_call(adb, serial, "override", request={"name": args.name}),
"OVERRIDE_FAILED"), serial)
if command == "prefs":
if args.prefs_op == "set":
request = {"op": "set", "file": args.file, "key": args.key, "type": args.type, "value": args.value}
else:
request = {"op": "get", "file": args.file}
return add_serial(checked(provider_call(adb, serial, "prefs", request=request), "PREFS_FAILED"), serial)
if command == "access":
return add_serial(checked(provider_call(adb, serial, "access"), "ACCESS_FAILED"), serial)
if command == "logs":
if args.command == "logs":
state = get_state(adb, serial)
session = state.get("session", {})
if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"):
@@ -501,24 +258,20 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial)
if command == "screenshot":
return add_serial(screenshot(adb, serial, args.path, args.display), serial)
if args.command == "screenshot":
destination = Path(args.path).expanduser().resolve()
result = run_adb(adb, serial, "exec-out", "screencap", "-p", timeout=30)
if result.returncode != 0:
detail = result.stderr.decode(errors="replace").strip()
raise ControlError(detail or "screencap failed", "SCREENSHOT_FAILED")
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(result.stdout)
except OSError as exc:
raise ControlError(str(exc), "FILE_IO") from exc
return add_serial({"ok": True, "command": "screenshot", "path": str(destination)}, serial)
if command == "record":
return add_serial(record(adb, serial, args.path, args.seconds, args.display), serial)
if command == "wake":
# A sleeping device stops the session and hides the app's windows from ui dump.
run_adb(adb, serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP")
run_adb(adb, serial, "shell", "wm", "dismiss-keyguard")
power = run_adb(adb, serial, "shell", "dumpsys", "power").stdout.decode(errors="replace")
awake = re.search(r"mWakefulness=(\w+)", power)
return add_serial({"ok": True, "command": "wake", "wakefulness": awake.group(1) if awake else None}, serial)
if command == "displays":
return add_serial({"ok": True, "command": "displays", "displays": displays(adb, serial)}, serial)
raise ControlError(f"Unknown command: {command}", "UNKNOWN_COMMAND")
raise ControlError(f"Unknown command: {args.command}", "UNKNOWN_COMMAND")
def build_parser() -> argparse.ArgumentParser:
@@ -535,172 +288,49 @@ def build_parser() -> argparse.ArgumentParser:
start.add_argument("--ui", choices=["bigpicture", "desktop"], default="bigpicture")
start.add_argument("--url", help="steam:// URL to pass to the client")
start.add_argument("--wait", action="store_true", help="wait for READY after starting")
start.add_argument("--reuse", action="store_true", help="keep a running session of this mode instead of failing")
start.add_argument("--timeout", type=float, default=90)
run = commands.add_parser("run", help="run a guest program under Gamescope")
run.add_argument("program")
run.add_argument("arguments", nargs=argparse.REMAINDER)
wait = commands.add_parser("wait", help="wait for a session phase, or for a game to have focus")
wait.add_argument("phase", choices=["ready", "idle", "failed", "focused", "game"])
wait.add_argument("app_id", nargs="?", type=int, help="with focused: the Steam app id")
wait = commands.add_parser("wait", help="wait for a session phase")
wait.add_argument("phase", choices=["ready", "idle", "failed"])
wait.add_argument("--timeout", type=float, default=90)
stop = commands.add_parser("stop", help="stop the session and wait for IDLE")
stop.add_argument("--timeout", type=float, default=90)
# Collecting the client's logs and redacting them took about two minutes after a Skyrim session.
stop.add_argument("--artifact-timeout", type=float, default=300)
commands.add_parser("resume", help="resume a suspended session")
launch = commands.add_parser("launch", help="launch a Steam game in the running Steam session")
launch.add_argument("app_id", type=int)
launch.add_argument("--wait", action="store_true", help="wait until the game has focus")
launch.add_argument("--timeout", type=float, default=180)
quit_ = commands.add_parser("quit", help="quit a running game (through the client, then signals)")
quit_.add_argument("app_id", type=int)
quit_.add_argument("--timeout", type=float, default=15)
commands.add_parser("focus", help="gamescope's focus and every Xwayland server's windows, fresh")
guest = commands.add_parser("guest", help="run a command inside the session (debugging tools)")
guest.add_argument("--timeout", type=float, default=30)
guest.add_argument("--env", action="append", metavar="KEY=VALUE")
guest.add_argument("--cwd")
guest.add_argument("--stdin")
guest.add_argument("--check", action="store_true", help="fail unless the command exits 0")
guest.add_argument("argv", nargs=argparse.REMAINDER)
cdp = commands.add_parser("cdp", help="the Steam client's DevTools (debugging tools)")
cdp_ops = cdp.add_subparsers(dest="cdp_op", required=True)
cdp_ops.add_parser("targets", help="list DevTools targets")
evaluate = cdp_ops.add_parser("eval", help="evaluate JavaScript (default target SharedJSContext)")
evaluate.add_argument("expression")
evaluate.add_argument("--target", help="title or URL substring of the target")
evaluate.add_argument("--no-await", action="store_true", help="do not await a returned promise")
evaluate.add_argument("--timeout", type=float, default=15)
inp = commands.add_parser("input", help="keys, text, touch and pad input into the session")
kinds = inp.add_subparsers(dest="input_type", required=True)
key = kinds.add_parser("key", help="a key by name (ENTER, ESC, A, F1...) or evdev code")
key.add_argument("key")
key.add_argument("--action", choices=["tap", "down", "up"], default="tap")
key.add_argument("--mod", action="append", help="modifier held around a tap (CTRL, SHIFT, ALT, META)")
key.add_argument("--hold", type=int, default=40, help="ms between down and up")
text = kinds.add_parser("text", help="type text (US layout keys; --commit for text-input)")
text.add_argument("text")
text.add_argument("--commit", action="store_true")
tap = kinds.add_parser("tap", help="touch at a point of the session's output")
tap.add_argument("x", type=float)
tap.add_argument("y", type=float)
tap.add_argument("--space", choices=["output", "fraction"], default="output")
tap.add_argument("--hold", type=int, default=60)
swipe = kinds.add_parser("swipe", help="drag a touch from one point to another")
for name in ("x", "y", "x2", "y2"):
swipe.add_argument(name, type=float)
swipe.add_argument("--space", choices=["output", "fraction"], default="output")
swipe.add_argument("--duration", type=int, default=300)
pad = kinds.add_parser("pad", help="a virtual pad button (a, b, x, y, lb, rb, start, select, guide, qam, up...)")
pad.add_argument("button")
pad.add_argument("--action", choices=["tap", "down", "up"], default="tap")
pad.add_argument("--hold", type=int, default=120)
stick = kinds.add_parser("stick", help="hold a stick at x, y (-1..1)")
stick.add_argument("stick", choices=["left", "right"])
stick.add_argument("x", type=float)
stick.add_argument("y", type=float)
stick.add_argument("--hold", type=int, default=300)
trigger = kinds.add_parser("trigger", help="hold a trigger (0..1)")
trigger.add_argument("trigger", choices=["left", "right"])
trigger.add_argument("value", type=float, nargs="?", default=1.0)
trigger.add_argument("--hold", type=int, default=200)
ui = commands.add_parser("ui", help="the app's own UI: dump nodes, click or type by test tag")
ui_ops = ui.add_subparsers(dest="ui_op", required=True)
dump = ui_ops.add_parser("dump", help="tagged, labelled and clickable nodes of every app window")
dump.add_argument("--all", action="store_true", help="every node")
for name in ("click", "set-text"):
op = ui_ops.add_parser(name)
op.add_argument("tag", nargs="?", help="test tag, e.g. rail-steam")
op.add_argument("--match", help="instead of a tag: text or description containing this")
op.add_argument("--index", type=int, default=0, help="which match, in tree order")
if name == "set-text":
op.add_argument("--text", required=True)
env = commands.add_parser("env", help="session environment lines (debugging tools)")
env_ops = env.add_subparsers(dest="env_op", required=True)
env_ops.add_parser("list")
env_set = env_ops.add_parser("set", help="KEY=VALUE lines for the next session")
env_set.add_argument("lines", nargs="+")
env_set.add_argument("--persistent", action="store_true", help="every session until cleared")
env_clear = env_ops.add_parser("clear")
env_clear.add_argument("scope", nargs="?", choices=["next", "persistent", "all"], default="next")
override = commands.add_parser("override", help="put a binary first on the next session's PATH (debugging tools)")
override.add_argument("name", help="the program name, e.g. gamescope")
override.add_argument("path", help="the local file to push")
prefs = commands.add_parser("prefs", help="the app's preferences (debugging tools)")
prefs_ops = prefs.add_subparsers(dest="prefs_op", required=True)
prefs_get = prefs_ops.add_parser("get")
prefs_get.add_argument("file", help="preferences file, e.g. session")
prefs_set = prefs_ops.add_parser("set")
prefs_set.add_argument("file")
prefs_set.add_argument("key")
prefs_set.add_argument("value", nargs="?")
prefs_set.add_argument("--type", choices=["bool", "int", "long", "float", "string", "remove"], default="string")
commands.add_parser("access", help="whether debugging tools are enabled on the device")
logs = commands.add_parser("logs", help="pull the latest session artifact directory")
logs.add_argument("which", choices=["latest"])
logs.add_argument("destination", nargs="?")
logs.add_argument("--timeout", type=float, default=90)
shot = commands.add_parser("screenshot", help="save a device screenshot")
shot.add_argument("path")
shot.add_argument("--display", type=int, help="logical display id (see displays); default the default display")
rec = commands.add_parser("record", help="record the screen to an mp4")
rec.add_argument("path")
rec.add_argument("--seconds", type=float, default=10)
rec.add_argument("--display", type=int)
commands.add_parser("displays", help="the device's displays and their capture ids")
commands.add_parser("wake", help="wake the device and dismiss the keyguard")
screenshot = commands.add_parser("screenshot", help="save a device screenshot")
screenshot.add_argument("path")
return parser
EXIT_ADB = {"ADB_NOT_FOUND", "ADB_FAILED", "INVALID_PROVIDER_RESPONSE"}
EXIT_SESSION = {
"SESSION_ACTIVE", "NO_ACTIVE_SESSION", "SESSION_NOT_SUSPENDED", "SESSION_FAILED", "GUEST_EXIT",
"GUEST_START_FAILED", "RUNTIME_INSTALL_FAILED", "DESKTOP_INSTALL_FAILED", "ACTIVITY_START_FAILED",
"NO_STEAM_SESSION", "SESSION_NOT_READY", "AGENT_UNAVAILABLE", "LAUNCH_REJECTED", "NO_PAD",
}
EXIT_TIMEOUT = {"TIMEOUT", "ARTIFACT_TIMEOUT"}
EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED"}
EXIT_DENIED = {"AGENT_COMMANDS_DISABLED", "PROTECTED_PREFS", "PROTECTED_CONTROL"}
def exit_code(exc: Exception) -> int:
if isinstance(exc, DeviceResolutionError):
return 3
code = getattr(exc, "code", "")
if code in EXIT_ADB:
if code in {"ADB_NOT_FOUND", "ADB_FAILED", "INVALID_PROVIDER_RESPONSE"}:
return 3
if code in EXIT_SESSION:
if code in {"SESSION_ACTIVE", "NO_ACTIVE_SESSION", "SESSION_NOT_SUSPENDED", "SESSION_FAILED",
"GUEST_EXIT", "GUEST_START_FAILED", "RUNTIME_INSTALL_FAILED", "DESKTOP_INSTALL_FAILED",
"ACTIVITY_START_FAILED"}:
return 4
if code in EXIT_TIMEOUT:
if code in {"TIMEOUT", "ARTIFACT_TIMEOUT"}:
return 5
if code in EXIT_FILE:
if code in {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO"}:
return 6
if code in EXIT_DENIED:
return 7
return 2
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
def main() -> int:
args = build_parser().parse_args()
serial = None
try:
adb = adb_path(args.adb)
@@ -710,15 +340,11 @@ def main(argv: list[str] | None = None) -> int:
return 0
except (ControlError, DeviceResolutionError) as exc:
error = {"code": getattr(exc, "code", "DEVICE_RESOLUTION_FAILED"), "message": str(exc)}
code = exit_code(exc)
except Exception as exc: # the contract is JSON on stdout, even for a bug in this tool
error = {"code": "INTERNAL_ERROR", "message": f"{type(exc).__name__}: {exc}"}
code = 2
response = {"ok": False, "error": error}
if serial:
response["deviceSerial"] = serial
print(json.dumps(response, indent=2, sort_keys=True))
return code
response = {"ok": False, "error": error}
if serial:
response["deviceSerial"] = serial
print(json.dumps(response, indent=2, sort_keys=True))
return exit_code(exc)
if __name__ == "__main__":
+34 -3
View File
@@ -16,6 +16,11 @@ export QT_QPA_PLATFORMTHEME=lxqt
# The prefs for this are only advisory and a profile can override them; the environment is not.
export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1
export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1
# Firefox seals the size of the shared memory it hands a tab; on Android 16 (Galaxy S25/S26,
# RedMagic) adding the seal fails, Firefox only warns, and the tab refuses the unsealed memory
# ("Shared memory PlatformHandle is not safe to map") and dies on signal 11 - every page. Firefox's
# own switch skips the seals.
export MOZ_SHM_NO_SEALS=1
# pixman composites in software and gives programs shm buffers only, so a Vulkan program on it
# fails at its swapchain. vulkan (or gles2) composite on the GPU and give programs DMA-BUFs, but
# stock wlroots allocates through gbm on a real DRM node and the KGSL stand-in is not one ("unable
@@ -84,9 +89,35 @@ fi
# already running and exit without a word - a desktop with a wallpaper and nothing on it.
# Only the app's own compositor socket (wayland-0) is kept.
find "$XDG_RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name 'wayland-0*' ! -name 'keymap.xkb' -exec rm -rf {} + 2>/dev/null
# Fontconfig defaults and Android language fallbacks are set up by droiddeck-session before this
# script or any desktop program starts. The generated defaults live in conf.d, leaving local.conf
# available for the user's own final overrides.
# fontconfig's rules (sans-serif -> DejaVu Sans and the rest) live as links in /etc/fonts/conf.d,
# and the rootfs has none: applications then find fonts but no default font. Firefox logs
# "no fonts" and draws pages without text.
if [ -d /usr/share/fontconfig/conf.default ] && ! ls /etc/fonts/conf.d/*.conf 2>/dev/null | grep -qv droiddeck; then
for f in /usr/share/fontconfig/conf.default/*.conf; do ln -sf "$f" /etc/fonts/conf.d/; done
echo "== fontconfig: linked $(ls /etc/fonts/conf.d/*.conf | wc -l) default rules"
fi
# Those rules map sans-serif and friends onto whatever a full distribution installs, and this
# rootfs has one family. Naming it outright is what gives every toolkit a default font: without
# one Gecko reports "no fonts" with families found, and draws pages with no text at all.
if [ ! -f /etc/fonts/local.conf ]; then
{ echo '<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"><fontconfig>'
for generic in sans-serif sans serif monospace mono cursive fantasy system-ui ui-sans-serif; do
case $generic in
serif) face="DejaVu Serif" ;;
mono*|ui-mono) face="DejaVu Sans Mono" ;;
*) face="DejaVu Sans" ;;
esac
printf '<alias binding="weak"><family>%s</family><prefer><family>%s</family></prefer></alias>\n' "$generic" "$face"
done
echo '</fontconfig>'
} > /etc/fonts/local.conf
echo "== fontconfig: wrote local.conf (DejaVu as the default family)"
fi
# The cache in the image was built before those rules existed; rebuild it once, and only once.
if [ ! -f /var/cache/fontconfig/.droiddeck-built ] && command -v fc-cache >/dev/null 2>&1; then
fc-cache -f >/dev/null 2>&1 && touch /var/cache/fontconfig/.droiddeck-built
echo "== fontconfig: cache rebuilt, default sans is $(fc-match sans-serif 2>/dev/null)"
fi
# The menu's "Steam" / "Steam Big Picture" entries and one entry per installed game: all of them
# hand off to the app (droiddeck-steam-launch), since the client cannot draw on this desktop.
export BL_DESKTOP=1
@@ -1,687 +0,0 @@
#!/usr/bin/python3
"""The guest side of the agent bridge (docs/agent-control.md).
Started by droiddeck-session inside the session, so whatever it runs has the session's own
environment: DISPLAY, the games' Xwayland, XDG_RUNTIME_DIR, and the preloads every guest process
gets - which is what lets it reach the Steam client's DevTools port, open to guest processes only.
The app talks to it through files under $BL_LAUNCH_DIR/agent (the session root, the same path on
both sides):
req/<id>.json a request the app wrote (tmp + rename)
resp/<id>.json the answer, written the same way
hello.json this daemon is up: pid, version, what it can do
focus.json gamescope's focus and every server's top-level windows, rewritten on change
cdp-port the client's DevTools port, written by droiddeck-session
Requests: ping, exec, focus, cdp, quit. Each answer is {"ok": true, ...} or
{"ok": false, "error": {"code", "message"}}.
"""
import base64
import json
import math
import os
import selectors
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
import urllib.request
import urllib.parse
from pathlib import Path
VERSION = 1
OUTPUT_CAP = 256 * 1024
REQUEST_CAP = 256 * 1024
RESPONSE_CAP = 1024 * 1024
FOCUS_PROPS = (
"GAMESCOPE_FOCUSED_APP", "GAMESCOPE_FOCUSED_WINDOW", "GAMESCOPE_FOCUSABLE_APPS",
"GAMESCOPE_FOCUSABLE_WINDOWS", "GAMESCOPECTRL_BASELAYER_APPID",
)
WINDOW_PROPS = (
"STEAM_GAME", "WM_STATE", "_NET_WM_PID", "WM_CLASS", "_NET_WM_WINDOW_OPACITY", "STEAM_OVERLAY",
"GAMESCOPE_EXTERNAL_OVERLAY", "STEAM_INPUT_FOCUS", "_NET_WM_STATE",
)
MAP_STATE = ("unmapped", "unviewable", "viewable")
WM_STATES = {0: "withdrawn", 1: "normal", 3: "iconic"}
def log(message):
print("== agent: " + message, flush=True)
def write_json(path, value):
tmp = path.with_name(path.name + ".tmp")
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600), "w") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(value, stream, separators=(",", ":"))
os.replace(tmp, path)
class AgentError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
# --- X11: just enough of the protocol for root properties, the window tree and events ---------
class X:
"""One connection to an Xwayland server. The rootfs has xprop but no python X library."""
def __init__(self, display):
num = display.lstrip(":").split(".")[0]
last = None
for address in ("\0/tmp/.X11-unix/X" + num, "/tmp/.X11-unix/X" + num):
try:
self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.s.settimeout(3)
self.s.connect(address)
break
except OSError as e:
last = e
self.s.close()
else:
raise last
self.s.sendall(struct.pack("<BxHHHHxx", 0x6C, 11, 0, 0, 0))
head = self._read(8)
if head[0] != 1:
raise OSError("X connection refused on " + display)
setup = self._read(struct.unpack_from("<H", head, 6)[0] * 4)
vendor = struct.unpack_from("<H", setup, 16)[0]
formats = setup[21]
self.root = struct.unpack_from("<I", setup, 32 + (vendor + 3) // 4 * 4 + formats * 8)[0]
self.events = []
self._atoms = {}
self._names = {}
def close(self):
try:
self.s.close()
except OSError:
pass
def _read(self, n):
buf = b""
while len(buf) < n:
chunk = self.s.recv(n - len(buf))
if not chunk:
raise OSError("X connection closed")
buf += chunk
return buf
def request(self, data):
"""Send one request and return its reply, or None for an X error (a window that went)."""
self.s.sendall(data)
while True:
head = self._read(32)
if head[0] == 0:
return None
if head[0] == 1:
return head + self._read(struct.unpack_from("<I", head, 4)[0] * 4)
self.events.append(head)
def next_event(self):
if self.events:
return self.events.pop(0)
while True:
head = self._read(32)
if head[0] > 1:
return head
def atom(self, name):
if name not in self._atoms:
n = name.encode()
pad = (4 - len(n) % 4) % 4
r = self.request(struct.pack("<BBHHxx", 16, 0, 2 + (len(n) + pad) // 4, len(n)) + n + b"\0" * pad)
self._atoms[name] = struct.unpack_from("<I", r, 8)[0] if r else 0
return self._atoms[name]
def atom_name(self, atom):
if atom not in self._names:
r = self.request(struct.pack("<BxHI", 17, 2, atom))
self._names[atom] = r[32:32 + struct.unpack_from("<H", r, 8)[0]].decode(errors="replace") if r else str(atom)
return self._names[atom]
def select(self, window, mask):
self.s.sendall(struct.pack("<BxHIII", 2, 4, window, 0x800, mask))
def children(self, window):
r = self.request(struct.pack("<BxHI", 15, 2, window))
if not r:
return []
n = struct.unpack_from("<H", r, 16)[0]
return list(struct.unpack_from("<%dI" % n, r, 32))
def attributes(self, window):
r = self.request(struct.pack("<BxHI", 3, 2, window))
return (r[26], r[27], struct.unpack_from("<H", r, 12)[0]) if r else None
def geometry(self, window):
r = self.request(struct.pack("<BxHI", 14, 2, window))
return struct.unpack_from("<hhHH", r, 12) if r else None
def prop(self, window, name, length=1024):
atom = self.atom(name)
if not atom:
return None
r = self.request(struct.pack("<BBHIIIII", 20, 0, 6, window, atom, 0, 0, length))
if not r or r[1] == 0:
return None
fmt, n = r[1], struct.unpack_from("<I", r, 16)[0]
data = r[32:32 + n * fmt // 8]
if fmt == 32:
return list(struct.unpack("<%dI" % n, data))
return data
def windows(x):
"""Top-level windows that are mapped or carry an app id: what gamescope picks focus from."""
out = []
for win in x.children(x.root):
attrs = x.attributes(win)
if not attrs:
continue
props = {name: x.prop(win, name) for name in WINDOW_PROPS}
if attrs[0] != 2 and not props["STEAM_GAME"]:
continue
geo = x.geometry(win) or (0, 0, 0, 0)
cls = props["WM_CLASS"]
state = props["WM_STATE"]
entry = {
"window": win,
"map": MAP_STATE[attrs[0]] if attrs[0] < 3 else attrs[0],
"overrideRedirect": bool(attrs[1]),
"inputOnly": attrs[2] == 2,
"geometry": [geo[0], geo[1], geo[2], geo[3]],
"appId": props["STEAM_GAME"][0] if props["STEAM_GAME"] else None,
"wmState": WM_STATES.get(state[0], state[0]) if state else None,
"pid": props["_NET_WM_PID"][0] if props["_NET_WM_PID"] else None,
"class": cls.split(b"\0")[-2].decode(errors="replace") if cls and b"\0" in cls else None,
}
if props["_NET_WM_WINDOW_OPACITY"]:
entry["opacity"] = props["_NET_WM_WINDOW_OPACITY"][0]
for key, name in (("overlay", "STEAM_OVERLAY"), ("externalOverlay", "GAMESCOPE_EXTERNAL_OVERLAY"),
("inputFocus", "STEAM_INPUT_FOCUS")):
if props[name]:
entry[key] = props[name][0]
if props["_NET_WM_STATE"]:
entry["netState"] = [x.atom_name(a).replace("_NET_WM_STATE_", "") for a in props["_NET_WM_STATE"]]
out.append(entry)
return out
def displays():
seen = []
for d in (os.environ.get("DISPLAY"), os.environ.get("STEAM_GAME_DISPLAY_0")):
if d and d not in seen:
seen.append(d)
return seen
def focus_snapshot():
"""gamescope's focus as Steam sees it (root properties on the client's server) and every
server's windows. GAMESCOPE_FOCUSABLE_WINDOWS is the list the client orders the base layer
from: a game missing there stays behind the client however healthy its window looks."""
shown = displays()
if not shown:
raise AgentError("NO_DISPLAY", "This session has no X display")
snap = {"t": int(time.time() * 1000), "displays": {}}
for i, d in enumerate(shown):
x = X(d)
try:
if i == 0:
values = {name: x.prop(x.root, name) for name in FOCUS_PROPS}
trip = values["GAMESCOPE_FOCUSABLE_WINDOWS"] or []
snap["focusedApp"] = (values["GAMESCOPE_FOCUSED_APP"] or [None])[0]
snap["focusedWindow"] = (values["GAMESCOPE_FOCUSED_WINDOW"] or [None])[0]
snap["focusableApps"] = values["GAMESCOPE_FOCUSABLE_APPS"] or []
snap["focusableWindows"] = [
{"window": trip[k], "appId": trip[k + 1], "pid": trip[k + 2]} for k in range(0, len(trip) - 2, 3)
]
snap["baselayerAppIds"] = values["GAMESCOPECTRL_BASELAYER_APPID"] or []
snap["displays"][d] = windows(x)
finally:
x.close()
return snap
class FocusWatcher(threading.Thread):
"""Rewrites focus.json when gamescope's focus properties change, and every few seconds when
the windows do (a window can change without any root property moving)."""
def __init__(self, path):
super().__init__(daemon=True, name="focus")
self.path = path
self.changed = threading.Event()
self.last = None
self.seq = 0
def run(self):
threading.Thread(target=self.listen, daemon=True, name="focus-events").start()
while True:
self.changed.wait(timeout=5)
self.changed.clear()
time.sleep(0.2) # let a burst of property changes settle
try:
snap = focus_snapshot()
except (OSError, AgentError) as e:
log("focus snapshot failed: %s" % e)
time.sleep(2)
continue
body = {k: v for k, v in snap.items() if k != "t"}
if body == self.last:
continue
self.last = body
self.seq += 1
snap["seq"] = self.seq
write_json(self.path, snap)
def listen(self):
while True:
try:
x = X(os.environ["DISPLAY"])
wanted = {x.atom(name) for name in FOCUS_PROPS}
x.select(x.root, 0x400000) # PropertyChangeMask
self.changed.set()
while True:
event = x.next_event()
if event[0] & 0x7F == 28 and struct.unpack_from("<I", event, 8)[0] in wanted:
self.changed.set()
except OSError as e:
log("focus events: %s; reconnecting" % e)
time.sleep(2)
# --- Steam client DevTools (CDP) -----------------------------------------------------------------
class WebSocket:
"""A minimal RFC 6455 client: text frames out, text frames in. Enough for CDP."""
def __init__(self, url, timeout):
target = urllib.parse.urlsplit(url)
if (target.scheme != "ws" or target.hostname not in ("127.0.0.1", "::1", "localhost")
or target.username is not None or target.password is not None or target.fragment):
raise AgentError("INVALID_TARGET", "DevTools targets must use a loopback WebSocket")
hostport = target.netloc
path = target.path or "/"
if target.query:
path += "?" + target.query
host = "127.0.0.1" if target.hostname == "localhost" else target.hostname
self.s = socket.create_connection((host, target.port or 80), timeout=timeout)
try:
self._handshake(path, hostport)
except Exception:
self.s.close()
raise
def _handshake(self, path, hostport):
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, hostport, key)
).encode())
head = b""
while b"\r\n\r\n" not in head:
chunk = self.s.recv(4096)
if not chunk:
raise OSError("DevTools closed the connection during the handshake")
head += chunk
if len(head) > 16 * 1024:
raise AgentError("RESPONSE_TOO_LARGE", "The DevTools handshake exceeds 16 KiB")
status = head.split(b"\r\n", 1)[0]
if b" 101 " not in status:
raise OSError("DevTools refused the WebSocket: " + status.decode(errors="replace"))
self.buffer = head.split(b"\r\n\r\n", 1)[1]
def _read(self, n):
while len(self.buffer) < n:
chunk = self.s.recv(65536)
if not chunk:
raise OSError("DevTools closed the connection")
self.buffer += chunk
data, self.buffer = self.buffer[:n], self.buffer[n:]
return data
def send(self, text):
payload = text.encode()
header = bytearray([0x81])
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header += struct.pack(">H", n)
else:
header.append(0x80 | 127)
header += struct.pack(">Q", n)
mask = os.urandom(4)
header += mask
self.s.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def recv(self):
message = b""
while True:
b0, b1 = self._read(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", self._read(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._read(8))[0]
if n > RESPONSE_CAP or len(message) + n > RESPONSE_CAP:
raise AgentError("RESPONSE_TOO_LARGE", "The DevTools response exceeds 1 MiB")
mask = self._read(4) if b1 & 0x80 else None
data = self._read(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
op = b0 & 0x0F
if op == 8:
raise OSError("DevTools closed the WebSocket")
if op == 9: # ping
continue
if op in (0, 1, 2):
message += data
if b0 & 0x80:
return message.decode(errors="replace")
def close(self):
try:
self.s.close()
except OSError:
pass
def cdp_port(agent_dir):
try:
port = (agent_dir / "cdp-port").read_text().strip()
except OSError:
port = os.environ.get("BL_CDP_PORT", "")
if not port.isdigit():
raise AgentError("NO_DEVTOOLS", "The Steam client's DevTools port is not known (no Steam client in this session yet)")
return int(port)
def cdp_targets(agent_dir, timeout=5):
url = "http://127.0.0.1:%d/json" % cdp_port(agent_dir)
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
data = r.read(RESPONSE_CAP + 1)
if len(data) > RESPONSE_CAP:
raise AgentError("RESPONSE_TOO_LARGE", "The DevTools response exceeds 1 MiB")
return json.loads(data.decode())
except OSError as e:
raise AgentError("DEVTOOLS_UNREACHABLE", "Could not reach the client's DevTools at %s: %s" % (url, e))
def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15):
targets = [t for t in cdp_targets(agent_dir) if t.get("webSocketDebuggerUrl")]
want = target or "SharedJSContext"
match = [t for t in targets if want in (t.get("title") or "") or want in (t.get("url") or "")]
if not match:
names = ", ".join(sorted({t.get("title") or t.get("url") or "?" for t in targets}))
raise AgentError("NO_TARGET", "No DevTools target matches '%s' (have: %s)" % (want, names))
ws = WebSocket(match[0]["webSocketDebuggerUrl"], timeout)
try:
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
"expression": expression, "returnByValue": True, "awaitPromise": await_promise,
"userGesture": True,
}}))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
message = json.loads(ws.recv())
if message.get("id") != 1:
continue
if "error" in message:
raise AgentError("CDP_ERROR", json.dumps(message["error"]))
result = message.get("result", {})
if result.get("exceptionDetails"):
details = result["exceptionDetails"]
text = (details.get("exception") or {}).get("description") or details.get("text") or "exception"
raise AgentError("JS_EXCEPTION", text)
value = result.get("result", {})
return {"target": match[0].get("title"), "type": value.get("type"),
"value": value.get("value"), "description": value.get("description")}
raise AgentError("TIMEOUT", "No answer from DevTools in %ss" % timeout)
finally:
ws.close()
# --- Game processes ------------------------------------------------------------------------------
def game_pids(app_id):
"""Our processes started for this Steam app (Steam sets SteamAppId / SteamGameId in their
environment, and Proton's wine processes inherit it)."""
wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()]
me = os.getpid()
pids = []
for entry in os.listdir("/proc"):
if not entry.isdigit() or int(entry) == me:
continue
try:
with open("/proc/%s/environ" % entry, "rb") as f:
env = f.read().split(b"\0")
except OSError:
continue
if any(w in env for w in wanted):
pids.append(int(entry))
return pids
def quit_game(agent_dir, app_id, timeout):
if not str(app_id).isdigit():
raise AgentError("INVALID_APP_ID", "appId must be a decimal Steam app id")
steps = []
if game_pids(app_id):
try:
cdp_eval(agent_dir, "SteamClient.Apps.TerminateApp(%s, false)" % json.dumps(str(app_id)), timeout=5)
steps.append("client")
except AgentError as e:
steps.append("client unavailable: %s" % e)
deadline = time.monotonic() + timeout
while game_pids(app_id) and time.monotonic() < deadline:
time.sleep(0.5)
left = game_pids(app_id)
if left:
steps.append("SIGTERM %s" % left)
for pid in left:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
end = time.monotonic() + 5
while game_pids(app_id) and time.monotonic() < end:
time.sleep(0.5)
left = game_pids(app_id)
if left:
steps.append("SIGKILL %s" % left)
for pid in left:
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
time.sleep(1)
return {"appId": int(app_id), "steps": steps, "remaining": game_pids(app_id)}
# --- Requests ------------------------------------------------------------------------------------
def run_exec(req):
argv = req.get("argv")
if not isinstance(argv, list) or not argv or not all(isinstance(a, str) and "\0" not in a for a in argv):
raise AgentError("INVALID_REQUEST", "exec needs argv, a non-empty list of strings")
timeout = request_timeout(req, 30)
env = dict(os.environ)
extra_env = req.get("env") if req.get("env") is not None else {}
if not isinstance(extra_env, dict) or any("\0" in str(k) or "=" in str(k) or "\0" in str(v) for k, v in extra_env.items()):
raise AgentError("INVALID_REQUEST", "env must be an object with valid environment entries")
env.update({str(k): str(v) for k, v in extra_env.items()})
stdin = req.get("stdin")
if stdin is not None and not isinstance(stdin, str):
raise AgentError("INVALID_REQUEST", "stdin must be a string")
started = time.monotonic()
try:
proc = subprocess.Popen(
argv, cwd=req.get("cwd") or None, env=env, start_new_session=True,
stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
except OSError as e:
raise AgentError("EXEC_FAILED", "%s: %s" % (argv[0], e))
captured = {"stdout": bytearray(), "stderr": bytearray()}
truncated = {"stdout": False, "stderr": False}
pending = memoryview(stdin.encode()) if stdin is not None else memoryview(b"")
deadline = started + timeout
timed_out = False
try:
with selectors.DefaultSelector() as selector:
for stream, name in ((proc.stdout, "stdout"), (proc.stderr, "stderr")):
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ, name)
if proc.stdin is not None:
os.set_blocking(proc.stdin.fileno(), False)
if pending:
selector.register(proc.stdin, selectors.EVENT_WRITE, "stdin")
else:
proc.stdin.close()
while selector.get_map() or proc.poll() is None:
remaining = deadline - time.monotonic()
if remaining <= 0:
timed_out = True
break
for key, _ in selector.select(min(0.1, remaining)):
stream, name = key.fileobj, key.data
if name == "stdin":
try:
pending = pending[os.write(stream.fileno(), pending[:65536]):]
except BrokenPipeError:
pending = pending[len(pending):]
if not pending:
selector.unregister(stream)
stream.close()
else:
data = os.read(stream.fileno(), 65536)
if not data:
selector.unregister(stream)
stream.close()
continue
available = OUTPUT_CAP - len(captured[name])
captured[name].extend(data[:available])
truncated[name] |= len(data) > available
if timed_out:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait(timeout=5)
finally:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait(timeout=5)
for stream in (proc.stdin, proc.stdout, proc.stderr):
if stream is not None:
stream.close()
def text(name):
return {"text": captured[name].decode(errors="replace"), "truncated": truncated[name]}
return {"exitCode": proc.returncode, "timedOut": timed_out,
"seconds": round(time.monotonic() - started, 3), "stdout": text("stdout"), "stderr": text("stderr")}
def request_timeout(req, default):
try:
timeout = float(req.get("timeout", default))
except (TypeError, ValueError):
raise AgentError("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
if not math.isfinite(timeout) or not 0 < timeout <= 600:
raise AgentError("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
return timeout
def handle(agent_dir, req):
kind = req.get("kind")
if kind == "ping":
return {"pid": os.getpid(), "version": VERSION, "displays": displays()}
if kind == "exec":
return run_exec(req)
if kind == "focus":
return focus_snapshot()
if kind == "cdp":
if req.get("op") == "targets":
return {"targets": [{k: t.get(k) for k in ("title", "url", "type")} for t in cdp_targets(agent_dir)]}
expression = req.get("expression")
if not isinstance(expression, str) or not expression:
raise AgentError("INVALID_REQUEST", "cdp eval needs an expression")
return cdp_eval(agent_dir, expression, req.get("target"), bool(req.get("awaitPromise", True)),
request_timeout(req, 15))
if kind == "quit":
return quit_game(agent_dir, req.get("appId"), request_timeout(req, 15))
raise AgentError("UNKNOWN_REQUEST", "Unknown agent request '%s'" % kind)
def answer(agent_dir, path):
rid = path.stem
try:
with path.open("rb") as stream:
data = stream.read(REQUEST_CAP + 1)
if len(data) > REQUEST_CAP:
raise ValueError("The request exceeds 256 KiB")
req = json.loads(data)
if not isinstance(req, dict):
raise ValueError("The request must be a JSON object")
except (OSError, ValueError) as e:
req = None
result = {"ok": False, "error": {"code": "INVALID_REQUEST", "message": str(e)}}
finally:
try:
path.unlink()
except OSError:
pass
if req is not None:
try:
result = dict(handle(agent_dir, req), ok=True)
except AgentError as e:
result = {"ok": False, "error": {"code": e.code, "message": str(e)}}
except Exception as e: # an answer of some kind always goes back
result = {"ok": False, "error": {"code": "AGENT_FAILED", "message": "%s: %s" % (type(e).__name__, e)}}
write_json(agent_dir / "resp" / (rid + ".json"), result)
def serve(agent_dir, focus=True, poll=0.1):
(agent_dir / "req").mkdir(parents=True, exist_ok=True)
(agent_dir / "resp").mkdir(parents=True, exist_ok=True)
can_focus = focus and bool(os.environ.get("DISPLAY"))
if can_focus:
FocusWatcher(agent_dir / "focus.json").start()
write_json(agent_dir / "hello.json", {"pid": os.getpid(), "version": VERSION, "started": int(time.time() * 1000),
"displays": displays(), "focus": can_focus})
log("serving %s (focus %s)" % (agent_dir, "on" if can_focus else "off"))
taken = set()
while True:
for path in sorted((agent_dir / "req").glob("*.json")):
if path.name in taken:
continue
taken.add(path.name)
threading.Thread(target=lambda p=path: (answer(agent_dir, p), taken.discard(p.name)),
daemon=True, name="req").start()
time.sleep(poll)
def main(argv):
base = os.environ.get("BL_LAUNCH_DIR")
if not base:
print("BL_LAUNCH_DIR is not set", file=sys.stderr)
return 2
serve(Path(base) / "agent", focus="--no-focus" not in argv)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -1,46 +0,0 @@
#!/bin/bash
# DroidDeck's fontconfig setup for the Linux runtime. The rootfs ships DejaVu alone and no rules
# for a default family: applications then find fonts but no default, and Firefox logs "no fonts"
# and draws pages without text. DejaVu also has no CJK, Thai or Arabic glyphs, so those draw as
# tofu boxes. The app binds Android's fonts for the session, which adds language-specific fallbacks
# separately. This script links fontconfig's default rules and writes generic DejaVu aliases to an
# owned conf.d file. Fontconfig reads local.conf afterward, so user overrides keep precedence and
# are never replaced.
# The first argument is the root to set up, "/" in a session; the tests point it at a temp tree.
set -u
root=${1:-/}
root=${root%/}
# "/" strips to empty above; put it back so the paths below read as absolute and the test for a
# real session ("/") still holds.
[ -n "$root" ] || root=/
conf_dir=$root/etc/fonts
defaults_dir=$root/usr/share/fontconfig/conf.default
mkdir -p "$conf_dir/conf.d" 2>/dev/null
# The default rules (sans-serif -> DejaVu Sans and the rest) live as links in /etc/fonts/conf.d,
# and the rootfs has none. Linked by name, so a file we ship is never overwritten.
if [ -d "$defaults_dir" ]; then
for f in "$defaults_dir"/*.conf; do
[ -e "$f" ] || continue
[ -e "$conf_dir/conf.d/${f##*/}" ] || ln -sf "$f" "$conf_dir/conf.d/${f##*/}"
done
fi
owned_conf=$conf_dir/conf.d/65-droiddeck-default-fonts.conf
{
echo '<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"><fontconfig>'
for generic in sans-serif sans serif monospace mono cursive fantasy system-ui ui-sans-serif; do
case $generic in
serif) face="DejaVu Serif" ;;
mono*|ui-mono) face="DejaVu Sans Mono" ;;
*) face="DejaVu Sans" ;;
esac
printf '<alias binding="weak"><family>%s</family><prefer><family>%s</family></prefer></alias>\n' "$generic" "$face"
done
echo '</fontconfig>'
} > "$owned_conf.new"
if ! cmp -s "$owned_conf.new" "$owned_conf" 2>/dev/null; then
mv -f "$owned_conf.new" "$owned_conf"
echo "== fontconfig: wrote DejaVu defaults"
fi
rm -f "$owned_conf.new"
@@ -97,14 +97,6 @@ export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0}
# A sleep that starts no process. Under proot every exec is a traced execve plus the loader's
# opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60
# execs a minute each. read -t on a pipe nothing ever writes to waits in bash itself.
# The agent bridge's guest side (docs/agent-control.md): runs what the app's agent provider asks
# for inside this session and publishes gamescope's focus. Its files live under the session root
# ($BL_LAUNCH_DIR/agent), which the app empties before each session.
start_agent() {
[ -n "${BL_LAUNCH_DIR:-}" ] && [ -x /usr/local/bin/droiddeck-agent ] || return 0
mkdir -p "$BL_LAUNCH_DIR/agent"
/usr/local/bin/droiddeck-agent "$@" 2>&1 &
}
nap() {
[ -n "${bl_nap_fd:-}" ] || exec {bl_nap_fd}<> <(:)
read -r -t "$1" -u "$bl_nap_fd" _ 2>/dev/null || :
@@ -144,9 +136,6 @@ program_cores() {
}
if [ -z "${BL_INSIDE:-}" ]; then
# Install the shared default-family aliases before Steam, gamescope or the desktop queries fonts.
/usr/local/bin/droiddeck-fonts 2>&1 | sed 's/^/[fonts] /'
# Android's directory binding and language-specific family matches handle CJK and Thai fonts.
device_fonts=/usr/local/share/fonts/android
if [ -d "$device_fonts" ] && [ -d /etc/fonts/conf.d ] && command -v fc-cache >/dev/null 2>&1; then
fonts_rules=$(
@@ -177,7 +166,6 @@ if [ -z "${BL_INSIDE:-}" ]; then
# into the app's: no gamescope in between.
if [ "$mode" = lxqt ]; then
step "starting the desktop"
start_agent --no-focus
[ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; }
cores=$(program_cores)
if [ -n "$cores" ]; then
@@ -296,8 +284,6 @@ if [ -n "${BL_ANDROID_CLIPBOARD:-}" ] && [ -x /usr/local/bin/droiddeck-clipboard
/usr/local/bin/droiddeck-clipboard "$BL_ANDROID_CLIPBOARD" &
fi
start_agent
case $mode in
desktop)
exec pcmanfm
@@ -724,8 +710,6 @@ BUSCONF
export BL_CDP_GUARD=$BL_CDP_PORT
echo "== steam: devtools port $BL_CDP_PORT, guest processes only"
fi
# The agent bridge drives the client through it (droiddeck-agent's cdp requests).
[ -n "${BL_LAUNCH_DIR:-}" ] && mkdir -p "$BL_LAUNCH_DIR/agent" && echo "$BL_CDP_PORT" > "$BL_LAUNCH_DIR/agent/cdp-port"
droiddeck-steam-compat "$steam_root" 2>&1 &
# The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
# BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
-811
View File
@@ -1,811 +0,0 @@
/*
* The Steam client lists a whole content directory about ten times per file it creates, moves or
* deletes, to match names case-insensitively. On Android's FUSE storage that made reserving space
* quadratic. Listings under steamapps/ on FUSE are kept for at most DIR_TTL_NS and dropped on any
* inotify event the client's own patched changes don't account for; directory timestamps alone
* can't be trusted, as NTFS volumes report whole seconds. BL_NO_DIR_CACHE=1 turns it off.
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <pthread.h>
#include <stdarg.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/inotify.h>
#include <sys/stat.h>
#include <sys/statfs.h>
#include <time.h>
#include <unistd.h>
#define DIR_TTL_NS 5000000000LL
#define MAX_LISTINGS 16
#define MAX_ENTRIES 100000
#define MAX_HANDLES 64
#define FUSE_MAGIC 0x65735546
#define WATCHED (IN_CREATE | IN_DELETE | IN_MOVED_FROM | IN_MOVED_TO | IN_DELETE_SELF | IN_MOVE_SELF | IN_ONLYDIR)
/* The client is 64-bit; 32-bit builds only pass creates through. */
#if __SIZEOF_POINTER__ == 8
_Static_assert(sizeof(struct dirent) == sizeof(struct dirent64), "dirent layouts differ");
struct entry { ino_t ino; unsigned char type; char *name; };
struct listing {
dev_t dev;
ino_t ino;
struct timespec mtime, ctime;
off_t size;
nlink_t nlink;
long long filled;
long long used;
int refs;
int detached;
int wd;
size_t n, cap;
struct entry *e;
char *recs;
size_t len;
};
struct handle {
struct listing *snap;
struct listing *fill;
DIR *real;
size_t pos;
int fd;
int wd;
int dirty;
char *path;
struct dirent64 out;
};
struct expect { int wd; uint32_t mask; const char *name; };
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t oplock = PTHREAD_MUTEX_INITIALIZER;
static struct listing *table[MAX_LISTINGS];
static struct handle *handles[MAX_HANDLES];
static int high_water;
static int cached_listings;
static int changing;
static int notify = -1;
static int forked;
static struct { dev_t dev; int fuse; } devs[8];
static int ndevs;
static long long now_ns(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC_COARSE, &t);
return (long long)t.tv_sec * 1000000000LL + t.tv_nsec;
}
/* A child shares the parent's inotify queue, so it stops keeping listings. */
static void after_fork(void) {
pthread_mutex_init(&lock, NULL);
pthread_mutex_init(&oplock, NULL);
forked = 1;
}
static int enabled(void) {
static int cached = -1;
if (cached < 0) {
const char *off = getenv("BL_NO_DIR_CACHE");
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
strstr(program_invocation_name, "steamrtarm64") != NULL && !(off && strcmp(off, "1") == 0);
if (cached) pthread_atfork(NULL, NULL, after_fork);
}
return cached && !forked;
}
static int eligible(const char *path) {
if (path == NULL || path[0] != '/' || !enabled()) return 0;
const char *s = strstr(path, "/steamapps");
return s && (s[10] == '/' || s[10] == 0);
}
static int on_fuse(dev_t dev, const char *path) {
pthread_mutex_lock(&lock);
for (int i = 0; i < ndevs; i++) {
if (devs[i].dev == dev) {
int fuse = devs[i].fuse;
pthread_mutex_unlock(&lock);
return fuse;
}
}
pthread_mutex_unlock(&lock);
struct statfs fs;
int saved = errno;
int fuse = statfs(path, &fs) == 0 && (unsigned long)fs.f_type == FUSE_MAGIC;
errno = saved;
pthread_mutex_lock(&lock);
if (ndevs < 8) {
devs[ndevs].dev = dev;
devs[ndevs].fuse = fuse;
ndevs++;
}
pthread_mutex_unlock(&lock);
return fuse;
}
static int same(const struct listing *l, const struct stat *st) {
return l->ino == st->st_ino && l->dev == st->st_dev && l->size == st->st_size && l->nlink == st->st_nlink &&
l->mtime.tv_sec == st->st_mtim.tv_sec && l->mtime.tv_nsec == st->st_mtim.tv_nsec &&
l->ctime.tv_sec == st->st_ctim.tv_sec && l->ctime.tv_nsec == st->st_ctim.tv_nsec;
}
static void stamp(struct listing *l, const struct stat *st) {
l->dev = st->st_dev;
l->ino = st->st_ino;
l->size = st->st_size;
l->nlink = st->st_nlink;
l->mtime = st->st_mtim;
l->ctime = st->st_ctim;
}
static void destroy(struct listing *l) {
for (size_t i = 0; i < l->n; i++) free(l->e[i].name);
free(l->e);
free(l->recs);
free(l);
}
static void unref(struct listing *l) {
if (--l->refs == 0 && l->detached) destroy(l);
}
/* The rest is called with lock held. */
static void release(int wd) {
if (wd < 0 || notify < 0) return;
for (int i = 0; i < MAX_LISTINGS; i++)
if (table[i] && table[i]->wd == wd) return;
for (int i = 0; i < high_water; i++)
if (handles[i] && handles[i]->wd == wd) return;
inotify_rm_watch(notify, wd);
}
static void detach(struct listing *l) {
__atomic_sub_fetch(&cached_listings, 1, __ATOMIC_RELEASE);
l->detached = 1;
if (l->refs == 0) destroy(l);
}
/* Takes the listing out of the table; freed now or when its last reader closes. */
static void drop(int slot) {
struct listing *l = table[slot];
int wd = l->wd;
table[slot] = NULL;
detach(l);
release(wd);
}
static void changed(int wd) {
for (int i = 0; i < MAX_LISTINGS; i++)
if (table[i] && (table[i]->wd == wd || wd < 0)) drop(i);
for (int i = 0; i < high_water; i++)
if (handles[i] && (handles[i]->wd == wd || wd < 0)) __atomic_store_n(&handles[i]->dirty, 1, __ATOMIC_RELAXED);
}
/* Applies queued events; the expected ones are the client's own changes, already patched in. */
static void pump(struct expect *x, int nx) {
char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
if (notify < 0 || forked) return;
int saved = errno;
for (;;) {
ssize_t n = read(notify, buf, sizeof(buf));
if (n <= 0) {
if (n < 0 && errno != EAGAIN && errno != EINTR) {
notify = -2;
changed(-1);
}
break;
}
for (char *p = buf; p < buf + n;) {
struct inotify_event *ev = (struct inotify_event *)p;
p += sizeof(*ev) + ev->len;
if (ev->mask & IN_Q_OVERFLOW) {
changed(-1);
continue;
}
int k = 0;
while (k < nx && !(x[k].wd == ev->wd && (ev->mask & x[k].mask) && ev->len && strcmp(ev->name, x[k].name) == 0)) k++;
if (k < nx) {
x[k].wd = -1;
for (int i = 0; i < high_water; i++)
if (handles[i] && handles[i]->wd == ev->wd) __atomic_store_n(&handles[i]->dirty, 1, __ATOMIC_RELAXED);
} else {
changed(ev->wd);
}
}
}
errno = saved;
}
static int watch(const char *path) {
if (notify == -1) notify = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
if (notify < 0) {
notify = -2;
return -1;
}
int saved = errno;
int wd = inotify_add_watch(notify, path, WATCHED);
errno = saved;
return wd;
}
static int find(dev_t dev, ino_t ino) {
for (int i = 0; i < MAX_LISTINGS; i++)
if (table[i] && table[i]->dev == dev && table[i]->ino == ino) return i;
return -1;
}
static int push(struct listing *l, ino_t ino, unsigned char type, const char *name) {
if (l->n == l->cap) {
size_t cap = l->cap ? l->cap * 2 : 256;
struct entry *e = realloc(l->e, cap * sizeof(*e));
if (e == NULL) return -1;
l->e = e;
l->cap = cap;
}
char *copy = strdup(name);
if (copy == NULL) return -1;
l->e[l->n++] = (struct entry){ino, type, copy};
return 0;
}
static void install(struct listing *l) {
l->detached = 0;
__atomic_add_fetch(&cached_listings, 1, __ATOMIC_RELEASE);
int slot = find(l->dev, l->ino);
if (slot >= 0) {
struct listing *old = table[slot];
table[slot] = l;
detach(old);
if (old->wd != l->wd) release(old->wd);
return;
}
int victim = -1;
for (int i = 0; i < MAX_LISTINGS; i++) {
if (table[i] == NULL) { victim = i; break; }
if (victim < 0 || table[i]->used < table[victim]->used) victim = i;
}
if (table[victim]) drop(victim);
table[victim] = l;
}
static int registered(DIR *d) {
int n = __atomic_load_n(&high_water, __ATOMIC_ACQUIRE);
for (int i = 0; i < n; i++)
if (__atomic_load_n(&handles[i], __ATOMIC_ACQUIRE) == (struct handle *)d) return i;
return -1;
}
static DIR *wrap(struct handle *h) {
for (int i = 0; i < MAX_HANDLES; i++) {
if (handles[i] == NULL) {
__atomic_store_n(&handles[i], h, __ATOMIC_RELEASE);
if (i >= high_water) __atomic_store_n(&high_water, i + 1, __ATOMIC_RELEASE);
return (DIR *)h;
}
}
return NULL;
}
#define RECLEN(namelen) ((offsetof(struct dirent64, d_name) + (namelen) + 1 + 7) & ~(size_t)7)
/* The listing as packed dirent64 records, built once per version. */
static int records(struct listing *l) {
if (l->recs) return 0;
size_t len = 0;
for (size_t i = 0; i < l->n; i++) len += RECLEN(strlen(l->e[i].name));
char *recs = malloc(len ? len : 1);
if (recs == NULL) return -1;
size_t off = 0;
for (size_t i = 0; i < l->n; i++) {
size_t namelen = strlen(l->e[i].name), reclen = RECLEN(namelen);
struct dirent64 *r = (struct dirent64 *)(recs + off);
memset(r, 0, reclen);
off += reclen;
r->d_ino = l->e[i].ino;
r->d_off = (off_t)off;
r->d_reclen = (unsigned short)reclen;
r->d_type = l->e[i].type;
memcpy(r->d_name, l->e[i].name, namelen + 1);
}
l->recs = recs;
l->len = len;
return 0;
}
/* Lock-free from here on unless noted: a stream is only ever used by one thread at a time. */
static struct handle *lookup(DIR *d) {
return registered(d) >= 0 ? (struct handle *)d : NULL;
}
#define REAL(name) static __typeof__(&name) next_##name
#define NEXT(name) \
if (next_##name == NULL) next_##name = (__typeof__(&name))dlsym(RTLD_NEXT, #name)
REAL(opendir);
REAL(readdir64);
REAL(readdir);
REAL(closedir);
REAL(dirfd);
REAL(rewinddir);
REAL(telldir);
REAL(seekdir);
REAL(mkdir);
REAL(rmdir);
REAL(unlink);
REAL(remove);
REAL(rename);
REAL(link);
REAL(symlink);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
REAL(readdir64_r);
REAL(readdir_r);
/* Resolved before the client starts threads; the calls below only fill gaps for earlier callers. */
__attribute__((constructor)) static void resolve(void) {
NEXT(opendir);
NEXT(readdir64);
NEXT(readdir);
NEXT(closedir);
NEXT(dirfd);
NEXT(rewinddir);
NEXT(telldir);
NEXT(seekdir);
NEXT(mkdir);
NEXT(rmdir);
NEXT(unlink);
NEXT(remove);
NEXT(rename);
NEXT(link);
NEXT(symlink);
NEXT(readdir64_r);
NEXT(readdir_r);
}
#pragma GCC diagnostic pop
/* A reader of the kept listing when it is still current. */
static DIR *hit(const struct stat *st, struct handle *h, long long now) {
pthread_mutex_lock(&lock);
if (!changing) pump(NULL, 0);
int slot = find(st->st_dev, st->st_ino);
if (slot >= 0 && same(table[slot], st) && now - table[slot]->filled < DIR_TTL_NS && records(table[slot]) == 0) {
h->snap = table[slot];
h->snap->refs++;
h->snap->used = now;
DIR *d = wrap(h);
if (d) {
pthread_mutex_unlock(&lock);
return d;
}
unref(h->snap);
h->snap = NULL;
} else if (slot >= 0) {
drop(slot);
}
pthread_mutex_unlock(&lock);
return NULL;
}
/* Registers the stream and its watch before anything is read, so no change goes unseen. */
static int track(struct handle *h, const char *path) {
pthread_mutex_lock(&lock);
h->wd = watch(path);
DIR *d = h->wd >= 0 ? wrap(h) : NULL;
if (d == NULL && h->wd >= 0) release(h->wd);
pthread_mutex_unlock(&lock);
return d != NULL;
}
/* Reads through the real stream, keeping what it returns. */
static void filling(struct handle *h, DIR *real, const struct stat *st, long long now) {
h->real = real;
h->fill = calloc(1, sizeof(*h->fill));
if (h->fill) {
stamp(h->fill, st);
h->fill->filled = now;
h->fill->used = now;
h->fill->wd = h->wd;
}
}
static void untrack(struct handle *h) {
pthread_mutex_lock(&lock);
int i = registered((DIR *)h);
if (i >= 0) __atomic_store_n(&handles[i], NULL, __ATOMIC_RELEASE);
release(h->wd);
pthread_mutex_unlock(&lock);
}
DIR *opendir(const char *path) {
NEXT(opendir);
struct stat st;
if (!eligible(path) || stat(path, &st) != 0 || !S_ISDIR(st.st_mode) || !on_fuse(st.st_dev, path))
return next_opendir(path);
struct handle *h = calloc(1, sizeof(*h));
char *copy = strdup(path);
if (h == NULL || copy == NULL) {
free(h);
free(copy);
return next_opendir(path);
}
h->fd = -1;
h->wd = -1;
h->path = copy;
long long now = now_ns();
DIR *d = hit(&st, h, now);
if (d) return d;
if (!track(h, path)) {
free(h->path);
free(h);
return next_opendir(path);
}
DIR *real = stat(path, &st) == 0 ? next_opendir(path) : NULL;
if (real == NULL) {
untrack(h);
free(h->path);
free(h);
return next_opendir(path);
}
filling(h, real, &st, now);
return (DIR *)h;
}
static void abandon(struct handle *h) {
if (h->fill) destroy(h->fill);
h->fill = NULL;
}
static void finish(struct handle *h) {
struct listing *l = h->fill;
struct stat st;
h->fill = NULL;
int current = stat(h->path, &st) == 0 && same(l, &st);
pthread_mutex_lock(&lock);
if (!changing) pump(NULL, 0);
if (current && !__atomic_load_n(&h->dirty, __ATOMIC_RELAXED)) install(l);
else destroy(l);
pthread_mutex_unlock(&lock);
}
static struct dirent64 *entry(struct handle *h) {
NEXT(readdir64);
if (h->real) {
int saved = errno;
errno = 0;
struct dirent64 *r = next_readdir64(h->real);
int error = errno;
if (h->fill) {
if (r) {
if (h->fill->n >= MAX_ENTRIES || push(h->fill, r->d_ino, r->d_type, r->d_name) != 0) abandon(h);
} else if (error == 0) {
finish(h);
} else {
abandon(h);
}
}
errno = error ? error : saved;
return r;
}
if (h->pos >= h->snap->len) return NULL;
const struct dirent64 *r = (const struct dirent64 *)(h->snap->recs + h->pos);
h->pos += r->d_reclen;
memcpy(&h->out, r, r->d_reclen);
return &h->out;
}
struct dirent64 *readdir64(DIR *d) {
NEXT(readdir64);
struct handle *h = lookup(d);
return h ? entry(h) : next_readdir64(d);
}
struct dirent *readdir(DIR *d) {
NEXT(readdir);
struct handle *h = lookup(d);
return h ? (struct dirent *)entry(h) : next_readdir(d);
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
int readdir64_r(DIR *d, struct dirent64 *out, struct dirent64 **result) {
NEXT(readdir64_r);
struct handle *h = lookup(d);
if (h == NULL) return next_readdir64_r(d, out, result);
if (h->real) {
abandon(h);
return next_readdir64_r(h->real, out, result);
}
struct dirent64 *r = entry(h);
if (r) memcpy(out, r, r->d_reclen);
*result = r ? out : NULL;
return 0;
}
int readdir_r(DIR *d, struct dirent *out, struct dirent **result) {
NEXT(readdir_r);
if (lookup(d) == NULL) return next_readdir_r(d, out, result);
return readdir64_r(d, (struct dirent64 *)out, (struct dirent64 **)result);
}
#pragma GCC diagnostic pop
int closedir(DIR *d) {
NEXT(closedir);
pthread_mutex_lock(&lock);
int i = registered(d);
struct handle *h = i >= 0 ? handles[i] : NULL;
if (h) {
__atomic_store_n(&handles[i], NULL, __ATOMIC_RELEASE);
if (h->snap) unref(h->snap);
release(h->wd);
}
pthread_mutex_unlock(&lock);
if (h == NULL) return next_closedir(d);
int r = 0;
if (h->real) r = next_closedir(h->real);
abandon(h);
if (h->fd >= 0) close(h->fd);
free(h->path);
free(h);
return r;
}
int dirfd(DIR *d) {
NEXT(dirfd);
struct handle *h = lookup(d);
if (h == NULL) return next_dirfd(d);
if (h->real) return next_dirfd(h->real);
if (h->fd < 0) h->fd = open(h->path, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
return h->fd;
}
void rewinddir(DIR *d) {
NEXT(rewinddir);
struct handle *h = lookup(d);
if (h == NULL) { next_rewinddir(d); return; }
if (h->real) {
abandon(h);
next_rewinddir(h->real);
} else {
h->pos = 0;
}
}
long telldir(DIR *d) {
NEXT(telldir);
struct handle *h = lookup(d);
if (h == NULL) return next_telldir(d);
return h->real ? next_telldir(h->real) : (long)h->pos;
}
void seekdir(DIR *d, long pos) {
NEXT(seekdir);
struct handle *h = lookup(d);
if (h == NULL) { next_seekdir(d, pos); return; }
if (h->real) {
abandon(h);
next_seekdir(h->real, pos);
} else {
size_t off = 0;
while (off < (size_t)pos && off < h->snap->len) off += ((const struct dirent64 *)(h->snap->recs + off))->d_reclen;
if (off == (size_t)pos) h->pos = off;
}
}
struct side { char dir[PATH_MAX]; const char *name; struct stat before; int known; };
static int split(const char *path, struct side *s) {
size_t n = strlen(path);
while (n > 1 && path[n - 1] == '/') n--;
const char *slash = memrchr(path, '/', n);
if (slash == NULL || (size_t)(slash - path) >= sizeof(s->dir) || slash[1] == 0) return 0;
size_t d = slash == path ? 1 : (size_t)(slash - path);
memcpy(s->dir, path, d);
s->dir[d] = 0;
s->name = slash + 1;
return strchr(s->name, '/') == NULL && strcmp(s->name, ".") != 0 && strcmp(s->name, "..") != 0;
}
static int watching(const char *path) {
return eligible(path) && __atomic_load_n(&cached_listings, __ATOMIC_ACQUIRE) > 0;
}
/* Called with oplock held: the client's tracked changes happen one at a time. */
static void begin(void) {
pthread_mutex_lock(&lock);
pump(NULL, 0);
changing = 1;
pthread_mutex_unlock(&lock);
}
static void before(struct side *s) {
s->known = stat(s->dir, &s->before) == 0;
}
enum { ADDED, REMOVED };
/* Patches the listing after the client's own change, or drops it when unsure. Called with lock held. */
static int patch(struct side *s, int what, uint32_t event, int ok, int error, const char *target, struct expect *x) {
struct stat now;
if (!s->known) return 0;
int slot = find(s->before.st_dev, s->before.st_ino);
if (slot < 0) return 0;
struct listing *l = table[slot];
if (!same(l, &s->before)) {
drop(slot);
return 0;
}
size_t exact = l->n, folded = l->n;
for (size_t i = 0; i < l->n; i++) {
if (strcmp(l->e[i].name, s->name) == 0) { exact = i; break; }
if (folded == l->n && strcasecmp(l->e[i].name, s->name) == 0) folded = i;
}
if (!ok) {
if ((what == ADDED && error == EEXIST && exact == l->n && folded == l->n) || (what == REMOVED && error == ENOENT && exact < l->n))
drop(slot);
return 0;
}
if (stat(s->dir, &now) != 0 || now.st_ino != l->ino) {
drop(slot);
return 0;
}
if (l->refs > 0) {
struct listing *copy = calloc(1, sizeof(*copy));
int failed = copy == NULL;
for (size_t i = 0; !failed && i < l->n; i++) failed = push(copy, l->e[i].ino, l->e[i].type, l->e[i].name) != 0;
if (failed) {
if (copy) destroy(copy);
drop(slot);
return 0;
}
copy->filled = l->filled;
copy->used = l->used;
copy->wd = l->wd;
copy->detached = 0;
stamp(copy, &s->before);
table[slot] = copy;
l->detached = 1;
l = copy;
}
free(l->recs);
l->recs = NULL;
l->len = 0;
int fine = 1, expected = 1;
struct stat t;
if (what == ADDED) {
if (exact < l->n) {
if (lstat(target, &t) == 0) l->e[exact].ino = t.st_ino;
expected = event == IN_MOVED_TO;
} else if (folded < l->n || lstat(target, &t) != 0) {
fine = 0;
} else {
unsigned char type = S_ISDIR(t.st_mode) ? DT_DIR : S_ISLNK(t.st_mode) ? DT_LNK : S_ISREG(t.st_mode) ? DT_REG : DT_UNKNOWN;
fine = l->n < MAX_ENTRIES && push(l, t.st_ino, type, s->name) == 0;
}
} else if (exact < l->n) {
free(l->e[exact].name);
memmove(&l->e[exact], &l->e[exact + 1], (l->n - exact - 1) * sizeof(l->e[0]));
l->n--;
} else {
fine = 0;
}
if (!fine) {
drop(slot);
return 0;
}
stamp(l, &now);
if (!expected) return 0;
*x = (struct expect){l->wd, event, s->name};
return 1;
}
static void end(struct expect *x, int nx) {
pthread_mutex_lock(&lock);
pump(x, nx);
changing = 0;
pthread_mutex_unlock(&lock);
}
int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode,
int (*real)(int, const char *, int, mode_t)) __attribute__((visibility("hidden")));
int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode,
int (*real)(int, const char *, int, mode_t)) {
struct side s;
if (!(flags & O_CREAT) || (dirfd != AT_FDCWD && (path == NULL || path[0] != '/')) || !watching(path) || !split(path, &s))
return real(dirfd, path, flags, mode);
pthread_mutex_lock(&oplock);
begin();
before(&s);
int fd = real(dirfd, path, flags, mode);
int saved = errno;
struct expect x;
pthread_mutex_lock(&lock);
int nx = patch(&s, ADDED, IN_CREATE, fd >= 0, saved, path, &x);
pthread_mutex_unlock(&lock);
end(&x, nx);
pthread_mutex_unlock(&oplock);
errno = saved;
return fd;
}
#define ONE_PATH(name, what, event, call, ...) \
int name(const char *path, ##__VA_ARGS__) { \
NEXT(name); \
struct side s; \
if (!watching(path) || !split(path, &s)) return call; \
pthread_mutex_lock(&oplock); \
begin(); \
before(&s); \
int r = call; \
int saved = errno; \
struct expect x; \
pthread_mutex_lock(&lock); \
int nx = patch(&s, what, event, r == 0, saved, what == ADDED ? path : NULL, &x); \
pthread_mutex_unlock(&lock); \
end(&x, nx); \
pthread_mutex_unlock(&oplock); \
errno = saved; \
return r; \
}
ONE_PATH(mkdir, ADDED, IN_CREATE, next_mkdir(path, mode), mode_t mode)
ONE_PATH(rmdir, REMOVED, IN_DELETE, next_rmdir(path))
ONE_PATH(unlink, REMOVED, IN_DELETE, next_unlink(path))
ONE_PATH(remove, REMOVED, IN_DELETE, next_remove(path))
#define TWO_PATHS(name, gone, event) \
int name(const char *from, const char *to) { \
NEXT(name); \
struct side a, b; \
int track_a = gone && watching(from) && split(from, &a), track_b = watching(to) && split(to, &b); \
if (!track_a && !track_b) return next_##name(from, to); \
pthread_mutex_lock(&oplock); \
begin(); \
if (track_a) before(&a); \
if (track_b) before(&b); \
int r = next_##name(from, to); \
int saved = errno; \
struct expect x[2]; \
int nx = 0; \
pthread_mutex_lock(&lock); \
if (track_a) nx += patch(&a, REMOVED, IN_MOVED_FROM, r == 0, saved, NULL, &x[nx]); \
if (track_b) { \
if (track_a && strcmp(a.dir, b.dir) == 0) b.known = stat(b.dir, &b.before) == 0; \
nx += patch(&b, ADDED, event, r == 0, saved, to, &x[nx]); \
} \
pthread_mutex_unlock(&lock); \
end(x, nx); \
pthread_mutex_unlock(&oplock); \
errno = saved; \
return r; \
}
TWO_PATHS(rename, 1, IN_MOVED_TO)
TWO_PATHS(link, 0, IN_CREATE)
TWO_PATHS(symlink, 0, IN_CREATE)
#else
int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode,
int (*real)(int, const char *, int, mode_t)) __attribute__((visibility("hidden")));
int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode,
int (*real)(int, const char *, int, mode_t)) {
return real(dirfd, path, flags, mode);
}
#endif
+24 -67
View File
@@ -41,19 +41,6 @@ static const char *net_dir(void) {
return dir;
}
static int loopback_family(const struct sockaddr *address, socklen_t length) {
if (address && address->sa_family == AF_INET && length >= sizeof(struct sockaddr_in)) {
return ((const struct sockaddr_in *)address)->sin_addr.s_addr == htonl(INADDR_LOOPBACK) ? AF_INET : 0;
}
if (address && address->sa_family == AF_INET6 && length >= sizeof(struct sockaddr_in6)) {
const struct in6_addr *ip = &((const struct sockaddr_in6 *)address)->sin6_addr;
if (IN6_IS_ADDR_LOOPBACK(ip)) return AF_INET6;
if (IN6_IS_ADDR_V4MAPPED(ip) && ip->s6_addr[12] == 127 && ip->s6_addr[13] == 0 &&
ip->s6_addr[14] == 0 && ip->s6_addr[15] == 1) return AF_INET;
}
return 0;
}
/* Record that this process owns a loopback socket on its local port; peer is the far end's port,
* 0 for a listening socket. */
static void record_port(int fd, unsigned peer) {
@@ -62,11 +49,12 @@ static void record_port(int fd, unsigned peer) {
if (getsockname(fd, (struct sockaddr *)&ss, &len) != 0) {
return;
}
int family = loopback_family((struct sockaddr *)&ss, len);
if (!family) return;
unsigned port;
if (ss.ss_family == AF_INET) {
struct sockaddr_in *in = (struct sockaddr_in *)&ss;
if (in->sin_addr.s_addr != htonl(INADDR_LOOPBACK)) {
return;
}
port = ntohs(in->sin_port);
} else if (ss.ss_family == AF_INET6) {
port = ntohs(((struct sockaddr_in6 *)&ss)->sin6_port);
@@ -76,21 +64,14 @@ static void record_port(int fd, unsigned peer) {
if (port == 0) {
return;
}
struct stat socket_stat;
if (fstat(fd, &socket_stat) != 0) return;
char path[300];
snprintf(path, sizeof(path), "%s/p%u", net_dir(), port);
char staged[360];
snprintf(staged, sizeof(staged), "%s.tmp.%d.%d", path, (int)getpid(), fd);
char line[128];
int n = snprintf(line, sizeof(line), "%d %d %d %u %d %llu %d\n", (int)getpid(), (int)getppid(),
(int)getuid(), peer, fd, (unsigned long long)socket_stat.st_ino, family);
int out = open(staged, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0600);
char line[64];
int n = snprintf(line, sizeof(line), "%d %d %d %u\n", (int)getpid(), (int)getppid(), (int)getuid(), peer);
int out = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (out >= 0) {
int complete = write(out, line, n) == n;
if (write(out, line, n) != n) { /* best effort */ }
close(out);
if (complete) rename(staged, path);
unlink(staged);
}
}
@@ -117,7 +98,6 @@ typedef int (*bind_fn)(int, const struct sockaddr *, socklen_t);
typedef int (*listen_fn)(int, int);
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
typedef int (*accept4_fn)(int, struct sockaddr *, socklen_t *, int);
static unsigned guarded_port(void);
/* udevmon.c: a stand-in for the netlink socket the sandbox refuses, bound here without the kernel. */
__attribute__((visibility("hidden"))) int bl_udevmon_stand_in(int fd);
@@ -132,18 +112,6 @@ int bind(int fd, const struct sockaddr *addr, socklen_t len) {
}
return 0;
}
struct sockaddr_storage local;
unsigned guarded = guarded_port();
if (guarded && addr_port(addr, len) == guarded && len <= sizeof(local)) {
memcpy(&local, addr, len);
if (addr->sa_family == AF_INET) {
((struct sockaddr_in *)&local)->sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr = (struct sockaddr *)&local;
} else if (addr->sa_family == AF_INET6) {
((struct sockaddr_in6 *)&local)->sin6_addr = in6addr_loopback;
addr = (struct sockaddr *)&local;
}
}
int ret = real(fd, addr, len);
if (ret == 0) record_port(fd, 0);
return ret;
@@ -167,20 +135,22 @@ static unsigned local_port(int fd) {
}
static void bind_before_loopback_connect(int fd, const struct sockaddr *addr, socklen_t len) {
if (!loopback_family(addr, len)) return;
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
if (getsockname(fd, (struct sockaddr *)&ss, &slen) != 0 || ss.ss_family != addr->sa_family ||
addr_port((struct sockaddr *)&ss, slen) != 0) {
if (!addr || addr->sa_family != AF_INET || len < sizeof(struct sockaddr_in) ||
((const struct sockaddr_in *)addr)->sin_addr.s_addr != htonl(INADDR_LOOPBACK)) {
return;
}
struct sockaddr_storage local;
if (len > sizeof(local)) return;
memcpy(&local, addr, len);
if (addr->sa_family == AF_INET) ((struct sockaddr_in *)&local)->sin_port = 0;
else ((struct sockaddr_in6 *)&local)->sin6_port = 0;
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
if (getsockname(fd, (struct sockaddr *)&ss, &slen) != 0 || ss.ss_family != AF_INET ||
((struct sockaddr_in *)&ss)->sin_port != 0) {
return;
}
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int saved = errno;
bind(fd, (struct sockaddr *)&local, len);
bind(fd, (struct sockaddr *)&local, sizeof(local));
errno = saved;
}
@@ -195,11 +165,6 @@ static unsigned guarded_port(void) {
}
static int guest_peer(int fd) {
struct sockaddr_storage address;
socklen_t length = sizeof(address);
if (getpeername(fd, (struct sockaddr *)&address, &length) != 0) return 0;
int family = loopback_family((struct sockaddr *)&address, length);
if (!family) return 0;
unsigned peer = peer_port(fd);
if (peer == 0) {
return 0;
@@ -214,34 +179,26 @@ static int guest_peer(int fd) {
if (in < 0) {
return 0;
}
char buf[128];
char buf[64];
int n = read(in, buf, sizeof(buf) - 1);
close(in);
if (n <= 0) {
return 0;
}
buf[n] = '\0';
int pid, parent, uid, descriptor, recorded_family;
unsigned destination;
unsigned long long inode;
if (sscanf(buf, "%d %d %d %u %d %llu %d", &pid, &parent, &uid, &destination, &descriptor,
&inode, &recorded_family) != 7 || pid <= 0 || descriptor < 0 || destination != guarded_port() ||
recorded_family != family) return 0;
snprintf(path, sizeof(path), "/proc/%d/fd/%d", pid, descriptor);
return stat(path, &st) == 0 && S_ISSOCK(st.st_mode) && (unsigned long long)st.st_ino == inode;
int pid = atoi(buf);
return pid > 0 && (kill(pid, 0) == 0 || errno != ESRCH);
}
int connect(int fd, const struct sockaddr *addr, socklen_t len) {
static connect_fn real;
if (!real) real = (connect_fn)dlsym(RTLD_NEXT, "connect");
bind_before_loopback_connect(fd, addr, len);
unsigned peer = loopback_family(addr, len) ? addr_port(addr, len) : 0;
if (peer) record_port(fd, peer);
int ret = real(fd, addr, len);
if (ret == 0 || errno == EINPROGRESS) {
/* The caller goes on to test for EINPROGRESS, which the bookkeeping must not disturb. */
int saved = errno;
record_port(fd, peer);
record_port(fd, addr_port(addr, len));
errno = saved;
}
return ret;
+5
View File
@@ -1237,9 +1237,14 @@ static void ns_received(struct msghdr *msg) {
}
}
int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags, ssize_t (*real_recvmsg)(int, struct msghdr *, int),
ssize_t *result) __attribute__((visibility("hidden")));
ssize_t recvmsg(int fd, struct msghdr *msg, int flags) {
static ssize_t (*real)(int, struct msghdr *, int);
if (!real) real = (ssize_t (*)(int, struct msghdr *, int))dlsym(RTLD_NEXT, "recvmsg");
ssize_t answered;
if (msg && bl_udevmon_recvmsg(fd, msg, flags, real, &answered)) return answered;
ssize_t r = real(fd, msg, flags);
if (r >= 0 && msg && msg->msg_control && msg->msg_controllen && ns_on()) {
int saved = errno;
+4 -16
View File
@@ -36,20 +36,6 @@
int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden")));
int bl_writable_retry(int dirfd, const char *path, int flags) __attribute__((visibility("hidden")));
int bl_ntsync_open(const char *path, int flags) __attribute__((visibility("hidden")));
int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode,
int (*real)(int, const char *, int, mode_t)) __attribute__((visibility("hidden")));
static int (*real_open)(const char *, int, ...);
static int (*real_openat)(int, const char *, int, ...);
static int via_open(int dirfd, const char *path, int flags, mode_t mode) {
(void)dirfd;
return real_open(path, flags, mode);
}
static int via_openat(int dirfd, const char *path, int flags, mode_t mode) {
return real_openat(dirfd, path, flags, mode);
}
static int retry_without_noatime(int fd, int flags) {
return fd < 0 && errno == EPERM && (flags & O_NOATIME);
@@ -63,6 +49,7 @@ static int is_trace_marker(const char *path) {
}
int open(const char *path, int flags, ...) {
static int (*real_open)(const char *, int, ...);
va_list ap;
mode_t mode;
int fd;
@@ -81,7 +68,7 @@ int open(const char *path, int flags, ...) {
errno = ENOSYS;
return -1;
}
fd = bl_dircache_create(AT_FDCWD, path, flags, mode, via_open);
fd = real_open(path, flags, mode);
if (retry_without_noatime(fd, flags)) fd = real_open(path, flags & ~O_NOATIME, mode);
/* A read-only file the Steam client rewrites (perms.c). */
if (fd < 0 && bl_writable_retry(AT_FDCWD, path, flags)) fd = real_open(path, flags & ~O_NOATIME, mode);
@@ -89,6 +76,7 @@ int open(const char *path, int flags, ...) {
}
int openat(int dirfd, const char *path, int flags, ...) {
static int (*real_openat)(int, const char *, int, ...);
va_list ap;
mode_t mode;
int fd;
@@ -107,7 +95,7 @@ int openat(int dirfd, const char *path, int flags, ...) {
errno = ENOSYS;
return -1;
}
fd = bl_dircache_create(dirfd, path, flags, mode, via_openat);
fd = real_openat(dirfd, path, flags, mode);
if (retry_without_noatime(fd, flags)) fd = real_openat(dirfd, path, flags & ~O_NOATIME, mode);
if (fd < 0 && bl_writable_retry(dirfd, path, flags)) fd = real_openat(dirfd, path, flags & ~O_NOATIME, mode);
return fd;
+184
View File
@@ -32,6 +32,10 @@
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dirent.h>
#include <endian.h>
#include <stdio.h>
#include <time.h>
struct stand_in {
int fd; /* the descriptor handed out, -1 when the slot is free */
@@ -95,8 +99,11 @@ static int is_wine_process(void) {
return known == 1;
}
static void start_injector(void);
static int make_stand_in(int type) {
int sv[2];
start_injector();
int saved = errno;
int flags = SOCK_DGRAM | (type & (SOCK_CLOEXEC | SOCK_NONBLOCK));
if (socketpair(AF_UNIX, flags, 0, sv) != 0) {
@@ -189,3 +196,180 @@ int setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt
}
return real_setsockopt(fd, level, optname, optval, optlen);
}
/* EXPERIMENT (docs/development/steam-touch-controller.md): hotplug through the stand-in.
*
* A file dropped in INJECT_DIR is a udev event: KEY=VALUE lines (ACTION, DEVPATH, SUBSYSTEM,
* DEVNAME, MAJOR, MINOR...). It is sent to every stand-in as udevd would send it to the "udev"
* group, and recvmsg below dresses it as udevd's: the udev multicast group as the sender address
* and root credentials, without which libudev drops a message. SDL's HID discovery counts an
* add or remove and enumerates again, so a hidraw node can come and go while the client runs. */
#define INJECT_DIR "/tmp/udev-inject"
struct monitor_netlink_header {
char prefix[8];
unsigned magic;
unsigned header_size;
unsigned properties_off;
unsigned properties_len;
unsigned filter_subsystem_hash;
unsigned filter_devtype_hash;
unsigned filter_tag_bloom_hi;
unsigned filter_tag_bloom_lo;
};
static unsigned murmur_hash2(const char *key, size_t len) {
/* systemd's string_hash32 (MurmurHash2, seed 0) */
const unsigned m = 0x5bd1e995;
const int r = 24;
unsigned h = (unsigned)len;
const unsigned char *data = (const unsigned char *)key;
while (len >= 4) {
unsigned k;
memcpy(&k, data, 4);
k *= m; k ^= k >> r; k *= m;
h *= m; h ^= k;
data += 4; len -= 4;
}
switch (len) {
case 3: h ^= data[2] << 16; /* fallthrough */
case 2: h ^= data[1] << 8; /* fallthrough */
case 1: h ^= data[0]; h *= m;
}
h ^= h >> 13; h *= m; h ^= h >> 15;
return h;
}
static void inject_file(const char *path, unsigned long long seqnum) {
char text[2048];
FILE *f = fopen(path, "re");
if (f == NULL) return;
size_t n = fread(text, 1, sizeof(text) - 1, f);
fclose(f);
text[n] = '\0';
char props[2300];
size_t used = 0;
char subsystem[64] = "";
char *save = NULL;
for (char *line = strtok_r(text, "\n", &save); line != NULL; line = strtok_r(NULL, "\n", &save)) {
size_t len = strlen(line);
if (len == 0 || used + len + 1 > sizeof(props) - 64) continue;
if (strncmp(line, "SUBSYSTEM=", 10) == 0) snprintf(subsystem, sizeof(subsystem), "%s", line + 10);
memcpy(props + used, line, len + 1);
used += len + 1;
}
used += (size_t)snprintf(props + used, sizeof(props) - used, "SEQNUM=%llu", seqnum) + 1;
struct monitor_netlink_header header;
memset(&header, 0, sizeof(header));
memcpy(header.prefix, "libudev", 8);
header.magic = htobe32(0xfeedcafe);
header.header_size = sizeof(header);
header.properties_off = sizeof(header);
header.properties_len = (unsigned)used;
header.filter_subsystem_hash = htobe32(murmur_hash2(subsystem, strlen(subsystem)));
char message[sizeof(header) + sizeof(props)];
memcpy(message, &header, sizeof(header));
memcpy(message + sizeof(header), props, used);
int sent = 0;
pthread_mutex_lock(&lock);
for (size_t i = 0; i < sizeof(stand_ins) / sizeof(stand_ins[0]); i++) {
if (stand_ins[i].fd == -1) continue;
if (send(stand_ins[i].peer, message, sizeof(header) + used, MSG_DONTWAIT | MSG_NOSIGNAL) > 0) sent++;
}
pthread_mutex_unlock(&lock);
FILE *done = fopen(INJECT_DIR "/.log", "ae");
if (done) {
char exe[PATH_MAX] = "";
ssize_t len = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
if (len > 0) exe[len] = '\0';
fprintf(done, "%s: pid %d (%s) sent to %d stand-in(s)\n", path, (int)getpid(), exe, sent);
fclose(done);
}
}
static void *injector(void *arg) {
(void)arg;
unsigned long long seqnum = 900000;
/* Every process with a stand-in sends each event file once (the client is one of several), so
* files are never claimed; whoever sees one older than a few seconds removes it. */
static char seen[32][128];
size_t next_seen = 0;
for (;;) {
DIR *dir = opendir(INJECT_DIR);
if (dir != NULL) {
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (entry->d_name[0] == '.') continue;
char path[300];
snprintf(path, sizeof(path), INJECT_DIR "/%s", entry->d_name);
struct stat st;
if (stat(path, &st) != 0) continue;
if (time(NULL) - st.st_mtime > 5) {
unlink(path);
continue;
}
char key[128];
snprintf(key, sizeof(key), "%s:%lu", entry->d_name, (unsigned long)st.st_ino);
int done = 0;
for (size_t i = 0; i < 32; i++)
if (strcmp(seen[i], key) == 0) done = 1;
if (done) continue;
snprintf(seen[next_seen++ % 32], sizeof(seen[0]), "%s", key);
inject_file(path, ++seqnum);
}
closedir(dir);
}
struct timespec interval = {0, 100 * 1000 * 1000};
nanosleep(&interval, NULL);
}
return NULL;
}
static void spawn_injector(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, injector, NULL) == 0) pthread_detach(thread);
}
static void start_injector(void) {
static pthread_once_t once = PTHREAD_ONCE_INIT;
pthread_once(&once, spawn_injector);
}
/* For ntsync.c's recvmsg(), which owns the symbol: 1 when fd was a stand-in and was answered here
* (*result holds what recvmsg returns), 0 when it is not one. */
__attribute__((visibility("hidden"))) int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags,
ssize_t (*real_recvmsg)(int, struct msghdr *, int),
ssize_t *result) {
if (!is_stand_in(fd)) return 0;
size_t control_room = msg->msg_controllen;
socklen_t name_room = msg->msg_namelen;
ssize_t n = real_recvmsg(fd, msg, flags);
*result = n;
if (n >= 0) {
FILE *log = fopen(INJECT_DIR "/.recv", "ae");
if (log) {
fprintf(log, "pid %d fd %d got %zd bytes, name room %u, control room %zu\n", (int)getpid(), fd, n,
(unsigned)name_room, control_room);
fclose(log);
}
}
if (n < 0) return 1;
if (msg->msg_name != NULL && name_room >= sizeof(struct sockaddr_nl)) {
struct sockaddr_nl nl;
memset(&nl, 0, sizeof(nl));
nl.nl_family = AF_NETLINK;
nl.nl_groups = 2; /* udev's multicast group */
memcpy(msg->msg_name, &nl, sizeof(nl));
msg->msg_namelen = sizeof(nl);
}
if (msg->msg_control != NULL && control_room >= CMSG_SPACE(sizeof(struct ucred))) {
struct cmsghdr *cmsg = (struct cmsghdr *)msg->msg_control;
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_CREDENTIALS;
cmsg->cmsg_len = CMSG_LEN(sizeof(struct ucred));
struct ucred cred = {.pid = 1, .uid = 0, .gid = 0};
memcpy(CMSG_DATA(cmsg), &cred, sizeof(cred));
msg->msg_controllen = CMSG_SPACE(sizeof(struct ucred));
}
return 1;
}
-190
View File
@@ -1,190 +0,0 @@
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
REPORT = "<!-- droiddeck-pr-release -->"
STATE = re.compile(r"\n<!-- droiddeck-discord: (\{[^\n]*\}) -->")
def github(path, method="GET", body=None):
args = ["gh", "api", path, "--method", method]
if body is not None:
args += ["--input", "-"]
result = subprocess.run(args, input=json.dumps(body) if body is not None else None,
capture_output=True, text=True)
if result.returncode:
raise RuntimeError("GitHub request failed")
return json.loads(result.stdout) if result.stdout.strip() else None
def report_comment(repo, number):
page = 1
while True:
comments = github(f"repos/{repo}/issues/{number}/comments?per_page=100&page={page}")
for comment in comments:
if (comment.get("user", {}).get("login") == "github-actions[bot]"
and (comment.get("body") or "").startswith(REPORT)):
return comment
if len(comments) < 100:
return None
page += 1
def saved_message(body, webhook_id):
match = STATE.search(body)
if not match:
return None
try:
state = json.loads(match.group(1))
except ValueError:
return None
if (state.get("webhook_id") == webhook_id
and re.fullmatch(r"[0-9]{15,22}", str(state.get("message_id", "")))):
return state["message_id"]
return None
class Discord:
def __init__(self, webhook):
match = re.fullmatch(r"https://discord\.com/api(?:/v[0-9]+)?/webhooks/([0-9]{15,22})/([A-Za-z0-9_-]+)", webhook)
if not match:
raise RuntimeError("Invalid Discord webhook configuration")
self.url = webhook
self.id = match.group(1)
def request(self, method, suffix="", body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(self.url + suffix, data=data,
headers={"Content-Type": "application/json",
"User-Agent": "DroidDeck-PR-builds"}, method=method)
for attempt in range(3):
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
if method != "POST" and error.code in (429, 500, 502, 503, 504) and attempt < 2:
time.sleep(2 ** attempt)
continue
raise RuntimeError(f"Discord request failed (HTTP {error.code})") from None
except (OSError, ValueError):
raise RuntimeError("Discord request failed") from None
def plain_title(title):
title = " ".join(title.split())[:300]
return re.sub(r"([\\`*_~|\[\]<>])", r"\\\1", title)
def description_excerpt(body):
body = re.sub(r"<!--.*?-->", "", body or "", flags=re.DOTALL)
body = re.sub(r"```.*?```", "", body, flags=re.DOTALL)
for paragraph in re.split(r"\n\s*\n", body):
lines = [line.strip() for line in paragraph.splitlines()]
lines = [line for line in lines if line and not re.match(r"^(#{1,6}\s|[-*]\s+\[[ xX]\]|🤖)", line)]
text = " ".join(lines)
if not text or text.strip("*_` :.").lower() in (
"summary", "description", "checklist", "testing", "n/a", "none",
"please describe your changes here", "describe your changes"):
continue
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
if len(text) > 300:
text = text[:297].rsplit(" ", 1)[0] + "..."
return plain_title(text)
return ""
def build_content(repo, number, title, sha, apk, release, description=None):
excerpt = description_excerpt(description)
return (f"**Signed PR test build** `pr-{number}` · `{sha[:7]}`\n"
f"[#{number}](https://github.com/{repo}/pull/{number}): {plain_title(title)}\n"
+ (f"\n{excerpt}\n\n" if excerpt else "") +
"_Not merged — for testing._\n"
f"Download: [standard APK](<{apk}>) · [all variants](<{release}>)")
def retired_content(content, merged):
label = "Merged" if merged else "Closed"
if content.startswith(("**Merged** —", "**Closed** —")):
content = "\n".join(line[2:-2] if line.startswith("~~") and line.endswith("~~") else line
for line in content.splitlines()[2:])
struck = "\n".join(f"~~{line}~~" if line else "" for line in content.splitlines())
return f"**{label}** — this PR test build is no longer available.\n\n{struck}"
def sync(mode, repo, number, discord, sha="", apk="", release=""):
pull = github(f"repos/{repo}/pulls/{number}")
if mode == "publish":
if (pull["state"] != "open" or pull.get("draft") or pull["base"]["ref"] != "main"
or pull["head"]["sha"] != sha):
print("PR changed; no Discord announcement published")
return
elif pull["state"] != "closed":
print("PR is open; Discord announcement retained")
return
comment = report_comment(repo, number)
if comment is None:
if mode == "retire":
return
raise RuntimeError("Signed build report comment is missing")
message_id = saved_message(comment["body"], discord.id)
if mode == "retire":
if message_id:
message = discord.request("GET", f"/messages/{message_id}")
if message:
discord.request("PATCH", f"/messages/{message_id}",
{"content": retired_content(message["content"], pull.get("merged", False)),
"allowed_mentions": {"parse": []}, "flags": 4})
return
content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body"))
if len(content) > 1800:
raise RuntimeError("Discord announcement is too long")
payload = {"content": content, "allowed_mentions": {"parse": []}, "flags": 4}
message = discord.request("PATCH", f"/messages/{message_id}", payload) if message_id else None
if message is None:
message = discord.request("POST", "?wait=true", payload)
if message is None:
raise RuntimeError("Discord webhook no longer exists")
state = {"webhook_id": discord.id, "message_id": message["id"]}
latest = github(f"repos/{repo}/issues/comments/{comment['id']}")
body = STATE.sub("", latest["body"]).rstrip() + f"\n<!-- droiddeck-discord: {json.dumps(state)} -->"
github(f"repos/{repo}/issues/comments/{comment['id']}", "PATCH", {"body": body})
print("Discord PR announcement updated")
def main():
webhook = os.environ.get("WEBHOOK", "")
if not webhook:
print("::notice::No DISCORD_PR_BUILDS_WEBHOOK secret; nothing posted")
return
repo = os.environ["GITHUB_REPOSITORY"]
number = os.environ["PR_NUMBER"]
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) or not re.fullmatch(r"[1-9][0-9]*", number):
raise RuntimeError("Invalid PR configuration")
mode = sys.argv[1]
if mode not in ("publish", "retire"):
raise RuntimeError("Invalid announcement mode")
sha, apk, release = (os.environ.get(key, "") for key in ("HEAD_SHA", "APK", "RELEASE"))
if mode == "publish":
prefix = f"https://github.com/Droid-Deck/DroidDeck-CI/releases/"
if (not re.fullmatch(r"[0-9a-f]{40}", sha)
or not apk.startswith(prefix + f"download/pr-{number}/")
or release != prefix + f"tag/pr-{number}"
or re.search(r"[\s<>]", apk)):
raise RuntimeError("Invalid published build configuration")
sync(mode, repo, number, Discord(webhook), sha, apk, release)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"::error::{error}" if isinstance(error, RuntimeError) else "::error::Discord announcement failed", file=sys.stderr)
sys.exit(1)
@@ -1,83 +0,0 @@
{
"name": "skyrim-launcher-focus",
"vars": {
"appId": 489830
},
"repeat": 6,
"setup": [
[
"wake"
]
],
"before": [
[
"start",
"steam",
"--wait",
"--reuse",
"--timeout",
"240"
]
],
"steps": [
[
"launch",
"${appId}",
"--wait",
"--timeout",
"240"
],
{
"sleep": 8
},
[
"input",
"tap",
"0.927",
"0.111",
"--space",
"fraction"
],
{
"sleep": 30
},
[
"wait",
"focused",
"${appId}",
"--timeout",
"60"
],
{
"screenshot": "in-game"
},
[
"quit",
"${appId}",
"--timeout",
"20"
],
{
"assert": "remaining",
"equals": []
},
{
"sleep": 5
}
],
"after": [
[
"quit",
"${appId}",
"--timeout",
"20"
]
],
"teardown": [
[
"stop",
"--timeout",
"120"
]
]
}
-70
View File
@@ -1,70 +0,0 @@
{
"name": "steam-game-focus",
"vars": {
"appId": 489830
},
"repeat": 6,
"setup": [
[
"wake"
]
],
"before": [
[
"start",
"steam",
"--wait",
"--reuse",
"--timeout",
"240"
]
],
"steps": [
[
"launch",
"${appId}",
"--wait",
"--timeout",
"240"
],
{
"assert": "focus.focusedApp",
"equals": "${appId}"
},
{
"assert": "focus.focusableApps",
"contains": "${appId}"
},
{
"screenshot": "focused"
},
[
"quit",
"${appId}",
"--timeout",
"20"
],
{
"assert": "remaining",
"equals": []
},
{
"sleep": 5
}
],
"after": [
[
"quit",
"${appId}",
"--timeout",
"20"
]
],
"teardown": [
[
"stop",
"--timeout",
"120"
]
]
}
-118
View File
@@ -1,118 +0,0 @@
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import unittest
SOURCE = Path(__file__).resolve().parents[1] / "linuxfs/preload/net.c"
@unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("cc"), "needs Linux and a C compiler")
class CdpGuardTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.build = tempfile.TemporaryDirectory()
cls.library = Path(cls.build.name) / "net.so"
subprocess.run(["cc", "-shared", "-fPIC", "-O2", str(SOURCE), "-x", "c", "-", "-ldl", "-o", str(cls.library)],
input=b"int bl_udevmon_stand_in(int fd) { return 0; }", check=True, capture_output=True)
@classmethod
def tearDownClass(cls):
cls.build.cleanup()
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.registry = Path(self.temp.name) / "net"
self.registry.mkdir()
def port(self, ipv6=False):
with socket.socket(socket.AF_INET6 if ipv6 else socket.AF_INET) as s:
s.bind(("::1" if ipv6 else "127.0.0.1", 0))
return s.getsockname()[1]
def env(self, port):
return dict(os.environ, LD_PRELOAD=str(self.library), BL_CDP_GUARD=str(port), BL_SYSV_DIR=self.temp.name)
def test_guarded_ipv4_bind_is_loopback_only(self):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); print(s.getsockname()[0])" % port
result = subprocess.run([sys.executable, "-c", program], env=self.env(port), check=True, capture_output=True, text=True)
self.assertEqual("127.0.0.1", result.stdout.strip())
def test_guarded_ipv6_bind_is_loopback_only(self):
if not socket.has_ipv6:
self.skipTest("needs IPv6")
port = self.port(True)
program = "import socket; s=socket.socket(socket.AF_INET6); s.bind(('::', %d)); print(s.getsockname()[0])" % port
result = subprocess.run([sys.executable, "-c", program], env=self.env(port), check=True, capture_output=True, text=True)
self.assertEqual("::1", result.stdout.strip())
def test_other_listeners_are_unchanged(self):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); print(s.getsockname()[0])" % port
result = subprocess.run([sys.executable, "-c", program], env=self.env(0), check=True, capture_output=True, text=True)
self.assertEqual("0.0.0.0", result.stdout.strip())
def connect(self, address, registered, stale=False, wrong_destination=False):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); s.listen(); print('ready', flush=True); c,_=s.accept(); c.sendall(b'accepted')" % port
server = subprocess.Popen([sys.executable, "-u", "-c", program], env=self.env(port), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
self.assertEqual(b"ready\n", server.stdout.readline())
with socket.socket() as client:
client.settimeout(3)
client.bind((address, 0))
if registered:
peer = client.getsockname()[1]
inode = os.fstat(client.fileno()).st_ino + (1 if stale else 0)
destination = port + (1 if wrong_destination else 0)
(self.registry / ("p%d" % peer)).write_text("%d 1 %d %d %d %d %d\n" %
(os.getpid(), os.getuid(), destination, client.fileno(), inode, socket.AF_INET))
client.connect(("127.0.0.1", port))
try:
return client.recv(8)
except ConnectionResetError:
return b""
finally:
server.kill()
server.wait()
server.stdout.close()
server.stderr.close()
def test_registered_guest_loopback_peer_is_accepted(self):
self.assertEqual(b"accepted", self.connect("127.0.0.1", True))
def test_unregistered_local_peer_is_refused(self):
self.assertEqual(b"", self.connect("127.0.0.1", False))
def test_matching_port_record_cannot_authorize_another_address(self):
self.assertEqual(b"", self.connect("127.0.0.2", True))
def test_stale_socket_record_does_not_authorize_a_reused_port(self):
self.assertEqual(b"", self.connect("127.0.0.1", True, stale=True))
def test_record_for_another_destination_does_not_authorize_cdp(self):
self.assertEqual(b"", self.connect("127.0.0.1", True, wrong_destination=True))
def test_preloaded_guest_connect_still_works(self):
port = self.port()
program = "import socket; s=socket.socket(); s.bind(('0.0.0.0', %d)); s.listen(); print('ready', flush=True); c,_=s.accept(); c.sendall(b'accepted')" % port
server = subprocess.Popen([sys.executable, "-u", "-c", program], env=self.env(port), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
self.assertEqual(b"ready\n", server.stdout.readline())
client = "import socket; s=socket.create_connection(('127.0.0.1', %d), timeout=3); print(s.recv(8).decode())" % port
result = subprocess.run([sys.executable, "-c", client], env=self.env(port), check=True, capture_output=True, text=True, timeout=5)
self.assertEqual("accepted", result.stdout.strip())
finally:
server.kill()
server.wait()
server.stdout.close()
server.stderr.close()
if __name__ == "__main__":
unittest.main()
-410
View File
@@ -1,410 +0,0 @@
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
FAKE = r"""
#define _GNU_SOURCE
#include <dirent.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/statfs.h>
#include <unistd.h>
int real_opendirs;
/* COARSE=1: directory stats that never change, like NTFS's whole-second times and fixed size. */
static void coarse(const char *path, struct stat *st) {
if (getenv("COARSE") && strstr(path, "/fuse/") && S_ISDIR(st->st_mode)) {
st->st_mtim = st->st_ctim = (struct timespec){1, 0};
st->st_size = 4096;
st->st_nlink = 1;
}
}
int stat(const char *path, struct stat *st) {
static int (*next)(const char *, struct stat *);
if (!next) next = dlsym(RTLD_NEXT, "stat");
int r = next(path, st);
if (r == 0) coarse(path, st);
return r;
}
int fstat(int fd, struct stat *st) {
static int (*next)(int, struct stat *);
if (!next) next = dlsym(RTLD_NEXT, "fstat");
char link[64], path[4096];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
ssize_t n = readlink(link, path, sizeof(path) - 1);
path[n > 0 ? n : 0] = 0;
int r = next(fd, st);
if (r == 0) coarse(path, st);
return r;
}
int fstatfs(int fd, struct statfs *out) {
static int (*next)(int, struct statfs *);
if (!next) next = dlsym(RTLD_NEXT, "fstatfs");
char link[64], path[4096];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
ssize_t n = readlink(link, path, sizeof(path) - 1);
path[n > 0 ? n : 0] = 0;
int r = next(fd, out);
if (r == 0 && strstr(path, "/fuse/")) out->f_type = 0x65735546;
return r;
}
int statfs(const char *path, struct statfs *out) {
static int (*next)(const char *, struct statfs *);
if (!next) next = dlsym(RTLD_NEXT, "statfs");
int r = next(path, out);
if (r == 0 && strstr(path, "/fuse/")) out->f_type = 0x65735546;
return r;
}
/* FASTPATH=1: open directories the way the fast-path preload does, through fdopendir. */
DIR *opendir(const char *path) {
static DIR *(*next)(const char *);
if (!next) next = dlsym(RTLD_NEXT, "opendir");
if (getenv("FASTPATH")) {
int fd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NONBLOCK);
DIR *d = fd < 0 ? NULL : fdopendir(fd);
if (d == NULL && fd >= 0) close(fd);
return d;
}
real_opendirs++;
return next(path);
}
DIR *fdopendir(int fd) {
static DIR *(*next)(int);
if (!next) next = dlsym(RTLD_NEXT, "fdopendir");
real_opendirs++;
return next(fd);
}
"""
PROGRAM = r"""
#define _GNU_SOURCE
#include <dirent.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
static int by_name(const void *a, const void *b) { return strcmp(*(char *const *)a, *(char *const *)b); }
static void print(char **names, int n) {
qsort(names, n, sizeof(*names), by_name);
for (int i = 0; i < n; i++) printf("%s%s", i ? "," : "", names[i]);
printf("\n");
}
static void list(const char *dir) {
char *names[256];
int n = 0;
DIR *d = opendir(dir);
struct dirent *e;
while (d && (e = readdir(d)))
if (strcmp(e->d_name, ".") && strcmp(e->d_name, "..")) names[n++] = strdup(e->d_name);
if (d) closedir(d);
print(names, n);
}
static void raw(const char *dir) {
char buf[8192], *names[256];
int n = 0, fd = open(dir, O_RDONLY | O_DIRECTORY);
long k;
while ((k = syscall(SYS_getdents64, fd, buf, sizeof(buf))) > 0) {
for (long off = 0; off < k;) {
struct dirent64 *e = (struct dirent64 *)(buf + off);
if (strcmp(e->d_name, ".") && strcmp(e->d_name, "..")) names[n++] = strdup(e->d_name);
off += e->d_reclen;
}
}
close(fd);
print(names, n);
}
static const char *stress_dir;
static int stop;
static void *reader(void *arg) {
(void)arg;
while (!__atomic_load_n(&stop, __ATOMIC_ACQUIRE)) {
DIR *d = opendir(stress_dir);
struct dirent *e;
while (d && (e = readdir(d)))
if (e->d_reclen == 0) abort();
if (d) closedir(d);
}
return NULL;
}
static void stress(const char *dir) {
pthread_t t[4];
char a[4096], b[4096];
stress_dir = dir;
for (int i = 0; i < 4; i++) pthread_create(&t[i], NULL, reader, NULL);
for (int i = 0; i < 300; i++) {
snprintf(a, sizeof(a), "%s/s%d", dir, i);
snprintf(b, sizeof(b), "%s/t%d", dir, i);
close(open(a, O_WRONLY | O_CREAT, 0644));
rename(a, b);
if (i % 3 == 0) unlink(b);
}
__atomic_store_n(&stop, 1, __ATOMIC_RELEASE);
for (int i = 0; i < 4; i++) pthread_join(t[i], NULL);
}
int main(int argc, char **argv) {
int *opens = dlsym(RTLD_DEFAULT, "real_opendirs");
for (int i = 1; i < argc; i++) {
const char *op = argv[i], *a = argv[i + 1];
if (!strcmp(op, "list")) { list(a); i++; }
else if (!strcmp(op, "raw")) { raw(a); i++; }
else if (!strcmp(op, "create")) { close(open(a, O_WRONLY | O_CREAT, 0644)); i++; }
else if (!strcmp(op, "sneak")) { close((int)syscall(SYS_openat, AT_FDCWD, a, O_WRONLY | O_CREAT, 0644)); i++; }
else if (!strcmp(op, "sneak-unlink")) { syscall(SYS_unlinkat, AT_FDCWD, a, 0); i++; }
else if (!strcmp(op, "unlink")) { unlink(a); i++; }
else if (!strcmp(op, "remove")) { remove(a); i++; }
else if (!strcmp(op, "mkdir")) { mkdir(a, 0755); i++; }
else if (!strcmp(op, "rmdir")) { rmdir(a); i++; }
else if (!strcmp(op, "rename")) { rename(a, argv[i + 2]); i += 2; }
else if (!strcmp(op, "half")) {
DIR *d = opendir(a);
readdir(d);
closedir(d);
i++;
}
else if (!strcmp(op, "rewind")) {
DIR *d = opendir(a);
int n = 0, m = 0;
while (readdir(d)) n++;
rewinddir(d);
while (readdir(d)) m++;
struct stat st;
printf("%d %d %d\n", n, m, fstat(dirfd(d), &st) == 0 && S_ISDIR(st.st_mode));
closedir(d);
i++;
}
else if (!strcmp(op, "seek")) {
DIR *d = opendir(a);
readdir(d);
readdir(d);
long at = telldir(d);
char want[256];
snprintf(want, sizeof(want), "%s", readdir(d)->d_name);
readdir(d);
seekdir(d, at);
int back = strcmp(readdir(d)->d_name, want) == 0;
seekdir(d, at + 1);
struct dirent *e = readdir(d);
printf("%d %d\n", back, e != NULL && e->d_reclen > 0);
closedir(d);
i++;
}
else if (!strcmp(op, "stress")) { stress(a); i++; }
else if (!strcmp(op, "drain")) {
DIR *d = opendir(a);
int rounds = 0, n;
do {
struct dirent *e;
n = 0;
while ((e = readdir(d)))
if (strcmp(e->d_name, ".") && strcmp(e->d_name, "..") && unlinkat(dirfd(d), e->d_name, 0) == 0) n++;
rewinddir(d);
} while (n > 0 && ++rounds < 50);
closedir(d);
printf("%d\n", rounds);
i++;
}
else if (!strcmp(op, "fork")) {
pid_t child = fork();
if (child == 0) {
DIR *d = opendir(a);
while (d && readdir(d)) {}
if (d) closedir(d);
close((int)syscall(SYS_openat, AT_FDCWD, argv[i + 2], O_WRONLY | O_CREAT, 0644));
DIR *e = opendir(a);
while (e && readdir(e)) {}
if (e) closedir(e);
_exit(0);
}
waitpid(child, NULL, 0);
i += 2;
}
else if (!strcmp(op, "opens")) printf("%d\n", opens ? *opens : -1);
}
return 0;
}
"""
@unittest.skipUnless(shutil.which("cc"), "needs a C compiler")
class DirCachePreloadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.dir = Path(tempfile.mkdtemp())
build = cls.dir / "build"
build.mkdir()
(build / "fake.c").write_text(FAKE)
(build / "program.c").write_text(PROGRAM)
cls.cache = build / "libblsession.so"
cls.fake = build / "fake.so"
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(cls.cache),
*map(str, sorted(PRELOAD.glob("*.c"))), "-ldl"], check=True)
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.fake), str(build / "fake.c"), "-ldl"], check=True)
cls.steam = cls.dir / "steamrtarm64/steam"
cls.steam.parent.mkdir()
subprocess.run(["cc", "-O2", "-pthread", "-o", str(cls.steam), str(build / "program.c"), "-ldl"], check=True)
cls.other = build / "steamwebhelper"
shutil.copy(cls.steam, cls.other)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.dir, True)
def setUp(self):
self.lib = Path(tempfile.mkdtemp(dir=self.dir)) / "fuse/steamapps"
self.staging = self.lib / "downloading/4992280"
self.staging.mkdir(parents=True)
for name in ("a.bundle", "b.bundle"):
(self.staging / name).touch()
def run_program(self, *args, program=None, env=None):
environment = dict(os.environ, LD_PRELOAD="%s:%s" % (self.cache, self.fake))
environment.update(env or {})
result = subprocess.run([str(program or self.steam), *map(str, args)], env=environment,
capture_output=True, text=True, check=True, timeout=60)
return result.stdout.splitlines()
def test_repeated_listings_read_the_directory_once(self):
out = self.run_program("list", self.staging, "list", self.staging, "list", self.staging, "opens")
self.assertEqual(["a.bundle,b.bundle"] * 3 + ["1"], out)
def test_own_changes_keep_the_listing_exact(self):
s = self.staging
out = self.run_program(
"list", s,
"create", s / "c.bundle", "list", s, "raw", s,
"create", s / "a.bundle", "list", s, "raw", s,
"mkdir", s / "sub", "list", s, "raw", s,
"rename", s / "c.bundle", s / "d.bundle", "list", s, "raw", s,
"unlink", s / "a.bundle", "list", s, "raw", s,
"rmdir", s / "sub", "list", s, "raw", s,
"remove", s / "b.bundle", "list", s, "raw", s,
"opens")
self.assertEqual(out[2:15:2], out[1:15:2])
self.assertEqual("d.bundle", out[-2])
self.assertEqual("1", out[-1])
def test_moves_between_directories_update_both(self):
common = self.lib / "common/Game"
common.mkdir(parents=True)
s = self.staging
out = self.run_program("list", s, "list", common,
"rename", s / "a.bundle", common / "a.bundle",
"list", s, "raw", s, "list", common, "raw", common, "opens")
self.assertEqual(["b.bundle", "b.bundle", "a.bundle", "a.bundle", "2"], out[2:])
def test_changes_made_around_the_cache_are_noticed(self):
s = self.staging
out = self.run_program("list", s, "sneak", s / "x.bundle", "list", s,
"sneak-unlink", s / "a.bundle", "list", s, "opens")
self.assertEqual(["a.bundle,b.bundle", "a.bundle,b.bundle,x.bundle", "b.bundle,x.bundle", "3"], out)
def test_changes_are_noticed_when_directory_times_are_coarse(self):
s = self.staging
out = self.run_program("list", s, "sneak", s / "x.bundle", "list", s,
"sneak-unlink", s / "a.bundle", "list", s,
"create", s / "c.bundle", "list", s, "raw", s, env={"COARSE": "1"})
self.assertEqual(["a.bundle,b.bundle", "a.bundle,b.bundle,x.bundle", "b.bundle,x.bundle"], out[:3])
self.assertEqual(out[4], out[3])
self.assertEqual("b.bundle,c.bundle,x.bundle", out[3])
def test_own_changes_stay_cached_when_directory_times_are_coarse(self):
s = self.staging
out = self.run_program("list", s, "create", s / "c.bundle", "rename", s / "c.bundle", s / "d.bundle",
"unlink", s / "a.bundle", "list", s, "raw", s, "opens", env={"COARSE": "1"})
self.assertEqual(["b.bundle,d.bundle", "b.bundle,d.bundle", "1"], out[1:])
def test_rewinddir_sees_what_changed_since(self):
s = self.staging
out = self.run_program("list", s, "drain", s, "list", s, "raw", s, env={"COARSE": "1"})
self.assertEqual(["1", "", ""], out[1:])
def test_directories_opened_through_fdopendir_underneath(self):
s = self.staging
out = self.run_program("list", s, "list", s, "sneak", s / "x.bundle", "list", s, "rewind", s,
"create", s / "c.bundle", "list", s, "raw", s, "opens",
env={"FASTPATH": "1", "COARSE": "1"})
self.assertEqual(["a.bundle,b.bundle", "a.bundle,b.bundle", "a.bundle,b.bundle,x.bundle"], out[:3])
self.assertEqual(out[5], out[4])
self.assertEqual("a.bundle,b.bundle,c.bundle,x.bundle", out[4])
self.assertEqual("2", out[-1])
def test_a_forked_child_leaves_the_parent_cache_alone(self):
s = self.staging
out = self.run_program("list", s, "fork", s, s / "f.bundle", "list", s, "raw", s, env={"COARSE": "1"})
self.assertEqual("a.bundle,b.bundle,f.bundle", out[1])
self.assertEqual(out[2], out[1])
def test_another_process_changing_the_directory_is_noticed(self):
s = self.staging
first = self.run_program("list", s)
(s / "y.bundle").touch()
self.assertEqual(["a.bundle,b.bundle"], first)
self.assertEqual(["a.bundle,b.bundle,y.bundle"], self.run_program("list", s))
def test_a_listing_read_halfway_is_not_kept(self):
out = self.run_program("half", self.staging, "list", self.staging, "list", self.staging, "opens")
self.assertEqual(["a.bundle,b.bundle", "a.bundle,b.bundle", "2"], out)
def test_rewind_and_dirfd_work_on_a_cached_listing(self):
out = self.run_program("list", self.staging, "rewind", self.staging, "opens")
self.assertEqual(["4 4 1", "1"], out[1:])
def test_seekdir_returns_to_a_told_position(self):
for name in ("c.bundle", "d.bundle", "e.bundle"):
(self.staging / name).touch()
out = self.run_program("list", self.staging, "seek", self.staging, "opens")
self.assertEqual(["1 1", "1"], out[1:])
def test_readers_and_changes_on_other_threads(self):
s = self.staging
out = self.run_program("list", s, "stress", s, "list", s, "raw", s)
self.assertEqual(out[-1], out[-2])
self.assertEqual(202, len(out[-1].split(",")))
def test_only_the_client_and_only_fuse_steamapps(self):
other = self.run_program("list", self.staging, "list", self.staging, "opens", program=self.other)
self.assertEqual("2", other[-1])
native = Path(tempfile.mkdtemp(dir=self.dir)) / "steamapps/downloading/1"
native.mkdir(parents=True)
self.assertEqual("2", self.run_program("list", native, "list", native, "opens")[-1])
outside = self.lib.parent / "elsewhere"
outside.mkdir()
self.assertEqual("2", self.run_program("list", outside, "list", outside, "opens")[-1])
def test_can_be_turned_off(self):
out = self.run_program("list", self.staging, "list", self.staging, "opens", env={"BL_NO_DIR_CACHE": "1"})
self.assertEqual("2", out[-1])
if __name__ == "__main__":
unittest.main()
-256
View File
@@ -1,256 +0,0 @@
"""The guest agent: its request files, exec, errors and the DevTools client, without a session."""
import base64
import hashlib
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
import tracemalloc
SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-agent"
loader = importlib.machinery.SourceFileLoader("droiddeck_agent", str(SCRIPT))
spec = importlib.util.spec_from_loader(loader.name, loader)
agent = importlib.util.module_from_spec(spec)
loader.exec_module(agent)
class FakeDevTools(threading.Thread):
"""GET /json lists one target; its WebSocket answers Runtime.evaluate the way Chromium does."""
def __init__(self, answer):
super().__init__(daemon=True)
self.answer = answer
self.server = socket.socket()
self.server.bind(("127.0.0.1", 0))
self.server.listen(4)
self.port = self.server.getsockname()[1]
self.seen = []
def run(self):
while True:
try:
conn, _ = self.server.accept()
except OSError:
return
threading.Thread(target=self.serve, args=(conn,), daemon=True).start()
def serve(self, conn):
head = b""
while b"\r\n\r\n" not in head:
head += conn.recv(4096)
request = head.decode()
if request.startswith("GET /json"):
body = json.dumps([{"title": "SharedJSContext", "url": "https://steamloopback.host/", "type": "page",
"webSocketDebuggerUrl": "ws://127.0.0.1:%d/devtools/page/1" % self.port}]).encode()
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body)
conn.close()
return
key = [l.split(":", 1)[1].strip() for l in request.split("\r\n") if l.lower().startswith("sec-websocket-key")][0]
accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
conn.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Accept: %s\r\n\r\n" % accept).encode())
b0, b1 = conn.recv(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", conn.recv(2))[0]
mask = conn.recv(4)
data = b""
while len(data) < n:
data += conn.recv(n - len(data))
message = json.loads(bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
self.seen.append(message)
for reply in ({"method": "Runtime.consoleAPICalled", "params": {}}, dict(self.answer, id=message["id"])):
payload = json.dumps(reply).encode()
header = bytes([0x81, 126]) + struct.pack(">H", len(payload)) if len(payload) > 125 else bytes([0x81, len(payload)])
conn.sendall(header + payload)
time.sleep(0.2)
conn.close()
class AgentTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dir = Path(self.temp.name)
(self.dir / "resp").mkdir()
def ask(self, request):
path = self.dir / "req.json"
path.write_text(json.dumps(request))
agent.answer(self.dir, path)
self.assertFalse(path.exists(), "the request file is consumed")
return json.loads((self.dir / "resp" / "req.json").read_text())
def test_exec_output_and_status(self):
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "echo out; echo err >&2; exit 3"], "env": {"X": "1"}})
self.assertTrue(answer["ok"])
self.assertEqual(3, answer["exitCode"])
self.assertEqual("out\n", answer["stdout"]["text"])
self.assertEqual("err\n", answer["stderr"]["text"])
self.assertFalse(answer["timedOut"])
def test_exec_environment_stdin_and_cap(self):
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "printf %s \"$X\"; cat"], "env": {"X": "a"}, "stdin": "b"})
self.assertEqual("ab", answer["stdout"]["text"])
big = self.ask({"kind": "exec", "argv": ["sh", "-c", "head -c 300000 /dev/zero"]})
self.assertTrue(big["stdout"]["truncated"])
self.assertEqual(agent.OUTPUT_CAP, len(big["stdout"]["text"]))
def test_exec_timeout_kills_the_process_group(self):
started = time.monotonic()
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "sleep 30 & sleep 30"], "timeout": 0.5})
self.assertTrue(answer["timedOut"])
self.assertLess(time.monotonic() - started, 10)
def test_exec_memory_is_bounded_while_draining_large_output(self):
tracemalloc.start()
try:
answer = agent.run_exec({"argv": [sys.executable, "-c", "import os; [os.write(1, b'x' * 65536) for _ in range(1024)]"]})
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
self.assertEqual(0, answer["exitCode"])
self.assertEqual(agent.OUTPUT_CAP, len(answer["stdout"]["text"]))
self.assertTrue(answer["stdout"]["truncated"])
self.assertLess(peak, 6 * 1024 * 1024)
def test_exec_drains_both_streams_while_writing_stdin(self):
program = "import os, sys; os.write(1, b'x' * 300000); print(len(sys.stdin.read()), file=sys.stderr)"
answer = self.ask({"kind": "exec", "argv": [sys.executable, "-c", program], "stdin": "y" * 100000})
self.assertFalse(answer["timedOut"])
self.assertTrue(answer["stdout"]["truncated"])
self.assertEqual("100000\n", answer["stderr"]["text"])
def test_flooding_both_streams_still_times_out(self):
program = "import os\nwhile True:\n os.write(1, b'o' * 65536)\n os.write(2, b'e' * 65536)"
answer = self.ask({"kind": "exec", "argv": [sys.executable, "-c", program], "timeout": 0.5})
self.assertTrue(answer["timedOut"])
for name in ("stdout", "stderr"):
self.assertEqual(agent.OUTPUT_CAP, len(answer[name]["text"]))
self.assertTrue(answer[name]["truncated"])
def test_timeout_applies_when_parent_exits_with_inherited_pipes(self):
started = time.monotonic()
answer = self.ask({"kind": "exec", "argv": ["sh", "-c", "sleep 30 & exit 0"], "timeout": 0.5})
self.assertTrue(answer["timedOut"])
self.assertLess(time.monotonic() - started, 5)
def test_invalid_timeout_and_input_are_refused_before_exec(self):
for value in (0, -1, 601, "NaN", "Infinity", "invalid"):
answer = self.ask({"kind": "exec", "argv": ["true"], "timeout": value})
self.assertEqual("INVALID_REQUEST", answer["error"]["code"])
for fields in ({"stdin": 1}, {"env": []}, {"env": {"BAD=KEY": "value"}}, {"argv": ["true\0"]}):
answer = self.ask({"kind": "exec", "argv": ["true"], **fields})
self.assertEqual("INVALID_REQUEST", answer["error"]["code"])
def test_errors_are_answers(self):
self.assertEqual("INVALID_REQUEST", self.ask({"kind": "exec", "argv": []})["error"]["code"])
self.assertEqual("EXEC_FAILED", self.ask({"kind": "exec", "argv": ["/no/such/program"]})["error"]["code"])
self.assertEqual("UNKNOWN_REQUEST", self.ask({"kind": "nope"})["error"]["code"])
self.assertEqual("INVALID_APP_ID", self.ask({"kind": "quit", "appId": "x"})["error"]["code"])
(self.dir / "req.json").write_text("{not json")
agent.answer(self.dir, self.dir / "req.json")
self.assertEqual("INVALID_REQUEST", json.loads((self.dir / "resp" / "req.json").read_text())["error"]["code"])
def test_request_shape_and_size_are_bounded(self):
self.assertEqual("INVALID_REQUEST", self.ask([])["error"]["code"])
self.assertEqual("INVALID_REQUEST", self.ask({"kind": "ping", "unused": "x" * agent.REQUEST_CAP})["error"]["code"])
def test_replies_are_owner_only_even_when_replacing_a_public_file(self):
path = self.dir / "private.json"
path.write_text("old")
path.chmod(0o644)
agent.write_json(path, {"ok": True})
self.assertEqual(0o600, path.stat().st_mode & 0o777)
def test_oversized_websocket_frames_are_refused_before_reading_payload(self):
ws = agent.WebSocket.__new__(agent.WebSocket)
ws.buffer = bytes([0x81, 127]) + struct.pack(">Q", agent.RESPONSE_CAP + 1)
with self.assertRaises(agent.AgentError) as caught:
ws.recv()
self.assertEqual("RESPONSE_TOO_LARGE", caught.exception.code)
def test_devtools_targets_cannot_connect_to_remote_hosts(self):
for url in ("ws://example.com/devtools", "ws://[email protected]/devtools", "wss://127.0.0.1/devtools"):
with patch.object(agent.socket, "create_connection") as connect:
with self.assertRaises(agent.AgentError) as caught:
agent.WebSocket(url, 1)
self.assertEqual("INVALID_TARGET", caught.exception.code)
connect.assert_not_called()
def test_cdp_without_a_port(self):
os.environ.pop("BL_CDP_PORT", None)
self.assertEqual("NO_DEVTOOLS", self.ask({"kind": "cdp", "expression": "1"})["error"]["code"])
def test_cdp_eval(self):
server = FakeDevTools({"result": {"result": {"type": "number", "value": 42}}})
server.start()
self.addCleanup(server.server.close)
(self.dir / "cdp-port").write_text("%d\n" % server.port)
answer = self.ask({"kind": "cdp", "expression": "6 * 7"})
self.assertTrue(answer["ok"], answer)
self.assertEqual(42, answer["value"])
self.assertEqual("SharedJSContext", answer["target"])
self.assertEqual("Runtime.evaluate", server.seen[0]["method"])
self.assertEqual("6 * 7", server.seen[0]["params"]["expression"])
targets = self.ask({"kind": "cdp", "op": "targets"})
self.assertEqual("SharedJSContext", targets["targets"][0]["title"])
missing = self.ask({"kind": "cdp", "expression": "1", "target": "Nope"})
self.assertEqual("NO_TARGET", missing["error"]["code"])
def test_cdp_exception(self):
server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": {
"text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}})
server.start()
self.addCleanup(server.server.close)
(self.dir / "cdp-port").write_text(str(server.port))
answer = self.ask({"kind": "cdp", "expression": "SteamClient"})
self.assertEqual("JS_EXCEPTION", answer["error"]["code"])
self.assertIn("ReferenceError", answer["error"]["message"])
def test_serve_answers_request_files(self):
server = subprocess.Popen([sys.executable, str(SCRIPT), "--no-focus"],
env=dict(os.environ, BL_LAUNCH_DIR=str(self.dir)),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def stop_server():
server.terminate()
server.wait(timeout=5)
self.addCleanup(stop_server)
agent_dir = self.dir / "agent"
for _ in range(100):
if (agent_dir / "hello.json").exists():
break
time.sleep(0.02)
hello = json.loads((agent_dir / "hello.json").read_text())
self.assertEqual(server.pid, hello["pid"])
self.assertFalse(hello["focus"])
(agent_dir / "req" / "a.json.tmp").write_text(json.dumps({"kind": "ping"}))
os.replace(agent_dir / "req" / "a.json.tmp", agent_dir / "req" / "a.json")
for _ in range(200):
if (agent_dir / "resp" / "a.json").exists():
break
time.sleep(0.02)
self.assertEqual(agent.VERSION, json.loads((agent_dir / "resp" / "a.json").read_text())["version"])
def test_game_pids_reads_the_steam_environment(self):
import subprocess
if not Path("/proc").is_dir():
self.skipTest("needs /proc")
child = subprocess.Popen(["sleep", "30"], env=dict(os.environ, SteamAppId="424242"))
self.addCleanup(lambda: (child.kill(), child.wait()))
time.sleep(0.2)
self.assertIn(child.pid, agent.game_pids(424242))
if __name__ == "__main__":
unittest.main()
-76
View File
@@ -1,76 +0,0 @@
import subprocess
import tempfile
import unittest
from pathlib import Path
FONTS = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-fonts"
SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session"
class DroidDeckFontsTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
defaults = self.root / "usr/share/fontconfig/conf.default"
defaults.mkdir(parents=True)
(defaults / "60-latin.conf").write_text("<fontconfig/>")
dejavu = self.root / "usr/share/fonts/truetype/dejavu"
dejavu.mkdir(parents=True)
(dejavu / "DejaVuSans.ttf").write_text("dejavu")
def run_fonts(self):
subprocess.run(["bash", str(FONTS), str(self.root)], check=True, capture_output=True)
def local_conf(self):
return (self.root / "etc/fonts/local.conf").read_text()
def owned_conf(self):
return (self.root / "etc/fonts/conf.d/65-droiddeck-default-fonts.conf").read_text()
def test_default_rules_linked_and_dejavu_is_the_default(self):
self.run_fonts()
self.assertTrue((self.root / "etc/fonts/conf.d/60-latin.conf").is_symlink())
text = self.owned_conf()
self.assertIn("<family>sans-serif</family><prefer><family>DejaVu Sans</family>", text)
self.assertIn("<family>monospace</family><prefer><family>DejaVu Sans Mono</family>", text)
self.assertNotIn("Noto Sans CJK", text)
self.assertFalse((self.root / "etc/fonts/local.conf").exists())
def test_an_existing_conf_d_rule_is_not_replaced(self):
conf_d = self.root / "etc/fonts/conf.d"
conf_d.mkdir(parents=True)
(conf_d / "60-latin.conf").write_text("mine")
self.run_fonts()
self.assertFalse((conf_d / "60-latin.conf").is_symlink())
self.assertEqual("mine", (conf_d / "60-latin.conf").read_text())
def test_custom_local_conf_survives_and_device_language_fallback_remains_configured(self):
custom = self.root / "etc/fonts/local.conf"
custom.parent.mkdir(parents=True, exist_ok=True)
custom.write_text("<fontconfig><rescan><int>45</int></rescan></fontconfig>")
self.run_fonts()
self.assertEqual("<fontconfig><rescan><int>45</int></rescan></fontconfig>", self.local_conf())
session = SESSION.read_text()
self.assertIn('device_fonts=/usr/local/share/fonts/android', session)
self.assertIn('zh-cn=Noto Sans CJK SC', session)
self.assertIn('ja=Noto Sans CJK JP', session)
self.assertIn('ko=Noto Sans CJK KR', session)
self.assertIn('th=Noto Sans Thai', session)
def test_unchanged_fonts_do_not_rewrite_owned_conf(self):
self.run_fonts()
owned = self.root / "etc/fonts/conf.d/65-droiddeck-default-fonts.conf"
before = owned.stat().st_mtime_ns
self.run_fonts()
self.assertEqual(before, owned.stat().st_mtime_ns)
def test_the_session_runs_the_bootstrap_before_it_starts_a_program(self):
text = SESSION.read_text()
self.assertIn("/usr/local/bin/droiddeck-fonts", text)
self.assertLess(text.index("droiddeck-fonts"), text.index("droiddeck-steam-install"))
if __name__ == "__main__":
unittest.main()
-353
View File
@@ -1,353 +0,0 @@
"""droiddeckctl and droiddeck-scenario against a fake adb: the JSON contract and exit codes."""
import base64
import contextlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import stat
import sys
import tempfile
import textwrap
import unittest
TOOLS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLS))
def load(name):
loader = importlib.machinery.SourceFileLoader(name.replace("-", "_"), str(TOOLS / name))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
ctl = load("droiddeckctl")
scenario = load("droiddeck-scenario")
# A stand-in for adb: answers the provider's content calls from a JSON config (a list answers
# successive calls), records every invocation, and fakes the few shell commands droiddeckctl uses.
FAKE_ADB = textwrap.dedent('''\
#!/usr/bin/env python3
import base64, json, os, sys
config_path = os.environ["FAKE_ADB_CONFIG"]
config = json.load(open(config_path))
args = sys.argv[1:]
if args[:1] == ["-s"]:
args = args[2:]
entry = {"args": args}
if args[:1] == ["get-state"]:
print("device"); sys.exit(0)
if args[:3] == ["shell", "content", "call"]:
method = args[args.index("--method") + 1]
request = None
if "--extra" in args:
request = json.loads(base64.b64decode(args[args.index("--extra") + 1].split(":", 2)[2]))
entry.update(method=method, request=request)
answers = config["provider"].get(method, {"ok": False, "error": {"code": "UNKNOWN_COMMAND", "message": method}})
if isinstance(answers, list):
count = config.setdefault("counts", {}).get(method, 0)
config["counts"][method] = count + 1
answers = answers[min(count, len(answers) - 1)]
json.dump(config, open(config_path, "w"))
print("Result: Bundle[{json=" + json.dumps(answers) + "}]")
elif args[:3] == ["shell", "dumpsys", "display"]:
print(config.get("dumpsys_display", ""))
elif args[:3] == ["shell", "dumpsys", "power"]:
print(" mWakefulness=Awake")
elif args[:2] == ["exec-out", "screencap"]:
sys.stdout.buffer.write(b"\\x89PNG fake")
with open(config["log"], "a") as log:
log.write(json.dumps(entry) + "\\n")
''')
DUMPSYS_DISPLAY = textwrap.dedent('''\
Display Devices: size=2
DisplayDeviceInfo{"Built-in Screen": uniqueId="local:111", 1080 x 1920}
DisplayDeviceInfo{"Screen-2": uniqueId="local:222", 1080 x 1240}
Logical Displays: size=2
Display 0:
mDisplayId=0
mPrimaryDisplayDevice=Built-in Screen
mBaseDisplayInfo=DisplayInfo{"Built-in Screen", displayId 0", real 1080 x 1920, x}
Display 4:
mDisplayId=4
mPrimaryDisplayDevice=Screen-2
mBaseDisplayInfo=DisplayInfo{"Screen-2", displayId 4", real 1080 x 1240, x}
''')
def state(phase="READY", focused=None, focusable=()):
focus = None if focused is None else {"focusedApp": focused, "focusableApps": list(focusable),
"baselayerAppIds": [], "seq": 1, "t": 0}
return {"ok": True, "schema": 2, "session": {"id": "s1", "phase": phase, "running": phase == "READY",
"focus": focus, "failure": None}}
class FakeAdbTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.dir = Path(self.temp.name)
self.adb = self.dir / "adb"
self.adb.write_text(FAKE_ADB)
self.adb.chmod(self.adb.stat().st_mode | stat.S_IEXEC)
self.config = self.dir / "config.json"
self.log = self.dir / "log.jsonl"
self.configure({})
os.environ["FAKE_ADB_CONFIG"] = str(self.config)
self.addCleanup(os.environ.pop, "FAKE_ADB_CONFIG", None)
def configure(self, provider, **extra):
self.config.write_text(json.dumps(dict({"provider": provider, "log": str(self.log)}, **extra)))
def run_ctl(self, *argv):
out = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
code = ctl.main(["--adb", str(self.adb), "--serial", "fake-1", *argv])
return code, json.loads(out.getvalue())
def calls(self, method=None):
if not self.log.exists():
return []
entries = [json.loads(line) for line in self.log.read_text().splitlines()]
return [e for e in entries if method is None or e.get("method") == method]
class DroiddeckctlTest(FakeAdbTest):
def test_oversized_requests_are_refused_before_adb(self):
with self.assertRaises(ctl.ControlError) as caught:
ctl.provider_call(str(self.adb), "fake-1", "guest", request={"stdin": "x" * ctl.REQUEST_CAP})
self.assertEqual("INVALID_REQUEST", caught.exception.code)
self.assertFalse(self.calls())
def test_state_passes_through_with_the_serial(self):
self.configure({"state": state()})
code, out = self.run_ctl("state")
self.assertEqual(0, code)
self.assertEqual("READY", out["session"]["phase"])
self.assertEqual("fake-1", out["deviceSerial"])
def test_launch_sends_the_app_id_and_waits_for_focus(self):
self.configure({
"launch": {"ok": True, "command": "launch", "appId": "489830"},
"state": [state(focused=769, focusable=[769]), state(focused=489830, focusable=[769])],
})
# Focus alone is not enough: the game must also be in the list Steam orders from.
code, out = self.run_ctl("launch", "489830", "--wait", "--timeout", "1")
self.assertEqual(5, code)
self.assertEqual("TIMEOUT", out["error"]["code"])
self.assertIn("focused app 489830", out["error"]["message"])
self.assertEqual({"appId": "489830"}, self.calls("launch")[0]["request"])
self.configure({
"launch": {"ok": True, "command": "launch", "appId": "489830"},
"state": [state(focused=769, focusable=[769]), state(focused=489830, focusable=[489830, 769])],
})
code, out = self.run_ctl("launch", "489830", "--wait", "--timeout", "5")
self.assertEqual(0, code)
self.assertEqual(489830, out["focus"]["focusedApp"])
def test_start_reuse_keeps_a_running_session(self):
running = state(focused=769)
running["session"]["mode"] = "steam"
self.configure({"state": running})
code, out = self.run_ctl("start", "steam", "--reuse", "--wait")
self.assertEqual(0, code)
self.assertTrue(out["reused"])
self.assertFalse([c for c in self.calls() if c["args"][:2] == ["shell", "am"]])
def test_start_without_reuse_refuses_a_running_session(self):
running = state()
running["session"]["mode"] = "steam"
self.configure({"state": running})
code, out = self.run_ctl("start", "steam")
self.assertEqual(4, code)
self.assertEqual("SESSION_ACTIVE", out["error"]["code"])
def test_wait_game_ignores_the_client(self):
self.configure({"state": [state(focused=769), state(focused=752590, focusable=[752590])]})
code, out = self.run_ctl("wait", "game", "--timeout", "5")
self.assertEqual(0, code)
self.assertEqual(752590, out["session"]["focus"]["focusedApp"])
def test_a_failed_session_ends_a_wait(self):
failed = state(phase="FAILED")
failed["session"]["failure"] = {"code": "GUEST_EXIT", "message": "boom"}
self.configure({"state": failed})
code, out = self.run_ctl("wait", "focused", "1", "--timeout", "5")
self.assertEqual(4, code)
self.assertEqual("GUEST_EXIT", out["error"]["code"])
def test_disabled_agent_commands_exit_7(self):
self.configure({"guest": {"ok": False, "error": {"code": "AGENT_COMMANDS_DISABLED", "message": "off"}}})
code, out = self.run_ctl("guest", "--", "true")
self.assertEqual(7, code)
self.assertEqual("AGENT_COMMANDS_DISABLED", out["error"]["code"])
def test_run_checks_access_before_launching_an_activity(self):
self.configure({"access": {"ok": True, "commands": False}})
code, out = self.run_ctl("run", "/usr/bin/sh", "--", "-c", "true")
self.assertEqual(7, code)
self.assertEqual("AGENT_COMMANDS_DISABLED", out["error"]["code"])
self.assertIn("Debugging tools", out["error"]["message"])
self.assertEqual(["access"], [c.get("method") for c in self.calls()])
def test_override_checks_names_before_pushing_any_file(self):
self.configure({"access": {"ok": True, "commands": True, "inbox": "/tmp/inbox"}})
source = self.dir / "binary"
source.write_bytes(b"test")
code, out = self.run_ctl("override", "../outside", str(source))
self.assertEqual(2, code)
self.assertEqual("INVALID_NAME", out["error"]["code"])
self.assertEqual(["access"], [c.get("method") for c in self.calls()])
def test_guest_request_and_check(self):
self.configure({"guest": {"ok": True, "exitCode": 3, "stdout": {"text": ""}, "stderr": {"text": "nope"}}})
code, out = self.run_ctl("guest", "--timeout", "7", "--env", "A=1", "--check", "--", "xprop", "-root")
self.assertEqual(4, code)
self.assertEqual("GUEST_EXIT", out["error"]["code"])
self.assertEqual({"argv": ["xprop", "-root"], "timeout": 7.0, "env": {"A": "1"}},
self.calls("guest")[0]["request"])
def test_screenshot_resolves_the_logical_display(self):
self.configure({}, dumpsys_display=DUMPSYS_DISPLAY)
target = self.dir / "shot.png"
code, out = self.run_ctl("screenshot", str(target), "--display", "4")
self.assertEqual(0, code)
self.assertTrue(target.read_bytes().startswith(b"\x89PNG"))
shot = [c for c in self.calls() if c["args"][:2] == ["exec-out", "screencap"]][0]
self.assertEqual(["exec-out", "screencap", "-p", "-d", "222"], shot["args"])
def test_displays(self):
self.configure({}, dumpsys_display=DUMPSYS_DISPLAY)
code, out = self.run_ctl("displays")
self.assertEqual(0, code)
self.assertEqual([0, 4], [d["display"] for d in out["displays"]])
self.assertEqual("222", out["displays"][1]["physicalId"])
self.assertEqual([1080, 1240], out["displays"][1]["size"])
def test_unknown_display_is_an_error(self):
self.configure({}, dumpsys_display=DUMPSYS_DISPLAY)
code, out = self.run_ctl("screenshot", str(self.dir / "x.png"), "--display", "9")
self.assertEqual(2, code)
self.assertEqual("NO_SUCH_DISPLAY", out["error"]["code"])
def test_input_requests(self):
self.configure({"input": {"ok": True, "command": "input"}})
self.run_ctl("input", "tap", "0.5", "0.25", "--space", "fraction")
self.run_ctl("input", "key", "enter", "--mod", "CTRL")
self.run_ctl("input", "pad", "a", "--hold", "50")
requests = [c["request"] for c in self.calls("input")]
self.assertEqual({"type": "tap", "x": 0.5, "y": 0.25, "space": "fraction", "holdMs": 60}, requests[0])
self.assertEqual({"type": "key", "key": "enter", "action": "tap", "modifiers": ["CTRL"], "holdMs": 40}, requests[1])
self.assertEqual({"type": "pad", "button": "a", "action": "tap", "holdMs": 50}, requests[2])
def test_ui_click_by_tag_or_match(self):
self.configure({"ui": {"ok": True, "command": "ui"}})
self.run_ctl("ui", "click", "rail-steam")
self.run_ctl("ui", "click", "--match", "Play", "--index", "1")
requests = [c["request"] for c in self.calls("ui")]
self.assertEqual({"op": "click", "index": 0, "tag": "rail-steam"}, requests[0])
self.assertEqual({"op": "click", "index": 1, "match": "Play"}, requests[1])
def test_cdp_targets_and_eval_requests(self):
self.configure({"cdp": {"ok": True, "targets": []}})
self.assertEqual(0, self.run_ctl("cdp", "targets")[0])
self.assertEqual(0, self.run_ctl("cdp", "eval", "1+1", "--target", "Big Picture")[0])
requests = [c["request"] for c in self.calls("cdp")]
self.assertEqual({"op": "targets"}, requests[0])
self.assertEqual({"op": "eval", "expression": "1+1", "awaitPromise": True, "timeout": 15.0,
"target": "Big Picture"}, requests[1])
def test_a_bug_still_answers_json(self):
self.configure({"state": {"ok": True, "session": "not an object"}})
code, out = self.run_ctl("wait", "game", "--timeout", "1")
self.assertEqual(2, code)
self.assertEqual("INTERNAL_ERROR", out["error"]["code"])
def test_wake(self):
code, out = self.run_ctl("wake")
self.assertEqual(0, code)
self.assertEqual("Awake", out["wakefulness"])
self.assertIn(["shell", "input", "keyevent", "KEYCODE_WAKEUP"], [c["args"] for c in self.calls()])
def test_override_pushes_to_the_inbox_first(self):
self.configure({
"access": {"ok": True, "commands": True, "inbox": "/sdcard/Android/data/x/files/agent-inbox"},
"override": {"ok": True, "installed": "/opt/droiddeck-agent/bin/gamescope"},
})
binary = self.dir / "gamescope"
binary.write_bytes(b"\x7fELF")
code, out = self.run_ctl("override", "gamescope", str(binary))
self.assertEqual(0, code)
push = [c for c in self.calls() if c["args"][:1] == ["push"]][0]
self.assertEqual("/sdcard/Android/data/x/files/agent-inbox/gamescope", push["args"][2])
self.assertEqual({"name": "gamescope"}, self.calls("override")[0]["request"])
def test_override_refused_before_pushing_when_commands_are_off(self):
self.configure({"access": {"ok": True, "commands": False, "inbox": "/x"}})
binary = self.dir / "gamescope"
binary.write_bytes(b"x")
code, out = self.run_ctl("override", "gamescope", str(binary))
self.assertEqual(7, code)
self.assertFalse([c for c in self.calls() if c["args"][:1] == ["push"]])
class ScenarioTest(FakeAdbTest):
def write(self, body):
path = self.dir / "scenario.json"
path.write_text(json.dumps(body))
return path
def run_scenario(self, path, *extra):
out = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
code = scenario.main([str(path), "--adb", str(self.adb), "--serial", "fake-1",
"--out", str(self.dir / "runs"), *extra])
return code, json.loads(out.getvalue())
def test_variables_asserts_and_repeats(self):
self.configure({"state": state(focused=489830, focusable=[489830])})
path = self.write({
"name": "t", "vars": {"appId": 1}, "repeat": 2,
"steps": [["state"], {"assert": "session.focus.focusedApp", "equals": "${appId}"}],
})
code, report = self.run_scenario(path, "--var", "appId=489830")
self.assertEqual(0, code)
self.assertEqual(2, report["passed"])
output = Path(report["out"])
self.assertEqual(0o700, output.stat().st_mode & 0o777)
for artifact in output.rglob("*.json"):
self.assertEqual(0o600, artifact.stat().st_mode & 0o777, str(artifact))
for run in report["runs"]:
self.assertEqual(0o700, Path(run["folder"]).stat().st_mode & 0o777)
def test_a_failed_assert_fails_the_run_and_collects_evidence(self):
self.configure({"state": state(focused=769, focusable=[769]), "focus": {"ok": True, "focusedApp": 769}},
dumpsys_display=DUMPSYS_DISPLAY)
path = self.write({"name": "t", "repeat": 3,
"steps": [["state"], {"assert": "session.focus.focusedApp", "equals": 489830}]})
code, report = self.run_scenario(path)
self.assertEqual(1, code)
self.assertEqual(1, len(report["runs"])) # stops at the first failure without --keep-going
self.assertIn("is 769", report["runs"][0]["error"])
evidence = json.loads((Path(report["runs"][0]["folder"]) / "evidence.json").read_text())
self.assertEqual(769, evidence["focus"]["focusedApp"])
def test_after_steps_run_after_a_failure(self):
self.configure({"state": state(focused=769), "quit": {"ok": True, "remaining": []}})
path = self.write({"name": "t", "repeat": 2, "vars": {"appId": 7},
"steps": [["state"], {"assert": "session.focus.focusedApp", "equals": 7}],
"after": [["quit", "${appId}"]]})
code, report = self.run_scenario(path, "--keep-going")
self.assertEqual(1, code)
self.assertEqual(2, len(report["runs"]))
self.assertEqual([{"appId": 7, "timeout": 15.0}] * 2, [c["request"] for c in self.calls("quit")])
if __name__ == "__main__":
unittest.main()
-161
View File
@@ -1,161 +0,0 @@
import json
import unittest
from unittest.mock import Mock, patch
from tools.release import pr_discord as module
class PrDiscordTest(unittest.TestCase):
def setUp(self):
self.pull = {"state": "open", "draft": False, "base": {"ref": "main"},
"head": {"sha": "a" * 40}, "title": "Fix **input** @everyone", "merged": False}
self.comment = {"id": 10, "body": module.REPORT + "\nBuild download"}
self.discord = Mock(id="1554202953248940172")
self.discord.request.return_value = {"id": "1557547293375332465"}
self.github = patch.object(module, "github").start()
self.addCleanup(patch.stopall)
self.github.side_effect = lambda path, method="GET", body=None: self.pull if "/pulls/" in path else self.comment
patch.object(module, "report_comment", return_value=self.comment).start()
def publish(self):
module.sync("publish", "Droid-Deck/DroidDeck", "410", self.discord, "a" * 40,
"https://github.com/Droid-Deck/DroidDeck-CI/releases/download/pr-410/build.apk",
"https://github.com/Droid-Deck/DroidDeck-CI/releases/tag/pr-410")
def save_state(self):
self.comment["body"] += '\n<!-- droiddeck-discord: ' + json.dumps({
"webhook_id": self.discord.id, "message_id": "1557547293375332465"}) + ' -->'
def test_create_confirmed_message_and_persist_reference(self):
self.publish()
args = self.discord.request.call_args.args
self.assertEqual(("POST", "?wait=true"), args[:2])
self.assertEqual({"parse": []}, args[2]["allowed_mentions"])
self.assertIn(r"Fix \*\*input\*\*", args[2]["content"])
saved = self.github.call_args.args[2]["body"]
self.assertEqual("1557547293375332465", module.saved_message(saved, self.discord.id))
self.assertIn("Build download", saved)
def test_new_build_updates_existing_message(self):
self.save_state()
self.publish()
self.assertEqual("PATCH", self.discord.request.call_args.args[0])
saved = self.github.call_args.args[2]["body"]
self.assertEqual(1, len(module.STATE.findall(saved)))
def test_deleted_message_is_recreated(self):
self.save_state()
self.discord.request.side_effect = [None, {"id": "1557547293375332466"}]
self.publish()
self.assertEqual(["PATCH", "POST"], [c.args[0] for c in self.discord.request.call_args_list])
def test_changed_pr_does_not_publish(self):
for change in ({"state": "closed"}, {"draft": True}, {"base": {"ref": "other"}},
{"head": {"sha": "b" * 40}}):
original = self.pull.copy()
self.pull.update(change)
self.publish()
self.discord.request.assert_not_called()
self.pull = original
def test_retirement_strikes_original_and_distinguishes_merge(self):
self.save_state()
original = module.build_content("Droid-Deck/DroidDeck", "410", self.pull["title"], "a" * 40, "apk", "release")
for merged, label in ((True, "Merged"), (False, "Closed")):
self.pull.update(state="closed", merged=merged)
self.discord.request.side_effect = [{"content": original}, {"id": "1557547293375332465"}]
module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord)
payload = self.discord.request.call_args.args[2]
self.assertTrue(payload["content"].startswith(f"**{label}** —"))
self.assertIn("~~Download:", payload["content"])
self.assertEqual({"parse": []}, payload["allowed_mentions"])
self.assertEqual(4, payload["flags"])
def test_retirement_is_idempotent_and_reopen_skips_it(self):
once = module.retired_content("first\n\nlast", True)
self.assertEqual(once, module.retired_content(once, True))
self.save_state()
module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord)
self.discord.request.assert_not_called()
def test_old_build_without_saved_id_is_not_guessed(self):
self.pull["state"] = "closed"
module.sync("retire", "Droid-Deck/DroidDeck", "410", self.discord)
self.discord.request.assert_not_called()
def test_replaced_webhook_cannot_edit_old_webhook_message(self):
self.save_state()
self.discord.id = "1554202953248940173"
self.publish()
self.assertEqual("POST", self.discord.request.call_args.args[0])
def test_only_github_actions_report_is_trusted(self):
with patch.object(module, "report_comment", wraps=original_report):
forged = {"user": {"login": "contributor"}, "body": module.REPORT, "id": 1}
real = {"user": {"login": "github-actions[bot]"}, "body": module.REPORT, "id": 2}
self.github.side_effect = [[forged, real]]
self.assertEqual(2, module.report_comment("Droid-Deck/DroidDeck", "410")["id"])
def test_invalid_webhook_never_echoes_secret(self):
with self.assertRaisesRegex(RuntimeError, "^Invalid Discord webhook configuration$"):
module.Discord("https://attacker.invalid/webhooks/secret")
def test_network_error_redacts_webhook_and_does_not_retry_creation(self):
webhook = "https://discord.com/api/webhooks/1554202953248940172/private_token"
with patch.object(module.urllib.request, "urlopen", side_effect=module.urllib.error.URLError(webhook)) as request:
with self.assertRaisesRegex(RuntimeError, "^Discord request failed$"):
module.Discord(webhook).request("POST", "?wait=true", {"content": "build"})
self.assertEqual(1, request.call_count)
def test_edit_failure_does_not_create_duplicate_announcement(self):
self.save_state()
self.discord.request.side_effect = RuntimeError("Discord request failed")
with self.assertRaises(RuntimeError):
self.publish()
self.assertEqual(1, self.discord.request.call_count)
self.assertEqual("PATCH", self.discord.request.call_args.args[0])
def test_missing_build_report_prevents_untracked_message(self):
module.report_comment.return_value = None
with self.assertRaisesRegex(RuntimeError, "Signed build report comment is missing"):
self.publish()
self.discord.request.assert_not_called()
def test_title_cannot_insert_status_or_strikethrough(self):
content = module.build_content("Droid-Deck/DroidDeck", "410", "~~Retired~~\n**Merged**", "a" * 40, "apk", "release")
self.assertNotIn("\n**Merged**", content)
self.assertIn(r"\~\~Retired\~\~", content)
self.assertLessEqual(len(module.retired_content(content, True)), 2000)
def test_description_uses_first_useful_paragraph(self):
body = "## Summary\n\n<!-- private template guidance -->\n\nFixes the [Store import](https://example.com).\n\nMore implementation detail."
content = module.build_content("Droid-Deck/DroidDeck", "400", "Store import", "a" * 40, "apk", "release", body)
self.assertIn("\n\nFixes the Store import.\n\n", content)
self.assertNotIn("private template", content)
self.assertNotIn("More implementation", content)
def test_empty_and_template_descriptions_are_omitted(self):
for body in (None, "", "## Summary\n\n<!-- fill this out -->\n\n- [ ] Tests\n- [x] Code review", "N/A"):
self.assertEqual("", module.description_excerpt(body))
def test_description_cannot_override_status_or_ping(self):
self.pull["body"] = "~~Closed~~ @everyone\n\n```\nsecret template code\n```"
self.publish()
payload = self.discord.request.call_args.args[2]
self.assertIn(r"\~\~Closed\~\~ @everyone", payload["content"])
self.assertNotIn("secret template", payload["content"])
self.assertEqual({"parse": []}, payload["allowed_mentions"])
def test_long_description_leaves_room_for_retirement(self):
content = module.build_content("Droid-Deck/DroidDeck", "410", "*" * 300, "a" * 40,
"https://github.com/Droid-Deck/DroidDeck-CI/releases/download/pr-410/build.apk",
"https://github.com/Droid-Deck/DroidDeck-CI/releases/tag/pr-410", "~" * 10000)
self.assertLessEqual(len(content), 1800)
self.assertLessEqual(len(module.retired_content(content, True)), 2000)
original_report = module.report_comment
if __name__ == "__main__":
unittest.main()
+5 -5
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import Mock, patch
from unittest.mock import patch
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
@@ -76,8 +76,8 @@ class ImportsTest(unittest.TestCase):
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \
patch.object(imports, 'request_restart'), \
patch.object(imports, 'time', Mock(monotonic=Mock(side_effect=[0, 301]),
sleep=Mock(side_effect=[None, RuntimeError('stop')]))), \
patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \
patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \
patch.object(imports.subprocess, 'run', return_value=result), \
patch('builtins.print'):
with self.assertRaisesRegex(RuntimeError, 'stop'):
@@ -98,8 +98,8 @@ class ImportsTest(unittest.TestCase):
with patch.object(imports, 'identify', return_value=42), \
patch.object(imports, 'game_running', return_value=False), \
patch.object(imports, 'request_restart') as restart, \
patch.object(imports, 'time', Mock(monotonic=Mock(side_effect=list(range(0, 30 * beats, 30))),
sleep=Mock(side_effect=[None] * (beats - 1) + [RuntimeError('stop')]))), \
patch.object(imports.time, 'monotonic', side_effect=list(range(0, 30 * beats, 30))), \
patch.object(imports.time, 'sleep', side_effect=[None] * (beats - 1) + [RuntimeError('stop')]), \
patch.object(imports.subprocess, 'run', return_value=result), \
patch('builtins.print'):
with self.assertRaisesRegex(RuntimeError, 'stop'):