Merge pull request #40 from Droid-Deck/fix/review-hardening

Fix/review hardening
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-27 13:57:20 -04:00
commit 5448177814
41 files changed
+4795 -3740

No files matched your search

+3
View File
@@ -295,6 +295,9 @@ jobs:
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
- name: Check the native dependency closure
run: |
set -euo pipefail
+8 -1
View File
@@ -140,6 +140,11 @@ android {
buildConfig true
}
testOptions {
// Plain JVM tests: a stray android.util.Log call returns a default instead of throwing.
unitTests.returnDefaultValues = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -152,7 +157,7 @@ android {
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.20'
implementation 'org.apache.commons:commons-compress:1.27.1'
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
@@ -169,4 +174,6 @@ dependencies {
implementation 'com.flyfishxu:kadb-android:1.2.1'
// Android terminal view/emulator used by the in-session secondary-display Linux shell.
implementation 'com.termux.termux-app:terminal-view:0.118.0'
testImplementation 'junit:junit:4.13.2'
}
+6 -1
View File
@@ -48,7 +48,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="sensorLandscape">
android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -125,6 +126,10 @@
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -13,6 +13,7 @@ add_library(bannerwayland SHARED
src/ahb_swapchain.c
src/waylandcomp_jni.c
src/banner_ext.c
src/wl_dmabuf.c
src/wl_clipboard.c
src/wl_text_input.c
src/wl_toplevel_icon.c
@@ -33,6 +34,9 @@ add_library(bannerwayland SHARED
src/framegen_bridge.c
src/framegen_jni.c
)
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
target_compile_options(bannerwayland PRIVATE
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
target_include_directories(bannerwayland PRIVATE
generated
prebuilt/include
+13 -366
View File
@@ -51,6 +51,7 @@
#include "effects_chain.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "compositor_internal.h"
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
@@ -161,21 +162,9 @@ static int log_budget(void) {
static struct wl_display *g_display;
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
struct client_info {
struct wl_client *client;
struct wl_listener destroy;
pid_t pid;
char name[64];
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
static struct client_info *g_clients;
static struct client_info *client_info_of(struct wl_client *client) {
struct client_info *client_info_of(struct wl_client *client) {
for (struct client_info *ci = g_clients; ci; ci = ci->next)
if (ci->client == client) return ci;
return NULL;
@@ -500,7 +489,7 @@ static void cursor_publish_hidden(void) {
}
static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) {
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
pthread_mutex_lock(&g_cursor_lock);
for (int y = 0; y < h; y++)
memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4);
@@ -513,10 +502,13 @@ static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t strid
static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return;
int32_t stride = wl_shm_buffer_get_stride(shm);
if (w <= 0 || h <= 0 || (int64_t)w * h > CURSOR_MAX_PX) return;
/* libwayland only promises stride >= width; each row copied here is width * 4 bytes, so a
* shorter stride would read past the end of the client's pool. */
if ((int64_t)stride < (int64_t)w * 4) return;
wl_shm_buffer_begin_access(shm);
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h,
(size_t)wl_shm_buffer_get_stride(shm), hx, hy);
cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, (size_t)stride, hx, hy);
wl_shm_buffer_end_access(shm);
}
@@ -557,40 +549,7 @@ static void constraints_focus_entered(struct wl_resource *target, struct wl_clie
/* ------------------------------------------------------------------ dmabuf buffers */
#define FOURCC(a, b, c, d) \
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
#define MOD_LINEAR VKP_MOD_LINEAR
#define MOD_INVALID VKP_MOD_INVALID
#define MAX_PLANES 4
struct dmabuf_params {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
uint64_t modifier[MAX_PLANES];
int n_planes;
};
struct dmabuf_buffer {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
int n_planes;
int32_t width, height;
uint32_t format;
uint64_t modifier;
struct vkp_image *img; /* imported once, reused for every frame */
int import_failed;
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
* until the surface commits something newer, so the picture never blinks to black. */
int refs;
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
};
static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
if (!b || --b->refs > 0) return;
vkp_image_destroy(b->img);
for (int i = 0; i < b->n_planes; i++)
@@ -601,7 +560,7 @@ static void dmabuf_buffer_unref(struct dmabuf_buffer *b) {
static void dbuf_buffer_destroy_req(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct wl_buffer_interface dbuf_buffer_impl = {
const struct wl_buffer_interface dbuf_buffer_impl = {
.destroy = dbuf_buffer_destroy_req,
};
@@ -1018,7 +977,8 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
} else if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) {
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
@@ -1570,319 +1530,6 @@ static void bind_desktop(struct wl_client *c, void *data, uint32_t ver, uint32_t
wl_resource_set_implementation(r, &desktop_impl, NULL, NULL);
}
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
dmabuf_buffer_unref(wl_resource_get_user_data(r));
}
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (plane >= MAX_PLANES) { close(fd); return; }
if (p->fd[plane] >= 0) close(p->fd[plane]);
p->fd[plane] = fd;
p->offset[plane] = offset;
p->stride[plane] = stride;
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
}
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
if (!b) return NULL;
b->n_planes = p->n_planes;
b->refs = 1; /* the wl_buffer resource's */
b->width = w; b->height = h; b->format = format;
b->modifier = p->modifier[0];
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
for (int i = 0; i < p->n_planes; i++) {
b->fd[i] = p->fd[i];
b->offset[i] = p->offset[i];
b->stride[i] = p->stride[i];
p->fd[i] = -1; /* ownership moves to the buffer */
}
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!buf) {
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
free(b);
wl_client_post_no_memory(c);
return NULL;
}
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
struct client_info *ci = client_info_of(c);
if (ci) ci->dmabuf_buffers++;
return buf;
}
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
uint32_t format, uint32_t flags) {
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
else zwp_linux_buffer_params_v1_send_failed(r);
}
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
params_do_create(c, r, buffer_id, w, h, format, flags);
}
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
.destroy = params_destroy,
.add = params_add,
.create = params_create,
.create_immed = params_create_immed,
};
static void params_resource_destroy(struct wl_resource *r) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (!p) return;
for (int i = 0; i < MAX_PLANES; i++)
if (p->fd[i] >= 0) close(p->fd[i]);
free(p);
}
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct dmabuf_params *p = calloc(1, sizeof(*p));
if (!p) { wl_client_post_no_memory(c); return; }
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
wl_resource_get_version(r), id);
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(pr, &params_impl, p, params_resource_destroy);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface);
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
.destroy = dmabuf_destroy,
.create_params = dmabuf_create_params,
.get_default_feedback = dmabuf_get_default_feedback,
.get_surface_feedback = dmabuf_get_surface_feedback,
};
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
* takes the first format it sees - gets exactly the table it always had. */
#define DMABUF_NFMT_MAX 6
#define DMABUF_NFMT_SDR 4
#define DMABUF_NMOD 4
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
static int g_dmabuf_fmts_ready;
static void dmabuf_build_feedback(void);
static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
* we advertised before this table existed. */
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
if (got[i] == MOD_LINEAR) continue;
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
g_dmabuf_fmts[f].mods[k++] = got[i];
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
}
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
g_dmabuf_fmts[f].n = k;
uint32_t fmt = g_dmabuf_fmts[f].fmt;
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
}
dmabuf_build_feedback();
}
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
*
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
*
* What a client needs from us is one tranche describing "everything the compositor can import":
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
#ifndef F_ADD_SEALS
#define F_ADD_SEALS 1033
#define F_SEAL_SEAL 0x0001
#define F_SEAL_SHRINK 0x0002
#define F_SEAL_GROW 0x0004
#define F_SEAL_WRITE 0x0008
#endif
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
static size_t g_fmt_table_size;
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
static dev_t g_main_device; /* the render node clients should allocate on */
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
static dev_t dmabuf_render_node(void) {
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
struct stat st;
if (g_no_render_node) return 0;
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
return 0;
}
static void dmabuf_build_feedback(void) {
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
int n = 0;
g_main_device = dmabuf_render_node();
for (int f = 0; f < g_dmabuf_nfmt; f++)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
entries[n].format = g_dmabuf_fmts[f].fmt;
entries[n].pad = 0;
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
n++;
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
if (write(fd, entries, size) != (ssize_t)size) {
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
close(fd);
return;
}
/* The client mmaps this read-only and trusts it not to change under it. */
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
/* One tranche: our device, every pair in the table, no scanout flag. */
static void dmabuf_feedback_send(struct wl_resource *fb) {
struct wl_array dev, idx;
uint16_t *ind;
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
wl_array_init(&dev);
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
wl_array_release(&dev);
wl_array_init(&idx);
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
wl_array_release(&idx);
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
zwp_linux_dmabuf_feedback_v1_send_done(fb);
}
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
.destroy = dmabuf_feedback_destroy,
};
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
wl_resource_get_version(parent), id);
if (!fb) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
dmabuf_feedback_send(fb);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct client_info *ci = client_info_of(c);
if (ci) ci->asked_feedback = 1;
dmabuf_new_feedback(c, r, id);
}
/* Per-surface feedback would let us hint a different tranche for a window on its own display
* layer; we have nothing better to say per surface, so it is the default one. */
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface) {
dmabuf_new_feedback(c, r, id);
}
static void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
/* From version 4 the format and modifier events are gone: the client asks for feedback
* instead, and sending both would only confuse it about which list is authoritative. */
if (ver >= 4) return;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
if (ver >= 3)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
}
}
/* ------------------------------------------------------------------ wl_output */
/* wl_output v3+ has a request (release); without an implementation the first client to send it
@@ -0,0 +1,76 @@
/*
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
*/
#pragma once
#include <stdint.h>
#include <sys/types.h>
#include <android/log.h>
#include <wayland-server.h>
#include "vk_present.h"
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
#ifndef WLOGE
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
#endif
/* ------------------------------------------------------------------ dmabuf buffers */
#define FOURCC(a, b, c, d) \
((uint32_t)(a) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
#define DRM_ARGB8888 FOURCC('A', 'R', '2', '4')
#define DRM_XRGB8888 FOURCC('X', 'R', '2', '4')
#define DRM_ABGR8888 FOURCC('A', 'B', '2', '4')
#define DRM_XBGR8888 FOURCC('X', 'B', '2', '4')
#define MOD_LINEAR VKP_MOD_LINEAR
#define MOD_INVALID VKP_MOD_INVALID
#define MAX_PLANES 4
struct dmabuf_params {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
uint64_t modifier[MAX_PLANES];
int n_planes;
};
struct dmabuf_buffer {
int fd[MAX_PLANES];
uint32_t offset[MAX_PLANES], stride[MAX_PLANES];
int n_planes;
int32_t width, height;
uint32_t format;
uint64_t modifier;
struct vkp_image *img; /* imported once, reused for every frame */
int import_failed;
/* One reference for the wl_buffer resource, one per surface showing the buffer. Mesa destroys
* a swapchain's wl_buffers the moment the game rebuilds its swapchain, i.e. while the last
* committed one is still what is on screen: the import (and the dma-buf memory it pins) stays
* until the surface commits something newer, so the picture never blinks to black. */
int refs;
void *ahb_state; /* zero-copy: ahb_swapchain.c's record (the game's AHardwareBuffer) */
};
/* Which program each Wayland client is (from /proc/<pid>/cmdline), for the session log. */
struct client_info {
struct wl_client *client;
struct wl_listener destroy;
pid_t pid;
char name[64];
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
/* ---- compositor.c */
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
/* ---- wl_dmabuf.c */
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
COMPOSITOR_INTERNAL void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id);
@@ -0,0 +1,334 @@
/*
* zwp_linux_dmabuf_v1: the buffer params a client builds a dma-buf wl_buffer from, the format and
* modifier table the renderer can import, and dma-buf feedback (version 4). Split out of
* compositor.c; the buffer records themselves are in compositor_internal.h.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <wayland-server.h>
#include "linux-dmabuf-v1-server-protocol.h"
#include "vk_present.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "compositor_internal.h"
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
static void dbuf_buffer_resource_destroy(struct wl_resource *r) {
dmabuf_buffer_unref(wl_resource_get_user_data(r));
}
static void params_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void params_add(struct wl_client *c, struct wl_resource *r, int32_t fd, uint32_t plane,
uint32_t offset, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (plane >= MAX_PLANES) { close(fd); return; }
if (p->fd[plane] >= 0) close(p->fd[plane]);
p->fd[plane] = fd;
p->offset[plane] = offset;
p->stride[plane] = stride;
p->modifier[plane] = ((uint64_t)mod_hi << 32) | mod_lo;
if ((int)plane + 1 > p->n_planes) p->n_planes = plane + 1;
}
static struct wl_resource *params_do_create(struct wl_client *c, struct wl_resource *r, uint32_t id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
struct dmabuf_buffer *b = calloc(1, sizeof(*b));
if (!b) return NULL;
b->n_planes = p->n_planes;
b->refs = 1; /* the wl_buffer resource's */
b->width = w; b->height = h; b->format = format;
b->modifier = p->modifier[0];
for (int i = 0; i < MAX_PLANES; i++) b->fd[i] = -1;
for (int i = 0; i < p->n_planes; i++) {
b->fd[i] = p->fd[i];
b->offset[i] = p->offset[i];
b->stride[i] = p->stride[i];
p->fd[i] = -1; /* ownership moves to the buffer */
}
struct wl_resource *buf = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!buf) {
for (int i = 0; i < b->n_planes; i++) if (b->fd[i] >= 0) close(b->fd[i]);
free(b);
wl_client_post_no_memory(c);
return NULL;
}
wl_resource_set_implementation(buf, &dbuf_buffer_impl, b, dbuf_buffer_resource_destroy);
struct client_info *ci = client_info_of(c);
if (ci) ci->dmabuf_buffers++;
return buf;
}
static void params_create(struct wl_client *c, struct wl_resource *r, int32_t w, int32_t h,
uint32_t format, uint32_t flags) {
struct wl_resource *buf = params_do_create(c, r, 0, w, h, format, flags);
if (buf) zwp_linux_buffer_params_v1_send_created(r, buf);
else zwp_linux_buffer_params_v1_send_failed(r);
}
static void params_create_immed(struct wl_client *c, struct wl_resource *r, uint32_t buffer_id,
int32_t w, int32_t h, uint32_t format, uint32_t flags) {
params_do_create(c, r, buffer_id, w, h, format, flags);
}
static const struct zwp_linux_buffer_params_v1_interface params_impl = {
.destroy = params_destroy,
.add = params_add,
.create = params_create,
.create_immed = params_create_immed,
};
static void params_resource_destroy(struct wl_resource *r) {
struct dmabuf_params *p = wl_resource_get_user_data(r);
if (!p) return;
for (int i = 0; i < MAX_PLANES; i++)
if (p->fd[i] >= 0) close(p->fd[i]);
free(p);
}
static void dmabuf_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct dmabuf_params *p = calloc(1, sizeof(*p));
if (!p) { wl_client_post_no_memory(c); return; }
for (int i = 0; i < MAX_PLANES; i++) p->fd[i] = -1;
struct wl_resource *pr = wl_resource_create(c, &zwp_linux_buffer_params_v1_interface,
wl_resource_get_version(r), id);
if (!pr) { free(p); wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(pr, &params_impl, p, params_resource_destroy);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id);
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface);
static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
.destroy = dmabuf_destroy,
.create_params = dmabuf_create_params,
.get_default_feedback = dmabuf_get_default_feedback,
.get_surface_feedback = dmabuf_get_surface_feedback,
};
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
* takes the first format it sees - gets exactly the table it always had. */
#define DMABUF_NFMT_MAX 6
#define DMABUF_NFMT_SDR 4
#define DMABUF_NMOD 4
#define DRM_ABGR2101010 FOURCC('A', 'B', '3', '0')
#define DRM_XBGR2101010 FOURCC('X', 'B', '3', '0')
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT_MAX] = {
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}, {DRM_ABGR2101010}, {DRM_XBGR2101010}};
static int g_dmabuf_nfmt = DMABUF_NFMT_SDR; /* rows advertised: the SDR four, + 2 with the HDR gate open */
static int g_dmabuf_fmts_ready;
static void dmabuf_build_feedback(void);
static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
/* LINEAR first: it is the layout every client and the shm fallback agree on, and the one
* we advertised before this table existed. */
g_dmabuf_fmts[f].mods[k++] = MOD_LINEAR;
for (int i = 0; i < n && k < DMABUF_NMOD - 1; i++) {
if (got[i] == MOD_LINEAR) continue;
if (got[i] == VKP_MOD_QCOM_COMPRESSED && !g_ubwc) continue;
g_dmabuf_fmts[f].mods[k++] = got[i];
if (got[i] == VKP_MOD_QCOM_COMPRESSED) compressed++;
}
g_dmabuf_fmts[f].mods[k++] = MOD_INVALID;
g_dmabuf_fmts[f].n = k;
uint32_t fmt = g_dmabuf_fmts[f].fmt;
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%c%c%c%c", f ? ", " : "",
fmt & 0xff, (fmt >> 8) & 0xff, (fmt >> 16) & 0xff, (fmt >> 24) & 0xff);
for (int i = 0; i < k - 1 && pos < (int)sizeof(line); i++)
pos += snprintf(line + pos, sizeof(line) - (size_t)pos, "%s%s", i ? "+" : " ",
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
* for 10-bit, since a gralloc buffer it cannot import is shown on the display layer only. */
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
}
dmabuf_build_feedback();
}
/* ---- dmabuf feedback (zwp_linux_dmabuf_v1 version 4)
*
* Turnip's Vulkan WSI is happy with the version 3 format/modifier events, so every Vulkan game
* worked while we only advertised 3. Mesa's EGL is not: its Wayland platform only takes the GPU
* (kopper/Zink) path when it can bind this interface with FEEDBACK, and with 3 it silently drops
* to its wl_shm software path. On this Proton layer that path has no software rasteriser to fall
* back to (the gallium build is zink+kopper+swrast, no llvmpipe), so the shm buffer it commits is
* never written: a native OpenGL window came out solid black, ~30 shm commits/s and no GPU frame
* at all (Wizardry: The Labyrinth of Lost Souls). Feedback is what makes that path work.
*
* What a client needs from us is one tranche describing "everything the compositor can import":
* a format table it mmaps read-only, the device to allocate on, and the indices it may use.
* Clients binding versions 1-3 keep getting the old format/modifier events instead. */
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
#ifndef F_ADD_SEALS
#define F_ADD_SEALS 1033
#define F_SEAL_SEAL 0x0001
#define F_SEAL_SHRINK 0x0002
#define F_SEAL_GROW 0x0004
#define F_SEAL_WRITE 0x0008
#endif
struct dmabuf_fmt_entry { uint32_t format; uint32_t pad; uint64_t modifier; }; /* the wire layout */
static int g_fmt_table_fd = -1; /* sealed read-only memfd of dmabuf_fmt_entry[] */
static size_t g_fmt_table_size;
static uint16_t g_fmt_table_n; /* entries, == the indices a tranche may name */
dev_t g_main_device; /* the render node clients should allocate on */
/* The GPU we import through is reached with KGSL, not DRM, so there is no render node of our own
* to name. Clients only use main_device to match "the same device as the compositor" and to pick
* a driver; the one DRM render node this platform has is the right answer, and Zink ignores it
* anyway (it renders on the Vulkan device it already has). 0 if the platform has none. */
static dev_t dmabuf_render_node(void) {
static const char *nodes[] = {"/dev/dri/renderD128", "/dev/dri/renderD129", "/dev/dri/card0"};
struct stat st;
if (g_no_render_node) return 0;
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++)
if (!stat(nodes[i], &st) && S_ISCHR(st.st_mode)) return st.st_rdev;
return 0;
}
static void dmabuf_build_feedback(void) {
struct dmabuf_fmt_entry entries[DMABUF_NFMT_MAX * DMABUF_NMOD];
int n = 0;
g_main_device = dmabuf_render_node();
for (int f = 0; f < g_dmabuf_nfmt; f++)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++) {
if (g_dmabuf_fmts[f].mods[m] == MOD_INVALID) continue; /* never offer INVALID here */
entries[n].format = g_dmabuf_fmts[f].fmt;
entries[n].pad = 0;
entries[n].modifier = g_dmabuf_fmts[f].mods[m];
n++;
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
if (write(fd, entries, size) != (ssize_t)size) {
WLOGE("dmabuf feedback: could not write the format table (%s)", strerror(errno));
close(fd);
return;
}
/* The client mmaps this read-only and trusts it not to change under it. */
fcntl(fd, F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
/* One tranche: our device, every pair in the table, no scanout flag. */
static void dmabuf_feedback_send(struct wl_resource *fb) {
struct wl_array dev, idx;
uint16_t *ind;
if (g_fmt_table_fd < 0) { zwp_linux_dmabuf_feedback_v1_send_done(fb); return; }
zwp_linux_dmabuf_feedback_v1_send_format_table(fb, g_fmt_table_fd, (uint32_t)g_fmt_table_size);
wl_array_init(&dev);
memcpy(wl_array_add(&dev, sizeof(dev_t)), &g_main_device, sizeof(dev_t));
zwp_linux_dmabuf_feedback_v1_send_main_device(fb, &dev);
zwp_linux_dmabuf_feedback_v1_send_tranche_target_device(fb, &dev);
wl_array_release(&dev);
wl_array_init(&idx);
ind = wl_array_add(&idx, (size_t)g_fmt_table_n * sizeof(uint16_t));
if (ind) for (uint16_t i = 0; i < g_fmt_table_n; i++) ind[i] = i;
zwp_linux_dmabuf_feedback_v1_send_tranche_formats(fb, &idx);
wl_array_release(&idx);
zwp_linux_dmabuf_feedback_v1_send_tranche_flags(fb, 0);
zwp_linux_dmabuf_feedback_v1_send_tranche_done(fb);
zwp_linux_dmabuf_feedback_v1_send_done(fb);
}
static void dmabuf_feedback_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = {
.destroy = dmabuf_feedback_destroy,
};
static void dmabuf_new_feedback(struct wl_client *c, struct wl_resource *parent, uint32_t id) {
struct wl_resource *fb = wl_resource_create(c, &zwp_linux_dmabuf_feedback_v1_interface,
wl_resource_get_version(parent), id);
if (!fb) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
dmabuf_feedback_send(fb);
}
static void dmabuf_get_default_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id) {
struct client_info *ci = client_info_of(c);
if (ci) ci->asked_feedback = 1;
dmabuf_new_feedback(c, r, id);
}
/* Per-surface feedback would let us hint a different tranche for a window on its own display
* layer; we have nothing better to say per surface, so it is the default one. */
static void dmabuf_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id,
struct wl_resource *surface) {
dmabuf_new_feedback(c, r, id);
}
void bind_dmabuf(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, ver, id);
wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL);
if (!g_dmabuf_fmts_ready) dmabuf_build_formats();
/* From version 4 the format and modifier events are gone: the client asks for feedback
* instead, and sending both would only confuse it about which list is authoritative. */
if (ver >= 4) return;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
zwp_linux_dmabuf_v1_send_format(r, g_dmabuf_fmts[f].fmt);
if (ver >= 3)
for (int m = 0; m < g_dmabuf_fmts[f].n; m++)
zwp_linux_dmabuf_v1_send_modifier(r, g_dmabuf_fmts[f].fmt,
(uint32_t)(g_dmabuf_fmts[f].mods[m] >> 32),
(uint32_t)(g_dmabuf_fmts[f].mods[m] & 0xffffffff));
}
}
@@ -0,0 +1,84 @@
package com.droiddeck.launcher
import android.os.Handler
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentsManager
/**
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
* catalog, downloads and the swap/restore actions. The launcher screen keeps one and shows it.
*/
internal class ComponentsMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
var compComp by mutableStateOf("dxvk")
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
fun refreshComponents(snapshotFirst: Boolean = false) {
Thread({
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(activity) }
runCatching { ComponentsManager.applyQueued(activity) }
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
}
}, "components").start()
}
fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compBusy = null
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-action").start()
}
fun refreshComponentCatalog() {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(activity, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(activity, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
}
fun downloadComponent(item: ComponentsManager.CatalogItem) {
if (compDownloads.containsKey(item.file)) return
compDownloads = compDownloads + (item.file to -1)
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(activity, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
}, "components-download").start()
}
private companion object {
const val TAG = "MainActivity"
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionState
/**
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
* in progress. The launcher screen keeps one and shows it.
*/
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
var deckySupervisor by mutableStateOf(false)
var deckyReleaseRequest = 0
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
// Use the newest compatible stable build, or the newest compatible
// prerelease when the fork has not published a stable ARM64 asset.
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(activity, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(activity)
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-decky").start()
}
}
@@ -0,0 +1,217 @@
package com.droiddeck.launcher
import android.app.Activity
import android.content.Context
import java.io.File
import android.net.Uri
import android.provider.OpenableColumns
import android.os.Handler
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.DriverRow
/**
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
* rows to the mode settings dialog.
*/
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
var releaseChecking by mutableStateOf(false)
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
var linuxSteam by mutableStateOf("")
var linuxDesktop by mutableStateOf("")
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
).joinToString(" · "),
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
val td = TurnipDriver(activity)
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(activity)
refreshReleaseRows()
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
*/
fun importDriver(uri: Uri, linux: Boolean) {
val name = activity.displayNameOf(uri)
Thread({
val problem = try {
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
else TurnipDriver(activity).installFromZip(uri, name)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
}
ui.post {
android.widget.Toast.makeText(
activity, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
fun deleteDriver(id: String, linux: Boolean) {
if (linux) {
LinuxVulkanDriverManager(activity).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
}
} else {
val td = TurnipDriver(activity)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
if (SessionPrefs.androidDriver(activity) == id) SessionPrefs.setAndroidDriver(activity, TurnipDriver.AUTO)
}
TurnipReleases.forget(activity, id)
android.widget.Toast.makeText(activity, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
/** The download entries and the refresh line, from what the last check found. */
fun refreshReleaseRows() {
val check = TurnipReleases.cached(activity)
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
}
}
/** Only when the user taps refresh: nothing goes online on its own. */
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
}
ui.post {
releaseChecking = false
refreshReleaseRows()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
}
/** Download one release driver and import it through the same importer a picked zip uses. */
fun downloadReleaseDriver(assetName: String) {
val asset = TurnipReleases.cached(activity)?.assets?.firstOrNull { it.name == assetName } ?: return
if (releaseProgress.containsKey(assetName)) return
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(activity, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else TurnipDriver(activity).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(activity, asset, id)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
}
}, "download-turnip").start()
}
private companion object {
const val TAG = "MainActivity"
}
}
internal fun Context.displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
} catch (e: Exception) {
null
}
@@ -9,7 +9,6 @@ import java.io.File
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.net.Uri
import android.provider.OpenableColumns
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -26,10 +25,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
@@ -44,7 +40,6 @@ import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
@@ -54,7 +49,6 @@ import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.ConfirmDialog
import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
@@ -82,6 +76,10 @@ import com.droiddeck.launcher.input.SecondScreenDisplays
*/
class MainActivity : ComponentActivity() {
private val ui = Handler(Looper.getMainLooper())
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val protons = ProtonMenu(this, ui)
// The screen's state. Compose redraws whatever reads these when they change.
private var installed by mutableStateOf<String?>(null)
@@ -102,21 +100,6 @@ class MainActivity : ComponentActivity() {
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var compSnapshot by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
private var compCatalogAt by mutableStateOf(0L)
private var compProton by mutableStateOf<String?>(null)
private var compComp by mutableStateOf("dxvk")
private var compChecking by mutableStateOf(false)
private var compBusy by mutableStateOf<String?>(null)
private var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
private var deckyInstalled by mutableStateOf<String?>(null)
private var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
private var deckyChecking by mutableStateOf(false)
private var deckyStage by mutableStateOf<String?>(null)
private var deckyPercent by mutableIntStateOf(-1)
private var deckySupervisor by mutableStateOf(false)
private var deckyReleaseRequest = 0
private var showMapping by mutableStateOf(false)
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
@@ -128,10 +111,6 @@ class MainActivity : ComponentActivity() {
private var desktopInstalled by mutableStateOf(false)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
private var protonBusyId by mutableStateOf<String?>(null)
private var protonStage by mutableStateOf<String?>(null)
private var protonPercent by mutableIntStateOf(-1)
private var showPerformance by mutableStateOf(false)
private var clientOverride by mutableStateOf(false)
private var clientCores by mutableStateOf<Set<Int>>(emptySet())
@@ -148,19 +127,6 @@ class MainActivity : ComponentActivity() {
private var forceFullscreen by mutableStateOf(true)
private var launcherFullscreen by mutableStateOf(true)
private var mic by mutableStateOf(false)
private var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
/** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */
private var linuxDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
private var androidDownloads by mutableStateOf<List<com.droiddeck.launcher.ui.DownloadRow>>(emptyList())
private var releaseStatus by mutableStateOf("Not checked yet - tap refresh to look for new drivers")
private var releaseChecking by mutableStateOf(false)
private var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
private var linuxSteam by mutableStateOf("")
private var linuxDesktop by mutableStateOf("")
private var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
private var androidSelected by mutableStateOf("")
// The app's own picker (files/), once per kind of pick: the two driver lists validate
// differently, and the reason a zip is refused names the list it belongs in.
@@ -168,10 +134,10 @@ class MainActivity : ComponentActivity() {
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) }
}
private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = true) }
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = true) }
}
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = false) }
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
@@ -249,7 +215,7 @@ class MainActivity : ComponentActivity() {
override fun startActivity(intent: Intent?) {
if (intent?.component?.className == SessionActivity::class.java.name) {
when {
protonBusyId != null || ProtonExtras.installInProgress -> {
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
return
}
@@ -543,7 +509,7 @@ class MainActivity : ComponentActivity() {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
compComp = all[(all.indexOf(compComp).coerceAtLeast(0) + step) % all.size]
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
}
return true
}
@@ -559,7 +525,7 @@ class MainActivity : ComponentActivity() {
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (showComponents) refreshComponents()
if (showComponents) components.refreshComponents()
}
}, "components-queue").start()
oscMode = SessionPrefs.oscMode(this)
@@ -567,9 +533,9 @@ class MainActivity : ComponentActivity() {
refreshHomeAppState()
refreshSecondScreenDisplays()
refresh()
deckyInstalled = DeckyManager.installed(this)
decky.deckyInstalled = DeckyManager.installed(this)
DeckyManager.syncCefMarker(this)
deckySupervisor = DeckyManager.supervisorEnabled(this)
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
// Added games' art (a store lookup for what the folders lack) starts here, not only when
// the cog opens.
refreshAddedGames()
@@ -638,38 +604,7 @@ class MainActivity : ComponentActivity() {
showComponents = false
showMapping = false
showProtons = true
refreshProtons()
}
private fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(this) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
// Use the newest compatible stable build, or the newest compatible
// prerelease when the fork has not published a stable ARM64 asset.
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
}, "decky-releases").start()
}
private fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = "Starting…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.install(this, release) { label, value -> ui.post { deckyStage = label; deckyPercent = value } }
}.getOrElse { error -> "Decky install failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1; deckyInstalled = DeckyManager.installed(this)
if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-decky").start()
protons.refreshProtons()
}
private fun refreshController() {
@@ -694,69 +629,12 @@ class MainActivity : ComponentActivity() {
showProtons = false
showMapping = false
showComponents = true
refreshComponents(snapshotFirst = true)
}
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
private fun refreshComponents(snapshotFirst: Boolean = false) {
Thread({
if (snapshotFirst) runCatching { ComponentsManager.snapshotAll(this) }
runCatching { ComponentsManager.applyQueued(this) }
val snap = runCatching { ComponentsManager.snapshot(this) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(this, false) }.getOrNull()
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
}
}, "components").start()
}
private fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshComponents()
}
}, "components-action").start()
}
private fun refreshComponentCatalog() {
if (compChecking) return
compChecking = true
Thread({
val cat = runCatching { ComponentsManager.catalog(this, true) }.getOrNull()
ui.post {
compChecking = false
if (cat == null || cat.items.isEmpty()) android.widget.Toast.makeText(this, "The Nightlies could not be reached", android.widget.Toast.LENGTH_LONG).show()
else { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
}
}, "components-catalog").start()
}
private fun downloadComponent(item: ComponentsManager.CatalogItem) {
if (compDownloads.containsKey(item.file)) return
compDownloads = compDownloads + (item.file to -1)
Thread({
val message = runCatching {
val pkg = ComponentsManager.download(this, item) { pc -> ui.post { if (compDownloads.containsKey(item.file)) compDownloads = compDownloads + (item.file to pc) } }
"Stored ${pkg.version}"
}.getOrElse { e -> "Download failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
compDownloads = compDownloads - item.file
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
refreshComponents()
}
}, "components-download").start()
components.refreshComponents(snapshotFirst = true)
}
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
componentAction("Importing") {
components.componentAction("Importing") {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error("cannot read the file")
try { "Imported ${ComponentsManager.importPackage(this, tmp, name).version}" } finally { tmp.delete() }
@@ -766,24 +644,24 @@ class MainActivity : ComponentActivity() {
@Composable
private fun ComponentsHost() {
ComponentsPage(
snapshot = compSnapshot,
catalog = compCatalog,
catalogAt = compCatalogAt,
protonId = compProton,
comp = compComp,
checking = compChecking,
busy = compBusy,
downloads = compDownloads,
snapshot = components.compSnapshot,
catalog = components.compCatalog,
catalogAt = components.compCatalogAt,
protonId = components.compProton,
comp = components.compComp,
checking = components.compChecking,
busy = components.compBusy,
downloads = components.compDownloads,
requestInitialFocus = focusComponentsContent,
onProton = { compProton = it },
onComp = { compComp = it },
onSwap = { file -> compProton?.let { pid -> componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> compProton?.let { pid -> componentAction("Restoring") { ComponentsManager.restore(this, pid, compComp, version) } } },
onCancelQueued = { compProton?.let { pid -> componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> compProton?.let { pid -> componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, compComp, version) } } },
onDownload = { downloadComponent(it) },
onRefresh = { refreshComponentCatalog() },
onProton = { components.compProton = it },
onComp = { components.compComp = it },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction("Cancelling") { ComponentsManager.cancelQueued(this, pid, components.compComp); "The waiting swap was cancelled." } } },
onDeletePackage = { file -> components.componentAction("Deleting") { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction("Deleting") { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
)
@@ -792,56 +670,19 @@ class MainActivity : ComponentActivity() {
@Composable
private fun ProtonHost() {
ProtonPage(
rows = protonRows,
busyId = protonBusyId,
stage = protonStage,
percent = protonPercent,
rows = protons.protonRows,
busyId = protons.protonBusyId,
stage = protons.protonStage,
percent = protons.protonPercent,
runtimeReady = ready,
sessionRunning = SessionState.running,
onInstall = { id -> installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; refreshProtons() },
onRemove = { id -> removeProton(id) },
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id -> protons.removeProton(id) },
onBack = { showProtons = false },
)
}
private fun installProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(this, tool)
protonBusyId = id
protonStage = "Starting…"
protonPercent = -1
Thread({
val problem = ProtonExtras.install(this, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-proton-$id").start()
}
private fun removeProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonPercent = -1
Thread({
ProtonExtras.remove(this, tool)
ui.post {
protonBusyId = null
protonStage = null
refreshProtons()
}
}, "remove-proton-$id").start()
}
private fun refreshPackages() {
packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
@@ -890,9 +731,9 @@ class MainActivity : ComponentActivity() {
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
linuxRows = linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop,
androidRows = androidRows, androidSelected = androidSelected,
linuxRows = drivers.linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
touchMode = touchMode,
suspendPolicy = suspendPolicy,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
@@ -910,27 +751,27 @@ class MainActivity : ComponentActivity() {
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, releaseStatus = releaseStatus,
releaseChecking = releaseChecking, canRestoreBundled = canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) deckyReleases.firstOrNull() else null,
deckyChecking = deckyChecking, deckyStage = deckyStage, deckyPercent = deckyPercent,
deckyEnabled = deckySupervisor, deckySessionRunning = SessionState.running,
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
onRemoveLinux = { id -> deleteDriver(id, linux = true) },
onRefreshReleases = { checkLatestTurnip() },
onDownloadDriver = { name -> downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); refreshDrivers() },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRefreshReleases = { drivers.checkLatestTurnip() },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> deleteDriver(id, linux = false) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -969,13 +810,13 @@ class MainActivity : ComponentActivity() {
pendingAddedGame = folder
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
},
onDeckyInstall = { release -> installDecky(release) },
onDeckyCheck = { refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); deckySupervisor = enabled },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
deckyInstalled = null
deckySupervisor = false
decky.deckyInstalled = null
decky.deckySupervisor = false
},
onDismiss = { settingsMode = null },
),
@@ -1034,7 +875,7 @@ class MainActivity : ComponentActivity() {
showProtons = false
showComponents = false
showMapping = false
refreshDrivers()
drivers.refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
@@ -1057,9 +898,9 @@ class MainActivity : ComponentActivity() {
gameStorage = SessionPrefs.gameStorage(this)
storageOptions = GameStorage.options(this).map { it.label to it.path }
if (mode == SessionService.MODE_STEAM) {
deckyInstalled = DeckyManager.installed(this)
deckySupervisor = DeckyManager.supervisorEnabled(this)
refreshDecky()
decky.deckyInstalled = DeckyManager.installed(this)
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
decky.refreshDecky()
}
settingsMode = mode
}
@@ -1077,178 +918,6 @@ class MainActivity : ComponentActivity() {
gameStorage = path
}
private fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(this)
fun origin(id: String) = if (TurnipReleases.isDownloaded(this, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
linuxRows = LinuxVulkanDriver.optionValues(this).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
lm.getMinGlibc(id).takeIf { it.isNotEmpty() }?.let { "glibc $it+" },
).joinToString(" · "),
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(this, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(this, SessionService.MODE_DESKTOP)
val td = TurnipDriver(this)
val auto = td.autoId()
androidRows = buildList {
add(DriverRow(
TurnipDriver.AUTO, "Auto - picked by GPU",
if (auto == "system") "system Vulkan: no bundled build for this GPU" else "${td.displayName(auto)} (bundled)",
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(this)
refreshReleaseRows()
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
*/
private fun importDriver(uri: Uri, linux: Boolean) {
val name = displayNameOf(uri)
Thread({
val problem = try {
if (linux) LinuxVulkanDriverManager(this).installDriver(uri, name)
else TurnipDriver(this).installFromZip(uri, name)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "driver import", e)
"Import failed: ${e.message}"
}
ui.post {
android.widget.Toast.makeText(
this, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
private fun deleteDriver(id: String, linux: Boolean) {
if (linux) {
LinuxVulkanDriverManager(this).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(this, mode) == id) SessionPrefs.setLinuxDriver(this, mode, "")
}
} else {
val td = TurnipDriver(this)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
if (SessionPrefs.androidDriver(this) == id) SessionPrefs.setAndroidDriver(this, TurnipDriver.AUTO)
}
TurnipReleases.forget(this, id)
android.widget.Toast.makeText(this, "Deleted ${id}", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
}
/** The download entries and the refresh line, from what the last check found. */
private fun refreshReleaseRows() {
val check = TurnipReleases.cached(this)
val lm = LinuxVulkanDriverManager(this)
val td = TurnipDriver(this)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(this, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
}
linuxDownloads = rows(linux = true)
androidDownloads = rows(linux = false)
if (!releaseChecking) releaseStatus = when (check) {
null -> "Not checked yet - tap refresh to look for new drivers"
else -> "Latest: " + check.latest.joinToString(" · ") { "${it.first} ${it.second}" } +
(if (check.failed.isEmpty()) "" else " · ${check.failed.joinToString()} unreachable") +
" · checked ${ago(check.checkedAt)}"
}
}
private fun ago(t: Long): String {
val m = ((System.currentTimeMillis() - t) / 60_000).coerceAtLeast(0)
return when {
m < 1 -> "just now"
m < 60 -> "$m min ago"
m < 48 * 60 -> "${m / 60} h ago"
else -> "${m / (24 * 60)} days ago"
}
}
/** Only when the user taps refresh: nothing goes online on its own. */
private fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
Thread({
val problem = try { TurnipReleases.refresh(this); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
}
ui.post {
releaseChecking = false
refreshReleaseRows()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
}
/** Download one release driver and import it through the same importer a picked zip uses. */
private fun downloadReleaseDriver(assetName: String) {
val asset = TurnipReleases.cached(this)?.assets?.firstOrNull { it.name == assetName } ?: return
if (releaseProgress.containsKey(assetName)) return
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(this, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(this).installDriver(uri, asset.name)
else TurnipDriver(this).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(this, asset, id)
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
android.widget.Toast.makeText(
this, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
}
}, "download-turnip").start()
}
private fun displayNameOf(uri: Uri): String? = if (uri.scheme == "file") uri.lastPathSegment else try {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
} catch (e: Exception) {
null
}
/** The two masks as the dialog shows them; an empty stored list shows as every core ticked. */
private fun refreshCores() {
clientOverride = SessionPrefs.clientCpusOverride(this)
@@ -1264,11 +933,8 @@ class MainActivity : ComponentActivity() {
refreshPhantomStatus()
}
private fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(this, it), ProtonExtras.queued(this, it)) }
}
private fun refresh() {
if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
@@ -1354,17 +1020,28 @@ class MainActivity : ComponentActivity() {
}
private fun install(release: LinuxRuntimeInstaller.Release) {
// The service keeps the process alive if the user switches away; this screen joins the
// same install (or starts it, if it gets there first) to show the progress.
com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release)
followInstall { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
}
/**
* Shows an install's progress until it ends. [run] either starts one or joins the one already
* running (null: nothing was), which is how a launcher rebuilt mid-install picks it back up.
*/
private fun followInstall(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = "Starting…"
percent = -1
Thread({
val ok = LinuxRuntimeInstaller.install(this, release) { s, p ->
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
ui.post { stage = s; percent = p }
}
})
ui.post {
busy = false
failed = !ok
if (ok != null) failed = !ok
refresh()
}
}, "install").start()
@@ -0,0 +1,62 @@
package com.droiddeck.launcher
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.ui.ProtonRow
import com.droiddeck.launcher.session.SessionState
/**
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
* removal in progress. The launcher screen keeps one and shows it.
*/
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
var protonBusyId by mutableStateOf<String?>(null)
var protonStage by mutableStateOf<String?>(null)
var protonPercent by mutableIntStateOf(-1)
fun installProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
ProtonExtras.unqueue(activity, tool)
protonBusyId = id
protonStage = "Starting…"
protonPercent = -1
Thread({
val problem = ProtonExtras.install(activity, tool) { label, value ->
ui.post { protonStage = label; protonPercent = value }
}
ui.post {
protonBusyId = null
protonStage = null
protonPercent = -1
refreshProtons()
if (problem != null) android.widget.Toast.makeText(activity, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "install-proton-$id").start()
}
fun removeProton(id: String) {
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
if (protonBusyId != null || SessionState.running) return
protonBusyId = id
protonStage = "Removing ${tool.name}…"
protonPercent = -1
Thread({
ProtonExtras.remove(activity, tool)
ui.post {
protonBusyId = null
protonStage = null
refreshProtons()
}
}, "remove-proton-$id").start()
}
fun refreshProtons() {
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
}
}
@@ -252,118 +252,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
readPrefs()
// One Compose layer for every menu and overlay. Touches nothing in it consumes fall
// through to the pad and the game underneath.
sessionOverlay = ComposeView(this).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
} else {
releaseDrawerDirection()
}
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app, displayId ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
))
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
}
}
}
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
setContentView(root)
handleHomeGuideIntent(intent)
@@ -393,6 +282,121 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
watchSession()
}
/**
* One Compose layer for every menu and overlay. Touches nothing in it consumes fall through to
* the pad and the game underneath.
*/
private fun createSessionOverlay(): ComposeView = ComposeView(this).apply {
isFocusable = true
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(
loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended,
title = loadingTitle(), steam = loadingMode() == SessionService.MODE_STEAM,
// As the drawer's Stop does: the service stops, and a start still installing is cancelled.
onCancel = { SessionService.stop(this@SessionActivity); finish() },
)
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}")
} else {
releaseDrawerDirection()
}
}
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
readPrefs()
applyFrameGen()
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
backActionsInverted = inverted
},
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
onSuspendPolicy = { policy ->
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
suspendPolicy = policy
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
onLaunchAndroidApp = { app, displayId ->
drawerOpen = false
try {
HomeApp.launch(this@SessionActivity, app, displayId)
} catch (_: Exception) {
val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen"
else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId"
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
))
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
}
}
}
private fun refreshHomeApp() {
isHomeApp = HomeApp.isDefault(this)
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
@@ -0,0 +1,25 @@
package com.droiddeck.launcher.core;
import java.io.File;
import java.io.IOException;
/**
* Where an archive entry may land. An entry name ("../x", "/etc/x") or a symlink already written
* by an earlier entry can point outside the directory being filled; the canonical path follows
* both, so anything that does not resolve inside it is refused.
*/
public final class ArchivePaths {
private ArchivePaths() {}
/**
* The file for {@code name} under {@code destination}, or null when it would resolve outside
* it. The destination itself counts as inside (a "./" entry).
*/
public static File inside(File destination, String name) throws IOException {
String base = destination.getCanonicalPath();
File file = new File(destination, name);
String path = file.getCanonicalPath();
if (path.equals(base) || path.startsWith(base + File.separator)) return file;
return null;
}
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.core
import java.io.File
import java.security.MessageDigest
/** File checksums as lowercase hex, and GitHub's release-asset digests, in one place. */
object Hashes {
private val GITHUB_SHA256 = Regex("(?i)sha256:[0-9a-f]{64}")
@JvmStatic
fun sha256(file: File): String = hex(file, "SHA-256")
@JvmStatic
fun sha512(file: File): String = hex(file, "SHA-512")
/** True when [digest] is GitHub's asset digest form, "sha256:" and 64 hex digits. */
@JvmStatic
fun isGithubSha256(digest: String?): Boolean = digest != null && digest.matches(GITHUB_SHA256)
/** The hex of a GitHub asset digest ("sha256:<hex>"), or null when [digest] is not one. */
@JvmStatic
fun githubSha256(digest: String?): String? = digest?.takeIf { isGithubSha256(it) }?.substringAfter(':')
private fun hex(file: File, algorithm: String): String {
val digest = MessageDigest.getInstance(algorithm)
file.inputStream().buffered(1 shl 16).use { input ->
val buffer = ByteArray(1 shl 20)
while (true) {
val n = input.read(buffer)
if (n < 0) break
digest.update(buffer, 0, n)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
}
@@ -37,11 +37,10 @@ public final class TarZst {
destination.mkdirs();
try (InputStream in = new ZstdCompressorInputStream(new BufferedInputStream(source, 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
String base = destination.getCanonicalPath() + File.separator;
TarArchiveEntry entry;
while ((entry = tar.getNextTarEntry()) != null) {
File file = new File(destination, entry.getName());
if (!file.getCanonicalPath().startsWith(base)) continue;
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) continue;
if (entry.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
file.mkdirs();
@@ -0,0 +1,93 @@
package com.droiddeck.launcher.files
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import java.io.File
// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr).
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
internal data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
internal fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
internal fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
@@ -0,0 +1,599 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's rows, tiles, favourites and context menu.
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
internal fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.style.TextOverflow
import java.io.File
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
internal fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
internal fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
@@ -7,12 +7,7 @@ import androidx.compose.foundation.Image
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
private fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
private fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
private fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
private data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
private fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
private fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1414,675 +1197,3 @@ fun FileManagerScreen(
} // end rail + content Row
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
private fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
private fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.files
import android.widget.Toast
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's properties dialog.
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
internal fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONArray
@@ -27,7 +28,9 @@ object TurnipReleases {
private const val KEY_DOWNLOADS = "downloads"
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String)
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
val sha256: String = "")
/** [latest] = each source's newest release tag, for the refresh line. */
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
@@ -89,11 +92,13 @@ object TurnipReleases {
val a = list.getJSONObject(j)
val name = a.getString("name")
val (linux, label) = src.classify(name, tag) ?: continue
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
// Newest first: the first release carrying a variant is the one offered.
if (!seen.add("$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label))
.put("linux", linux).put("label", label).put("sha256", sha))
}
}
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
@@ -131,7 +136,7 @@ object TurnipReleases {
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label")))
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
}
val latest = ArrayList<Pair<String, String>>()
val l = json.optJSONArray("latest") ?: JSONArray()
@@ -167,6 +172,7 @@ object TurnipReleases {
/** Download an asset into the cache; the caller imports it and deletes the file. */
fun download(context: Context, asset: Asset, progress: (Int) -> Unit): File {
if (asset.sha256.isEmpty()) throw IOException("This driver list predates checksums - refresh it and try again")
val target = File(context.cacheDir, asset.name)
val c = URL(asset.url).openConnection() as HttpURLConnection
c.connectTimeout = 15_000
@@ -191,6 +197,9 @@ object TurnipReleases {
}
}
}
if (!asset.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
throw IOException("Checksum mismatch - the download was discarded")
}
return target
} catch (e: Exception) {
FileUtils.delete(target)
@@ -1,12 +1,11 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
import org.json.JSONArray
/** Optional Decky Loader installation for the ARM64 Linux guest. */
@@ -84,7 +83,7 @@ object DeckyManager {
val n = it.optString("name")
(n == "$name.sha256" || n == "$name.sha256sum" || n == "$name.sha256.txt") && it.optString("browser_download_url").startsWith("https://")
}?.optString("browser_download_url")?.takeIf { it.startsWith("https://") }
val digest = binary.optString("digest").takeIf { it.matches(Regex("(?i)sha256:[0-9a-f]{64}")) }
val digest = binary.optString("digest").takeIf { Hashes.isGithubSha256(it) }
if (digest == null && checksum == null) continue
val size = binary.optLong("size", 0)
if (size <= 0) continue
@@ -109,7 +108,7 @@ object DeckyManager {
Regex("(?i)\\b[0-9a-f]{64}\\b").find(checksumBody)?.value
} ?: run { temp.delete(); return "Release checksum is missing" }
if (!expected.matches(Regex("(?i)[0-9a-f]{64}"))) { temp.delete(); return "Release checksum is invalid" }
if (!expected.equals(sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!expected.equals(Hashes.sha256(temp), true)) { temp.delete(); return "Decky Loader checksum mismatch" }
if (!validElf(temp, release.machine)) { temp.delete(); return "Release asset is not a compatible 64-bit Linux executable" }
if (SessionState.running) { temp.delete(); return "A session started during the download; stop it before installing Decky Loader" }
if (!temp.setExecutable(true, false)) { temp.delete(); return "Could not mark PluginLoader executable" }
@@ -148,15 +147,6 @@ object DeckyManager {
if (wipeData) File(root, "root/homebrew").deleteRecursively()
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).buffered().use { input ->
val buffer = ByteArray(1024 * 1024)
while (true) { val n = input.read(buffer); if (n < 0) break; digest.update(buffer, 0, n) }
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun validElf(file: File, machine: Int): Boolean = runCatching {
file.inputStream().use { input ->
val h = ByteArray(20)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
@@ -81,6 +82,8 @@ object DesktopCatalog {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return "The Linux runtime is not installed"
// Every catalog row carries a sha256; one without is refused rather than trusted.
if (entry.sha256.isEmpty()) return "The catalog has no checksum for ${entry.name}"
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
listener?.onProgress("Downloading ${entry.name}", 0)
@@ -88,11 +91,9 @@ object DesktopCatalog {
listener?.onProgress("Downloading ${entry.name}", if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return "Download failed"
if (entry.sha256.isNotEmpty()) {
listener?.onProgress("Verifying", -1)
val actual = LinuxRuntimeInstaller.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
}
listener?.onProgress("Verifying", -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return "Checksum mismatch - nothing was changed"
listener?.onProgress("Installing ${entry.name}", -1)
when (entry.kind) {
"appimage" -> {
@@ -4,7 +4,9 @@ import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.core.ArchivePaths;
import com.droiddeck.launcher.core.Downloader;
import com.droiddeck.launcher.core.Hashes;
import com.droiddeck.launcher.core.FileUtils;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@@ -23,7 +25,8 @@ import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
/**
* Downloads and unpacks the Linux runtime rootfs into {@code files/linuxfs}. It is opt-in: nothing
@@ -52,7 +55,7 @@ public final class LinuxRuntimeInstaller {
public final String sha256;
public final long size;
Release(String version, String url, String sha256, long size) {
public Release(String version, String url, String sha256, long size) {
this.version = version;
this.url = url;
this.sha256 = sha256;
@@ -60,6 +63,23 @@ public final class LinuxRuntimeInstaller {
}
}
/**
* One install per process. The launcher and the session screen can both start one, and a
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
* install would share the first one's archive and staging directory and wreck both.
*/
private static final class Job {
final CopyOnWriteArrayList<ProgressListener> listeners = new CopyOnWriteArrayList<>();
final CountDownLatch done = new CountDownLatch(1);
volatile String stage = "Starting\u2026";
volatile int percent = -1;
volatile boolean ok;
}
private static final Object JOB_LOCK = new Object();
private static Job running;
private static boolean removing;
private LinuxRuntimeInstaller() {}
/** The build currently unpacked, or null when the runtime is not installed. */
@@ -104,6 +124,66 @@ public final class LinuxRuntimeInstaller {
* not. An update therefore keeps Steam, the login and the installed games.
*/
public static boolean install(Context context, Release release, ProgressListener listener) {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (removing) return false;
owner = running == null;
if (owner) running = new Job();
job = running;
if (listener != null) job.listeners.add(listener);
}
if (!owner) return join(job, listener);
try {
job.ok = installOnce(context.getApplicationContext(), release, (stage, percent) -> {
job.stage = stage;
job.percent = percent;
for (ProgressListener l : job.listeners) l.onProgress(stage, percent);
});
return job.ok;
} finally {
synchronized (JOB_LOCK) {
running = null;
}
job.done.countDown();
}
}
/** True while an install is running in this process, whoever started it. */
public static boolean isInstalling() {
synchronized (JOB_LOCK) {
return running != null;
}
}
/**
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
* it ends and returns its result, or returns null at once when nothing is installing.
*/
public static Boolean attach(ProgressListener listener) {
Job job;
synchronized (JOB_LOCK) {
job = running;
if (job == null) return null;
if (listener != null) job.listeners.add(listener);
}
return join(job, listener);
}
private static boolean join(Job job, ProgressListener listener) {
try {
if (listener != null) listener.onProgress(job.stage, job.percent);
job.done.await();
return job.ok;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (listener != null) job.listeners.remove(listener);
}
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
try {
if (listener != null) listener.onProgress("Downloading", 0);
@@ -120,7 +200,7 @@ public final class LinuxRuntimeInstaller {
}
if (listener != null) listener.onProgress("Verifying", -1);
String actual = sha256(archive);
String actual = Hashes.sha256(archive);
if (!release.sha256.equalsIgnoreCase(actual)) {
Log.w(TAG, "checksum mismatch: wanted " + release.sha256 + ", got " + actual);
return false;
@@ -177,8 +257,20 @@ public final class LinuxRuntimeInstaller {
}
}
public static void uninstall(Context context) {
FileUtils.delete(LinuxRuntime.rootDir(context));
/** Removes the runtime; returns false without touching it while an install is running. */
public static boolean uninstall(Context context) {
synchronized (JOB_LOCK) {
if (running != null || removing) return false;
removing = true;
}
try {
FileUtils.delete(LinuxRuntime.rootDir(context));
return true;
} finally {
synchronized (JOB_LOCK) {
removing = false;
}
}
}
/**
@@ -193,12 +285,10 @@ public final class LinuxRuntimeInstaller {
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
TarArchiveEntry entry;
String base = destination.getCanonicalPath() + File.separator;
while ((entry = tar.getNextTarEntry()) != null) {
File file = new File(destination, entry.getName());
// Refuse anything that would land outside the runtime directory.
if (!(file.getCanonicalPath() + (entry.isDirectory() ? File.separator : "")).startsWith(base)
&& !file.getCanonicalPath().equals(destination.getCanonicalPath())) {
File file = ArchivePaths.inside(destination, entry.getName());
if (file == null) {
Log.w(TAG, "skipping entry outside the rootfs: " + entry.getName());
continue;
}
@@ -215,7 +305,13 @@ public final class LinuxRuntimeInstaller {
} else if (entry.isLink()) {
// A hard link to an earlier entry. Link where the filesystem allows it and
// fall back to a copy, which costs space but always works.
File target = new File(destination, entry.getLinkName());
// The target is checked too: a link to "../../shared_prefs/x" would pull a
// file from outside the rootfs into it.
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
try {
Files.createLink(file.toPath(), target.toPath());
@@ -244,16 +340,4 @@ public final class LinuxRuntimeInstaller {
return false;
}
}
public static String sha256(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new BufferedInputStream(new FileInputStream(file), 1 << 16)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = in.read(buffer)) != -1) digest.update(buffer, 0, read);
}
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) sb.append(String.format("%02x", b));
return sb.toString();
}
}
@@ -0,0 +1,100 @@
package com.droiddeck.launcher.runtime
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
/**
* Keeps the process in the foreground while the Linux runtime downloads and unpacks. The runtime
* is several hundred megabytes; without a foreground service Android may kill the app the moment
* the user switches away, and the install starts over from the resumable part file.
*
* The install itself is [LinuxRuntimeInstaller.install], which runs one job per process: the
* screen that asked for it joins the same job to show progress, so it does not matter which of the
* two gets there first.
*/
class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification("Starting…", -1))
if (release == null) {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = Int.MIN_VALUE
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
// Every percent at most once: a notification per read would flood the system.
if (percent != shown) {
shown = percent
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
}
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "runtime-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime downloads and installs"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle("Installing the Linux runtime")
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
companion object {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
private const val EXTRA_SIZE = "size"
/** Starts the install of [release] in the foreground; a running install is joined instead. */
fun start(context: Context, release: LinuxRuntimeInstaller.Release) {
val intent = Intent(context, RuntimeInstallService::class.java)
.putExtra(EXTRA_VERSION, release.version)
.putExtra(EXTRA_URL, release.url)
.putExtra(EXTRA_SHA256, release.sha256)
.putExtra(EXTRA_SIZE, release.size)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
val sha256 = intent.getStringExtra(EXTRA_SHA256) ?: return null
return LinuxRuntimeInstaller.Release(version, url, sha256, intent.getLongExtra(EXTRA_SIZE, 0L))
}
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
@@ -16,7 +17,6 @@ import java.io.BufferedOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* FEX, DXVK and VKD3D-Proton per Proton, for the Linux Steam client.
@@ -245,17 +245,8 @@ object ComponentsManager {
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
private fun sha256(f: File): String {
val md = MessageDigest.getInstance("SHA-256")
FileInputStream(f).use { input ->
val buf = ByteArray(1 shl 16)
while (true) { val n = input.read(buf); if (n < 0) break; md.update(buf, 0, n) }
}
return md.digest().joinToString("") { "%02x".format(it.toInt() and 255) }
}
private fun fingerprint(dir: File, comp: String): Map<String, String> =
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { sha256(File(dir, it)) }
compFiles(dir, comp).filter { owned(comp, it) }.associateWith { Hashes.sha256(File(dir, it)) }
// ------------------------------------------------------------------ packages
@@ -316,7 +307,7 @@ object ComponentsManager {
if (rel.endsWith(".so")) tmp.setExecutable(true, false)
tmp.setReadable(true, false)
if (!tmp.renameTo(dst)) { dst.delete(); check(tmp.renameTo(dst)) { "could not place $rel" } }
hashes[rel] = sha256(dst)
hashes[rel] = Hashes.sha256(dst)
}
return hashes
} finally {
@@ -629,6 +620,8 @@ object ComponentsManager {
val a = assets.getJSONObject(i)
val name = a.optString("name")
if (!name.endsWith(".wcp")) continue
// Only packages GitHub has a sha256 for are offered: the download is checked against it.
if (!Hashes.isGithubSha256(a.optString("digest"))) continue
items.put(JSONObject().put("file", name).put("comp", comp).put("release", tag)
.put("url", a.optString("browser_download_url")).put("size", a.optLong("size")).put("digest", a.optString("digest")))
}
@@ -650,13 +643,12 @@ object ComponentsManager {
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
require(Hashes.isGithubSha256(item.digest)) { "This package list predates checksums - refresh it and try again" }
val dest = File(packagesDir(context), safeName(item.file))
val part = File(dest.parentFile, dest.name + ".part")
part.delete()
check(Downloader.downloadFile(item.url, part, false) { f -> progress(if (f < 0) -1 else (f * 100).toInt().coerceIn(0, 100)) }) { "Download failed" }
if (item.digest.startsWith("sha256:")) {
if (!sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
}
if (!Hashes.sha256(part).equals(item.digest.substringAfter(':'), true)) { part.delete(); error("Checksum mismatch") }
val info = runCatching { packageInfo(part) }.getOrElse { part.delete(); throw it }
synchronized(lock) { part.renameTo(dest) }
return info.copy(file = dest.name)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.os.StatFs
import android.util.Log
@@ -7,7 +8,6 @@ import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.security.MessageDigest
import org.json.JSONArray
/** Immediate download and installation of the optional ARM64 Proton builds. */
@@ -21,7 +21,9 @@ object ProtonExtras {
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long)
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
val sha256: String? = null)
val tools = listOf(
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
@@ -99,14 +101,24 @@ object ProtonExtras {
return "Download was incomplete; the partial file is kept for a resumable retry"
}
asset.sha512?.let { checksumUrl ->
onProgress("Verifying download", -1)
val checksumText = Downloader.downloadString(checksumUrl)
val expected = checksumText?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
if (expected != null && !expected.equals(sha512(archive), ignoreCase = true)) {
archive.delete()
return "Checksum mismatch; the download was deleted"
// One checksum has to be found and has to match: GitHub's sha256 digest, else the
// release's own sha512 file. A download neither vouches for is not installed.
onProgress("Verifying download", -1)
val verified = when {
asset.sha256 != null -> asset.sha256.equals(Hashes.sha256(archive), ignoreCase = true)
asset.sha512 != null -> {
val expected = Downloader.downloadString(asset.sha512)?.let { Regex("(?i)\\b[0-9a-f]{128}\\b").find(it)?.value }
?: return "Could not read the release checksum; try again"
expected.equals(Hashes.sha512(archive), ignoreCase = true)
}
else -> {
archive.delete()
return "${tool.name} ${asset.tag} publishes no checksum; nothing was installed"
}
}
if (!verified) {
archive.delete()
return "Checksum mismatch; the download was deleted"
}
if (SessionState.running) return "A session started during the download; stop it before installing compatibility tools"
@@ -170,6 +182,7 @@ object ProtonExtras {
release.optString("tag_name"), name,
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
archive.optLong("size", 0L),
Hashes.githubSha256(archive.optString("digest")),
)
}
null
@@ -179,19 +192,6 @@ object ProtonExtras {
}
}
private fun sha512(file: File): String {
val digest = MessageDigest.getInstance("SHA-512")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(4 * 1024 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
private fun requestLines(context: Context): List<String> =
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
}
@@ -263,150 +263,17 @@ class SessionService : Service() {
killStragglers()
SessionFiles.stage(this, root)
// One folder per session, claimed by whoever started first - the activity starts the
// compositor before this service runs - so the compositor's log lands in the same place.
val sessionDir = SessionPaths.beginOrCurrent(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
SessionState.logFile = sessionLog
SessionState.logDirectory = sessionDir
SessionState.sessionId = sessionDir.name
SessionState.eventsFile = File(sessionDir, "events.jsonl")
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
// Written first, so a session that dies in its first second still says what it ran on.
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
NetworkReport.write(this, File(sessionDir, "network.txt"))
// Everything the app decides from here on - the driver it chose, the audio line, a rival
// client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it.
SessionLogCapture.start(File(sessionDir, "app.log"))
val size = SessionState.outputSize
val guest = ArrayList<String>()
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
// session bundle sit hours off the device's. Bannerlator carries the same line.
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
// Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
// its authors advise for those GPUs and what nothing else in the list needs.
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
// same set-up as a Steam session: it gets what the client and its games are started with.
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (steamHere) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// 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) }
// 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
// sent only when it is a real restriction - a game has no pin of its own to undo.
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
// The game cores also pin the desktop and a program from the rail; without a choice the
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
addClientEnvironment(guest, runtimeDir, steamHere)
// PulseAudio always: the client is a native Linux program and has no other way to make a
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
// an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it
// changes what games do and leaves the client alone. The microphone is its own opt-in on
// top, and the helper only opens an input stream when asked - so a user who wants game
// sound but no recording gets exactly that, and Android's recording indicator stays off.
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
// Both paths sit under the app's files directory, which the session binds at its own path,
// so the same string is valid on both sides and nothing has to be translated.
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
val relaySocket = File(audioDir, "relay.sock")
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
relay.attach(this)
components.add(relay)
}
if (wantsDirectAudio) {
// Read by the Proton wrappers, which point Wine at the driver and name it in the
// prefix. Absent, they take an early return and the game uses Proton's own audio - so
// this variable is the whole of the selection.
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
val pulse = startAudio(guest, sessionDir)
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
@@ -424,28 +291,7 @@ class SessionService : Service() {
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
if (controllersOn) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
SessionState.fakeInputDir = fakeInputDir
}
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
@@ -506,63 +352,7 @@ class SessionService : Service() {
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
// of our own, written from Android's battery API every few seconds, is bound over it.
val battery = BatteryComponent(File(filesDir, "session/sys/power_supply"))
battery.attach(this)
components.add(battery)
binds.add(battery.dir.path + ":/sys/class/power_supply")
// Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this
// listener, which drives the phone's vibrator (see RumbleComponent).
if (controllersOn) components.add(RumbleComponent().also { it.attach(this) })
// Where the device's files appear inside the session. Internal storage is bound at its own
// path already, and every program's file dialog opens at home and lists "Computer" from
// /proc/mounts, where a proot bind never shows - so a user saw only the runtime's own
// tree and could not find the phone at all. The same storage is placed under home as
// well, and the ROMs folder chosen on the main screen beside it; a bind rather than a
// link, so a folder on an SD card works the same.
val home = File(LinuxRuntime.rootDir(this), "root")
File(home, "Storage").mkdirs()
binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage")
// A second Steam library: the storage chosen in the Steam cog, at the path the runtime's
// bannerlator-steam-library registers with the client. Nothing bound = the script removes
// the entry, so the client never offers a place that is not there.
val library = GameStorage.effective(this)
if (library != null) {
val problem = GameStorage.prepare(library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
binds.add("${library.path}:/mnt/bannerlator-sd")
Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")")
} else {
Log.w(TAG, "game storage: $problem; internal only this session")
}
} else {
Log.i(TAG, "game storage: internal only")
}
// The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so
// the shortcuts the app writes point somewhere whatever storage the folder is on.
for (root in com.droiddeck.launcher.frontend.AddedGames.roots(this)) {
if (root.host.isDirectory && root.host.canRead()) {
File(LinuxRuntime.rootDir(this), root.guest.removePrefix("/")).mkdirs()
binds.add(root.host.path + ":" + root.guest)
Log.i(TAG, "added games: ${root.host} -> ${root.guest}")
} else {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
if (roms != null && roms.isDirectory && roms.canRead()) {
File(home, "ROMs").mkdirs()
binds.add(roms.path + ":/root/ROMs")
Log.i(TAG, "roms: $roms -> /root/ROMs")
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
val binds = sessionBinds(controllersOn, fakeInputDir)
val command = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
@@ -644,6 +434,243 @@ class SessionService : Service() {
}
}
/** The session's own log folder, opened and filled with what is known before anything starts. */
private fun openSessionFolder(): File {
// One folder per session, claimed by whoever started first - the activity starts the
// compositor before this service runs - so the compositor's log lands in the same place.
val sessionDir = SessionPaths.beginOrCurrent(this)
val sessionLog = File(sessionDir, "session.log")
SessionState.logFile = sessionLog
SessionState.logDirectory = sessionDir
SessionState.sessionId = sessionDir.name
SessionState.eventsFile = File(sessionDir, "events.jsonl")
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
// Written first, so a session that dies in its first second still says what it ran on.
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
NetworkReport.write(this, File(sessionDir, "network.txt"))
// Everything the app decides from here on - the driver it chose, the audio line, a rival
// client stopped, the exit status - reaches logcat and nowhere a user can get at. Mirror it.
SessionLogCapture.start(File(sessionDir, "app.log"))
return sessionDir
}
/** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */
private fun addClientEnvironment(guest: MutableList<String>, runtimeDir: File, steamHere: Boolean) {
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
guest.add("USER=root")
guest.add("PATH=/usr/local/bin:/usr/bin:/bin")
guest.add("TERM=xterm-256color")
guest.add("LANG=C.UTF-8")
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
// session bundle sit hours off the device's. Bannerlator carries the same line.
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
// Steam's CEF needs GL and the rootfs ships no native GL driver: route it through Zink.
guest.add("MESA_LOADER_DRIVER_OVERRIDE=zink")
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
// Downloads holds the value verbatim ("sysmem", "sysmem,deck_emu"); with nothing there, an
// imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both
// its authors advise for those GPUs and what nothing else in the list needs.
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (steamHere) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// 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) }
// 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
// sent only when it is a real restriction - a game has no pin of its own to undo.
if (steamHere && SessionPrefs.clientCpusOverride(this)) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
}
// The game cores also pin the desktop and a program from the rail; without a choice the
// session script picks every core but the slowest cluster for those (program_cores).
CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this))
.takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") }
}
/** PulseAudio, and the DirectAudio relay when a path needs it; returns the daemon for suspend and resume. */
private fun startAudio(guest: MutableList<String>, sessionDir: File): PulseAudioComponent {
// PulseAudio always: the client is a native Linux program and has no other way to make a
// sound - its menus, its music and its voice chat all go through here. DirectAudio is not
// an alternative to it on this path: it replaces the audio driver INSIDE Wine, so it
// changes what games do and leaves the client alone. The microphone is its own opt-in on
// top, and the helper only opens an input stream when asked - so a user who wants game
// sound but no recording gets exactly that, and Android's recording indicator stays off.
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
val wantsMic = SessionPrefs.micEnabled(this) &&
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
// Both paths sit under the app's files directory, which the session binds at its own path,
// so the same string is valid on both sides and nothing has to be translated.
val audioDir = File(filesDir, "directaudio").apply { mkdirs() }
val relaySocket = File(audioDir, "relay.sock")
val micFifo = if (wantsMic) File(audioDir, "mic.fifo") else null
val audioLog = File(sessionDir, "audio.log")
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
// fills the relay's ring and the relay, outside proot, drives the device.
// The client's own sound: the classic AAudio sink unless the user chose the relay.
val clientDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.clientDirectAudio(this)
if (clientDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
pulse.setLogFile(audioLog)
pulse.attach(this)
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
components.add(pulse)
if (wantsDirectAudio || wantsMic || clientDirectAudio) {
// After the daemon in the list, so it can wait for the pipe the daemon makes.
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
relay.setLogFile(audioLog)
relay.attach(this)
components.add(relay)
}
if (wantsDirectAudio) {
// Read by the Proton wrappers, which point Wine at the driver and name it in the
// prefix. Absent, they take an early return and the game uses Proton's own audio - so
// this variable is the whole of the selection.
guest.add("BL_DIRECTAUDIO=/" + SessionFiles.DIRECTAUDIO_DIR)
guest.add("BANNER_AUDIO_DIRECT_RELAY=" + relaySocket.absolutePath)
}
Log.i(TAG, "audio: client " + (if (clientDirectAudio) "DirectAudio" else "classic AAudio sink") + (if (wantsDirectAudio) " + DirectAudio for games" else "")
+ (if (wantsMic) " + microphone" else "") +
(if (SessionPrefs.micEnabled(this) && !wantsMic) " (microphone wanted but RECORD_AUDIO not granted)" else ""))
return pulse
}
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
// SDL and Steam key their mapping database on bus+vendor+product: only a known
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
SessionState.fakeInputDir = fakeInputDir
}
/** What proot binds into the guest: the pads, the battery, storage, the game library, added games and ROMs. */
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
// of our own, written from Android's battery API every few seconds, is bound over it.
val battery = BatteryComponent(File(filesDir, "session/sys/power_supply"))
battery.attach(this)
components.add(battery)
binds.add(battery.dir.path + ":/sys/class/power_supply")
// Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this
// listener, which drives the phone's vibrator (see RumbleComponent).
if (controllersOn) components.add(RumbleComponent().also { it.attach(this) })
// Where the device's files appear inside the session. Internal storage is bound at its own
// path already, and every program's file dialog opens at home and lists "Computer" from
// /proc/mounts, where a proot bind never shows - so a user saw only the runtime's own
// tree and could not find the phone at all. The same storage is placed under home as
// well, and the ROMs folder chosen on the main screen beside it; a bind rather than a
// link, so a folder on an SD card works the same.
val home = File(LinuxRuntime.rootDir(this), "root")
File(home, "Storage").mkdirs()
binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage")
// A second Steam library: the storage chosen in the Steam cog, at the path the runtime's
// bannerlator-steam-library registers with the client. Nothing bound = the script removes
// the entry, so the client never offers a place that is not there.
val library = GameStorage.effective(this)
if (library != null) {
val problem = GameStorage.prepare(library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
binds.add("${library.path}:/mnt/bannerlator-sd")
Log.i(TAG, "game storage: ${library.path} -> /mnt/bannerlator-sd (\"${library.label}\")")
} else {
Log.w(TAG, "game storage: $problem; internal only this session")
}
} else {
Log.i(TAG, "game storage: internal only")
}
// The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so
// the shortcuts the app writes point somewhere whatever storage the folder is on.
for (root in com.droiddeck.launcher.frontend.AddedGames.roots(this)) {
if (root.host.isDirectory && root.host.canRead()) {
File(LinuxRuntime.rootDir(this), root.guest.removePrefix("/")).mkdirs()
binds.add(root.host.path + ":" + root.guest)
Log.i(TAG, "added games: ${root.host} -> ${root.guest}")
} else {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
}
}
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
if (roms != null && roms.isDirectory && roms.canRead()) {
File(home, "ROMs").mkdirs()
binds.add(roms.path + ":/root/ROMs")
Log.i(TAG, "roms: $roms -> /root/ROMs")
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
return binds
}
private fun teardown(prootPid: Int, expectedStartTime: Long? = null) {
fun stillSameProcess(): Boolean = expectedStartTime == null || readStat(prootPid)?.second == expectedStartTime
if (!stillSameProcess()) return
@@ -0,0 +1,186 @@
package com.droiddeck.launcher.ui
import android.graphics.Bitmap
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import java.io.File
// Cover art: posters, the art grid and its tiles.
internal class Tile(
val title: String, val sub: String?, val art: File?, val key: String,
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
val showFooter: Boolean = true,
val onClick: () -> Unit,
)
@Composable
internal fun Poster(art: File?, name: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) }
Box(
modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f)
.graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false }
.clip(Shape12).background(artBrush(hueOf(name))),
) {
if (art != null) CoverImage(art, Modifier.fillMaxSize())
else Text(name, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) }
// Big enough to recognise a game by its art and read its name under it; icon tiles are squares.
val minSize = if (square) 76.dp else if (wide) 120.dp else 96.dp
val gap = 12.dp
// Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy
// grid composes only the rows on screen, so a press towards the next row bounced back among
// the visible tiles. A few hundred tiles lay out fine; the page's scroll follows the focused one.
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val avail = maxWidth - 8.dp
val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1)
val tileWidth = (avail - gap * (cols - 1)) / cols
Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) {
for (row in tiles.chunked(cols)) {
Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) {
for (t in row) key(t.key) {
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
}
}
}
}
}
}
@Composable
private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableInteractionSource, hot: Boolean, track: Modifier) {
val colors = MaterialTheme.colorScheme
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else if (hot) 1.04f else 1f, Motion.sp(0.55f, Spring.StiffnessMedium), label = "tileScale")
val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift")
val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev")
val pal = LocalPalette.current
val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing")
Column(
modifier = track
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) }
.clip(Shape12)
.background(colors.surface)
.border(2.5.dp, ring, Shape12)
.alpha(if (t.dim && !hot) 0.55f else 1f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
) {
Box(modifier = Modifier.fillMaxWidth().shine(hot)) {
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap)
}
if (t.showFooter) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(t.title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (t.sub != null) Text(t.sub, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
/**
* Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header -
* is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop.
*/
@Composable
private fun CoverImage(art: File, modifier: Modifier) {
var wideArt by remember(art) { mutableStateOf(false) }
Box(modifier) {
if (wideArt) {
AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp),
)
Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)))
}
AsyncImage(
model = art, contentDescription = null,
contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop,
onSuccess = { state ->
val size = state.painter.intrinsicSize
if (size.width > size.height * 1.1f) wideArt = true
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
val colors = MaterialTheme.colorScheme
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
when {
art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
else CoverImage(art, Modifier.fillMaxSize())
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
else -> {
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f))))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(6.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
}
}
@@ -0,0 +1,407 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.CompositionLocalProvider
import android.os.Build
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.HomeApp
import com.droiddeck.launcher.frontend.Library
import java.io.File
// The front end's right-hand pane and the page it shows: backdrop, desktop card and emulators.
@Composable
internal fun Pane(
s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier,
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
CompositionLocalProvider(LocalNarrowPane provides (maxWidth < NarrowPaneWidth)) {
val backdropArt: File? = when {
s.pageKey != null && page != null -> null
selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }?.art
selected == "games" -> s.steamGames.maxByOrNull { it.lastPlayed }?.art
selected.startsWith("rom:") -> romFor(s, selected)?.second?.art
else -> null
}
Backdrop(backdropArt)
AnimatedContent(
// Picking another game changes the detail beside the list, not the whole page.
targetState = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected,
transitionSpec = {
(fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
.apply { targetContentZIndex = 1f }
},
label = "pane",
) { key ->
if (page != null && key == s.pageKey) page()
else Content(s, if (key == "games") selected else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
}
}
}
}
@Composable
private fun Backdrop(art: File?) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Crossfade(targetState = art, animationSpec = Motion.tw(700), label = "backdrop") { a ->
// A game's own art, blurred, where it has some; otherwise the plain ground with one quiet
// glow of the theme's signal colour - a colour per title hash read as noise.
if (a != null && Build.VERSION.SDK_INT >= 31) {
Box(modifier = Modifier.fillMaxSize().alpha(0.26f).blur(70.dp)) {
AsyncImage(model = a, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.5f; scaleY = 1.5f })
}
} else {
Box(modifier = Modifier.fillMaxSize().background(Brush.radialGradient(listOf(pal.signal.copy(alpha = 0.07f), Color.Transparent), center = Offset.Zero, radius = 1400f)))
}
}
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(listOf(Color.Transparent, colors.background.copy(alpha = 0.35f)))))
}
private fun romFor(s: FrontEndState, selected: String): Pair<Library.Emulator, Library.Rom>? {
val parts = selected.split(":")
val e = s.emulators.firstOrNull { it.id == parts.getOrNull(1) } ?: return null
val g = e.games.getOrNull(parts.getOrNull(2)?.toIntOrNull() ?: -1) ?: return null
return e to g
}
@Composable
private fun Content(
s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier,
onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit,
onOpenDeveloperOptions: () -> Unit, onRequestWirelessAdb: (Boolean) -> Unit,
) {
val colors = MaterialTheme.colorScheme
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
// Setup scrolls inside itself, under its tabs.
if (selected == "setup") {
Column(modifier = modifier.padding(horizontal = padH, vertical = padV)) {
SetupPanel(s, a, onOpenDeveloperOptions, onRequestWirelessAdb)
}
return
}
// Steam is a full-bleed wall of the library with Play over it.
if (selected == "steam") {
SteamHome(s, a, modifier)
return
}
// The Games tab lays out its own list and detail.
if (selected == "games" || selected.startsWith("app:")) {
GamesPage(s, a, selected, onSelect, modifier)
return
}
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
when {
selected == "android-apps" && s.isHomeApp -> {
Rise(0) { PageHeader("Android apps") { Chip("${s.androidApps.size} apps", ok = false) } }
if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") }
else Rise(3, Modifier.fillMaxWidth()) {
ArtGrid(s.androidApps.map { app ->
Tile(
app.label,
null,
null,
"android:${app.packageName}",
onClick = { onAndroidAppClick(app) },
iconBitmap = app.icon,
)
})
}
}
selected == "desktop" -> {
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
Rise(0) {
PageHeader("Desktop") {
if (s.desktopInstalled) Chip("● Desktop installed", ok = true) else Chip("Installs on first open", ok = false)
}
}
Rise(2) { DesktopCard(s, a) }
if (installed.isNotEmpty()) {
Rise(3) { SectionTitle("Emulators", "${installed.size} installed") }
Rise(4) { EmulatorGrid(installed, first = true, onSelect = onSelect) }
}
if (available.isNotEmpty()) {
Rise(5) { SectionTitle("Available to install", available.size.toString()) }
Rise(6) { EmulatorGrid(available, first = installed.isEmpty(), onSelect = onSelect) }
}
}
selected.startsWith("emu:") -> {
val e = s.emulators.firstOrNull { "emu:${it.id}" == selected }
if (e == null) Note("Not installed.") else {
val pkgId = Library.packageId(e.id)
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
Rise(0) {
BackLink("Desktop") { onSelect("desktop") }
}
Rise(1) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
) {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
Column {
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(
e.system.replaceFirstChar { it.uppercase() } + if (e.installed) "" else " · not installed",
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
}
}
}
if (e.installed) {
Rise(3) {
Actions {
PrimaryButton("Open ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onEmulator(e) }
SecondaryButton("ROMs folder", onClick = a.onRoms)
if (pkg != null) SecondaryButton(
if (pkg.kind == "appimage") "Remove" else "Forget",
enabled = s.packageBusyId == null && !s.sessionRunning,
) { a.onRemovePackage(pkg.id) }
}
}
Rise(4) { SectionTitle("Games", e.games.size.toString()) }
if (e.games.isEmpty()) Rise(5) {
Note(
if (s.romsDir == null) "Choose a ROMs folder."
else if (e.id == "retroarch") "Browse to /root/ROMs in RetroArch."
else "Add ${e.system} games to ROMs/${e.system.substringBefore(' ')}.",
)
}
else Rise(5, Modifier.fillMaxWidth()) {
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) "installed" else g.hostPath.extension.uppercase().ifEmpty { "folder" }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
}
} else {
if (pkg != null) Rise(2) {
Actions {
PrimaryButton(
if (s.packageBusyId == pkg.id) "Installing…" else "Install ${e.name}",
enabled = s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning,
) { a.onInstallPackage(pkg.id) }
if (s.sessionRunning) ActionChip("Stop session to install", ok = false)
else if (!s.ready) ActionChip("Runtime required", ok = false)
}
}
Rise(3) {
Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
Note(when {
s.packageCatalogLoading -> "Loading install details…"
pkg == null -> "Install details are unavailable right now. Try again when the package catalog is reachable."
!s.ready -> "Install the Linux runtime from Setup before installing desktop apps."
s.sessionRunning -> "Stop the active session before installing desktop apps."
pkg.notes.isNotBlank() -> pkg.notes
else -> "Install ${e.name} into the Linux desktop runtime."
})
}
}
if (s.packageBusyId == pkg?.id) Rise(4) {
val stage = s.packageStage
Text(
if (stage != null && s.packagePercent >= 0) "$stage · ${s.packagePercent}%" else stage ?: "Starting…",
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp),
)
if (s.packagePercent >= 0) LinearProgressIndicator(progress = { s.packagePercent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
}
if (pkg?.kind == "tar") Rise(5) { Note("Forgetting this package hides it from Desktop; its files remain in the Linux runtime.") }
}
}
}
selected.startsWith("rom:") -> {
val pair = romFor(s, selected)
if (pair == null) Note("That game is gone from the ROMs folder.") else {
val (e, g) = pair
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
BackLink(e.name) { onSelect("emu:${e.id}") }
Eyebrow("Desktop · ${e.system}")
}
}
Rise(1) { Title(g.name) }
Rise(2) {
Row {
Column(modifier = Modifier.weight(1f)) {
Text(g.guestPath, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(colors.surface).padding(horizontal = 10.dp, vertical = 6.dp))
Spacer(Modifier.height(14.dp))
Actions {
Image(painterResource(e.iconRes), null, modifier = Modifier.size(40.dp))
PrimaryButton("Launch in ${e.name}", enabled = s.ready && !s.busy, main = true) { a.onRom(g) }
ActionChip(g.hostPath.extension.uppercase().ifEmpty { "folder" }, ok = false)
}
}
if (g.art != null && !narrow) Poster(g.art, g.name, Modifier.width(detailPosterWidth))
}
}
val others = e.games.filter { it !== g }
if (others.isNotEmpty()) {
Rise(3) { SectionTitle("Also in ${e.name}", null) }
Rise(4, Modifier.fillMaxWidth()) {
ArtGrid(others.map { x ->
val index = e.games.indexOf(x)
Tile(x.name, if (x.art != null) "installed" else x.hostPath.extension.uppercase().ifEmpty { "folder" }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
}, wide = others.none { it.art != null })
}
}
}
}
else -> Note("Select an item.")
}
}
}
/** The Linux desktop itself, above the emulators that run in it. */
@Composable
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val actions: @Composable () -> Unit = {
Actions {
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
PrimaryButton(if (s.desktopInstalled) "Open desktop" else "Install & open desktop", enabled = !s.busy, main = true, onClick = a.onDesktop)
Cog(onClick = a.onDesktopSettings)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
Column(modifier = Modifier.weight(1f)) {
Text("Linux desktop", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text("LXQt, Firefox and your emulators", fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (!narrow) actions()
}
if (narrow) actions()
}
}
/** Emulators as wide tiles - three across, or a list on a narrow page. */
@Composable
private fun EmulatorGrid(emulators: List<Library.Emulator>, first: Boolean, onSelect: (String) -> Unit) {
val columns = if (LocalNarrowPane.current) 1 else 3
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
emulators.chunked(columns).forEachIndexed { r, row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEachIndexed { i, e ->
key(e.id) {
EmulatorTile(e, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onSelect("emu:${e.id}") }
}
}
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
/**
* One emulator: its icon, system and games. One not yet installed says so in words and keeps full
* contrast - dimming it read as disabled rather than one press from installing.
*/
@Composable
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "emuScale")
val system = e.system.replaceFirstChar { it.uppercase() }
val detail = if (e.installed && e.id != "retroarch") "$system · ${e.games.size} game${if (e.games.size == 1) "" else "s"}" else system
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.paneItem("tile:emu:${e.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (!e.installed) Text(
"Install", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
@@ -0,0 +1,240 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.frontend.Library
// The Games page: the list, the hero for the selected game and its labels.
/**
* The Games tab: installed Steam games down the left, most recently played first, and the one
* picked beside them - its banner, Launch and the launch settings. One game skips the list.
*/
@Composable
internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, onSelect: (String) -> Unit, modifier: Modifier) {
val games = remember(s.steamGames) { s.steamGames.sortedByDescending { it.lastPlayed } }
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull() ?: return
val host = rememberMenuHost()
val narrow = LocalNarrowPane.current
if (games.size == 1) {
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
GameHeroCopy(current, if (narrow) 28.sp else 38.sp)
PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
}
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
}
Rise(1) { SectionTitle("Launch settings", null) }
Rise(2) { LaunchSettings(s, a, host) }
}
return
}
val pal = LocalPalette.current
Row(modifier = modifier) {
GameList(games, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = { a.onSteamGame(it) },
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(
modifier = Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState())
.padding(horizontal = if (narrow) 14.dp else 20.dp, vertical = 16.dp),
) {
GameHero(current, Modifier.fillMaxWidth().heightIn(min = if (narrow) 170.dp else 200.dp)) {
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
PrimaryButton("Launch", enabled = s.ready && !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(current) }
}
SectionTitle("Launch settings", null)
LaunchSettings(s, a, host)
}
}
}
/** When it was last played (or where it is, if never) over its name, then room for Launch. */
@Composable
private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.compose.ui.unit.TextUnit) {
Text(
(lastPlayedText(g.lastPlayed) ?: libraryLabel(g.library)).uppercase(),
fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
g.name, fontSize = titleSize, lineHeight = titleSize * 1.15f, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
@Composable
private fun GameList(
games: List<Library.SteamGame>, current: Library.SteamGame,
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
// Laid out whole, as the art grid is: the pad's focus search only finds rows that exist.
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 6.dp, bottom = 10.dp)) {
Text("Games", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(games.size.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
}
for (g in games) key(g.appId) {
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
}
}
}
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
@Composable
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
LaunchedEffect(focused) { if (focused && !selected) onSelect() }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}")
.clip(Shape12)
.background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent)
.border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect)
.controllerConfirm(onClick = onLaunch)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Box(Modifier.width(30.dp).height(45.dp).clip(RoundedCornerShape(5.dp)).background(artBrush(hueOf(g.name)))) {
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
}
Column(modifier = Modifier.weight(1f)) {
Text(g.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
lastPlayedText(g.lastPlayed)?.removePrefix("Last played ")?.replaceFirstChar { it.uppercase() } ?: "Never played",
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
/** A page's title, with room at its right for a status chip. */
@Composable
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
) {
Text(
title, fontSize = if (LocalNarrowPane.current) 22.sp else 26.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground, maxLines = 1,
)
trailing()
}
}
/** Whether Steam can start, said where Play is rather than only in Setup. */
@Composable
internal fun RuntimeChip(s: FrontEndState) = when {
s.busy -> Chip(if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage, ok = false)
!s.ready -> Chip("Runtime installs on first Play", ok = false)
s.available != null && s.available != s.installed -> Chip("Runtime update available", ok = false)
else -> Chip("● Runtime ready", ok = true)
}
/** "Last played 3 days ago" from Steam's unix seconds; null for a game never played. */
private fun lastPlayedText(lastPlayed: Long): String? {
if (lastPlayed <= 0L) return null
val span = android.text.format.DateUtils.getRelativeTimeSpanString(
lastPlayed * 1000L, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
).toString()
return "Last played " + span.replaceFirstChar { it.lowercase() }
}
private fun libraryLabel(library: String): String = when (library) {
"internal" -> "Internal storage"
"added" -> "Added game"
else -> library
}
/**
* A game's wide banner: Steam's hero art where the client cached one, else its capsule blurred to
* fill the width. The copy sits bottom-left over a scrim of the ground colour so it always reads;
* the art takes the banner's size, so a banner given a minimum height grows to fit its copy.
*/
@Composable
private fun GameHero(g: Library.SteamGame, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) {
val colors = MaterialTheme.colorScheme
Box(modifier = modifier.clip(Shape16).background(artBrush(hueOf(g.name)))) {
val image = g.hero ?: g.art
if (image != null) AsyncImage(
model = image, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize()
.then(if (g.hero == null) Modifier.blur(24.dp).graphicsLayer { scaleX = 1.3f; scaleY = 1.3f } else Modifier),
)
Spacer(
Modifier.matchParentSize().background(
Brush.horizontalGradient(
0f to colors.background.copy(alpha = 0.92f),
0.55f to colors.background.copy(alpha = 0.6f),
1f to Color.Transparent,
),
),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp),
content = content,
)
}
}
@@ -0,0 +1,250 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.PhantomProcessLimit
// The front end's side rail: the page list, the resume entry and the controller hints under it.
/** What the pad's face buttons do here, along the bottom edge as on a console. */
@Composable
internal fun ControllerHints() {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(modifier = Modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.End),
modifier = Modifier.fillMaxWidth().height(34.dp).background(colors.surface).padding(horizontal = 20.dp),
) {
HintGlyph("A", "Select")
HintGlyph("B", "Back")
}
}
}
@Composable
private fun HintGlyph(button: String, action: String) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text(button, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text(action, fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
internal val AttentionAmber = Color(0xFFFFB547)
/**
* The launcher's sections, always on screen down the left edge: the app is landscape-only, so the
* width a hamburger menu would save is better spent keeping every section one press away.
*/
@Composable
internal fun SideRail(
s: FrontEndState, selected: String,
onSelect: (String) -> Unit, onFocusSelect: (String) -> Unit, a: FrontEndActions, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
val compact = LocalConfiguration.current.screenHeightDp < 420
// Under 600dp wide (4:3 and square screens) the labels would cost a fifth of the width.
val iconOnly = isNarrowScreen()
val setupNeedsAttention = !s.ready || (s.available != null && s.available != s.installed) ||
PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
Image(painterResource(R.drawable.logo), contentDescription = "DroidDeck", modifier = Modifier.size(if (compact) 28.dp else 34.dp))
Spacer(Modifier.height(if (compact) 6.dp else 12.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
) {
if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Games appears once there is one: an empty list is no place to land.
if (s.steamGames.isNotEmpty()) RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") }
RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
}
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) {
val barWidth = if (iconOnly) 44.dp else 60.dp
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.width(barWidth))
else LinearProgressIndicator(modifier = Modifier.width(barWidth))
Text(if (s.percent >= 0) "${s.percent}%" else if (iconOnly) "…" else "Working", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
}
}
var lastRunning by remember { mutableStateOf("") }
if (s.running != null) lastRunning = s.running
AnimatedVisibility(
s.running != null,
enter = expandVertically(Motion.sp(0.75f)) + fadeIn(Motion.tw(300)),
exit = shrinkVertically(Motion.tw(220)) + fadeOut(Motion.tw(180)),
) { ResumeRailItem(lastRunning, compact, iconOnly, a.onResume) }
}
}
@Composable
private fun RailItem(
label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean,
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val frontFocus = LocalFrontFocus.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val pressed by src.collectIsPressedAsState()
if (frontFocus != null) LaunchedEffect(focused) {
if (focused) {
frontFocus.focusedRail = key
onFocus()
}
else if (frontFocus.focusedRail == key) frontFocus.focusedRail = null
}
val fg by animateColorAsState(
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
Motion.tw(220), label = "railFg",
)
val fill by animateColorAsState(
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
Motion.tw(220), label = "railFill",
)
val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing")
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
.clip(Shape14)
.background(fill)
.border(2.dp, ring, Shape14)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
.then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier),
) {
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (compact) 20.dp else 22.dp))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
}
if (badge) Box(
modifier = Modifier.align(Alignment.TopEnd).padding(top = if (iconOnly) 7.dp else 8.dp, end = if (iconOnly) 9.dp else 18.dp)
.size(8.dp).clip(CircleShape).background(AttentionAmber),
)
}
}
/** The running session, one press from anywhere in the launcher. */
@Composable
private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val focused by src.collectIsFocusedAsState()
val hovered by src.collectIsHoveredAsState()
val pulse = rememberInfiniteTransition(label = "pulse")
val ringScale by pulse.animateFloat(0.4f, 1.6f, infiniteRepeatable(tween(1600, easing = Motion.Ease), RepeatMode.Restart), label = "ring")
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier
.padding(top = 6.dp)
.width(if (iconOnly) 52.dp else 80.dp)
.clip(Shape14)
.background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f))
.border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14)
.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume)
.semantics { contentDescription = "Resume $name" }
.padding(vertical = if (iconOnly) 15.dp else if (compact) 6.dp else 9.dp, horizontal = 4.dp),
) {
Box(modifier = Modifier.size(14.dp), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.size(14.dp).graphicsLayer { scaleX = ringScale; scaleY = ringScale; alpha = (1.6f - ringScale) / 1.2f }.border(1.5.dp, pal.good, CircleShape))
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(pal.good))
}
// Icons only: the live dot alone says something is running; its description says what.
if (!iconOnly) {
Text("Resume", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, softWrap = false)
Text(name, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,445 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.VideogameAsset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
// The Setup page: runtime and device checks, tools, frame generation and launch settings.
private enum class CheckState { OK, WARN, BUSY }
/** One requirement in Setup's system check: a status mark, what it is, and at most one action. */
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when (state) {
CheckState.OK -> pal.good
CheckState.WARN -> AttentionAmber
CheckState.BUSY -> pal.signal
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
.background(if (state == CheckState.WARN) AttentionAmber.copy(alpha = 0.07f) else Color.Transparent)
.heightIn(min = 60.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(tint.copy(alpha = 0.16f))) {
Icon(
when (state) {
CheckState.OK -> Icons.Filled.Check
CheckState.WARN -> Icons.Filled.PriorityHigh
CheckState.BUSY -> Icons.Filled.Refresh
},
contentDescription = when (state) {
CheckState.OK -> "Done"
CheckState.WARN -> "Needs attention"
CheckState.BUSY -> "Working"
},
tint = tint, modifier = Modifier.size(16.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (detail != null) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (action != null) action()
}
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@Composable
internal fun SetupPanel(
s: FrontEndState,
a: FrontEndActions,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
) {
val host = rememberMenuHost()
var processLimitBusy by remember { mutableStateOf(false) }
var processLimitMessage by remember { mutableStateOf<String?>(null) }
val setProcessLimit: (Boolean) -> Unit = { enabled ->
processLimitBusy = true
processLimitMessage = null
a.onSetPhantomProcessLimit(enabled) { error ->
processLimitBusy = false
if (error == null) {
a.onRefreshPhantomStatus()
} else {
processLimitMessage = "Check the Wireless debugging IP address & Port, or pair again if Android removed this device."
onRequestWirelessAdb(enabled)
}
}
}
val runtime = when {
s.busy -> "Working…"
!s.ready -> "Install"
s.available != null && s.available != s.installed -> "Update"
else -> "Manage"
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val gpuOk = remember { DeviceSupport.adreno() }
val gpuName = remember { DeviceSupport.gpuName() }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
// Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page.
val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About")
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
// The control a controller was on went with the old tab: it lands on the new tab itself.
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
runCatching { tabFocus[tab].requestFocus() }
}
}
Rise(0, Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().bumpers(
onPrevious = { pick((tab + tabs.size - 1) % tabs.size) },
onNext = { pick((tab + 1) % tabs.size) },
),
) {
PageHeader("Setup") {
Chip(if (readyCount == checks) "● All set" else "$readyCount of $checks ready", ok = readyCount == checks)
}
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
SectionTitle("System check", null)
// What Steam needs, one row each: green when done, one button when not. The
// process-limit controls only open under their row.
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
CheckRow(
if (gpuOk) CheckState.OK else CheckState.WARN,
if (gpuOk) "Device supported" else "GPU not supported",
if (gpuOk) gpuName else "Steam draws with an Adreno driver; $gpuName may show a black screen",
)
CheckRow(
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
"Linux runtime",
when {
s.busy -> if (s.percent >= 0) "${s.stage} · ${s.percent}%" else s.stage
!s.ready -> "Not installed · about 3 GB, installed on the first Play"
s.available != null && s.available != s.installed -> "${s.installed ?: "Installed"} · update available"
else -> "${s.installed ?: "Installed"} · up to date"
},
) { SecondaryButton(runtime, enabled = !s.busy, compact = true, onClick = a.onRuntime) }
CheckRow(
if (limitBlocks) CheckState.WARN else CheckState.OK,
if (limitBlocks) "Android may close Steam" else "Android process limit",
if (limitBlocks) "“Restrict child processes” is on - it takes a minute to turn off" else PhantomProcessLimit.title(s.phantomProcessStatus),
) {
if (limitBlocks) PrimaryButton(if (showLimitDetails) "Hide" else "Fix it", compact = true) { showLimitDetails = !showLimitDetails }
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
SecondaryButton(if (showLimitDetails) "Hide" else "Details", compact = true) { showLimitDetails = !showLimitDetails }
}
}
AnimatedVisibility(showLimitDetails, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 56.dp, end = 14.dp, top = 4.dp, bottom = 10.dp)) {
Text(
PhantomProcessLimit.instructions(s.phantomProcessStatus),
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) {
SecondaryButton("Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Check again", compact = true, onClick = a.onRefreshPhantomStatus)
SecondaryButton("Copy ADB command", compact = true, onClick = { a.onCopyPhantomCommand(false) })
}
Text(
PhantomProcessLimit.ADB_COMMAND, fontSize = 12.sp, fontFamily = FontFamily.Monospace,
color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp),
)
if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
Text(
"With wireless debugging paired, DroidDeck can switch the limit itself. Turning it off is recommended for Steam sessions.",
fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 6.dp)) {
SecondaryButton("Turn limit off", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.DISABLED) { setProcessLimit(false) }
SecondaryButton("Turn limit on", compact = true, enabled = !processLimitBusy && s.phantomProcessStatus != PhantomProcessStatus.ENABLED) { setProcessLimit(true) }
}
if (processLimitBusy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
}
}
}
CheckRow(
if (signedIn) CheckState.OK else CheckState.WARN,
"Steam account",
s.offlineAccount?.let { if (s.offline) "Signed in as $it · offline mode" else "Signed in as $it" } ?: "Press Play and sign in to Steam",
divider = false,
)
}
SectionTitle("Tools", null)
ToolGrid(s, a)
}
1 -> {
val controller = s.controller
if (controller != null && a.controller != null) SettingsGroup("Controller") {
ControllerRows(host, s.oscMode, controller, a.controller)
}
if (s.controller == null || a.controller == null) Note("Controller settings are unavailable.")
}
2 -> {
SettingsGroup("Session") {
ChoiceRow(
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow("Frame generation", "Select the frame generation mode") {
Box {
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
FrameGenMenu(s, a, host)
}
}
SettingsRow("Session logs", "${if (s.logsEnabled) "Enabled" else "Disabled"} · logs are saved after each session") {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton(if (s.logsEnabled) "Turn off" else "Turn on") { a.onLogs() }
SecondaryButton("Share latest") { a.onShareLogs() }
}
}
SettingsRow("Offline mode", s.offlineAccount?.let { if (s.offline) "Enabled for $it" else "Signed in as $it" } ?: "Sign in to Steam first") {
SecondaryButton(if (s.offline) "Turn off" else "Turn on", enabled = s.offlineAccount != null) { a.onOffline() }
}
}
}
3 -> {
SettingsGroup("Launcher") {
SettingsRow("Theme", "Choose the launcher appearance") {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = "Theme") { firstItemFocus ->
Themes.all.forEachIndexed { index, theme ->
MenuItem(theme.label, checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
a.onTheme(theme.id)
host.open = null
}
}
}
}
}
ToggleRow(
host, "home-screen", "Use as a Home screen",
if (s.homeScreenEnabled) "DroidDeck can be the phone's Home app" else "Off: DroidDeck is never offered as a Home app",
s.homeScreenEnabled,
) { a.onHomeScreen(it) }
ToggleRow(
host, "launcher-fullscreen", "Fullscreen",
if (s.launcherFullscreen) "Hide the Android status and navigation bars" else "Show the Android status and navigation bars",
s.launcherFullscreen,
) { a.onLauncherFullscreen(it) }
if (s.homeScreenEnabled) {
ActionRow("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", "Choose", a.onHomeApp)
}
}
}
else -> {
SettingsGroup("About") {
ActionRow("Build", s.buildLabel, "Check for newer", a.onCheckLatestBuild)
ActionRow("Credits", "The people and projects DroidDeck builds on", "View", a.onCredits)
}
}
}
}
}
}
}
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
@Composable
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
val columns = if (LocalNarrowPane.current) 2 else 4
val tools = listOf(
ToolSpec(Icons.Outlined.Folder, "Files", "Browse and manage files", a.onFiles),
ToolSpec(Icons.Outlined.Extension, "Compatibility tools", "Install ARM64 Proton builds", a.onProtons),
ToolSpec(Icons.Outlined.Speed, "Performance", "CPU core assignment", a.onPerformance),
ToolSpec(Icons.Outlined.VideogameAsset, "ROMs folder", s.romsDir ?: "Choose where emulator games are stored", a.onRoms),
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
for (row in tools.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
@Composable
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.paneItem("tool:${t.title}")
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
.controllerConfirm(onClick = t.onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
AnchoredMenu(host.open == "fg", onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (s.lsfgReady) null else "Install Lossless Scaling in Steam"
MenuItem("Off", checked = s.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_WINFG && s.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = s.frameGenEngine == FrameGen.ENGINE_LSFG && s.frameGenMultiplier == m, enabled = s.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
}
}
/**
* What shapes a launch, beside the game rather than three screens away in Setup. They are the
* app-wide settings - each card opens the same page or menu Setup does.
*/
@Composable
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
// Three across, two on a narrow page; each row's cards share one height.
val columns = if (LocalNarrowPane.current) 2 else 3
val controller = a.controller
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m -> SettingCard("Compatibility", "Proton & components", "card:components", m) { a.onComponents(true) } }
add { m ->
Box(m) {
SettingCard("Frame generation", s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
host.open = if (host.open == "fg") null else "fg"
}
FrameGenMenu(s, a, host)
}
}
if (controller != null) add { m -> SettingCard("Controls", "Button mapping", "card:controls", m, controller.onMapping) }
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (card in row) card(Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge")
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier.paneItem(id)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.border(if (hot) 2.dp else 1.dp, edge, Shape14)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@@ -0,0 +1,226 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import android.provider.Settings
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// Small building blocks shared by the front end's pages: text styles, chips and buttons.
internal val Shape14 = RoundedCornerShape(14.dp)
@Composable
internal fun Eyebrow(t: String) {
val pal = LocalPalette.current
val rule = remember { Animatable(0f) }
LaunchedEffect(Unit) { rule.animateTo(1f, Motion.tw(600, 120)) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.width(18.dp).height(1.5.dp).graphicsLayer { scaleX = rule.value; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) }.background(pal.signal))
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = pal.signal)
}
}
@Composable internal fun Title(t: String) = Text(t, fontSize = 26.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(top = 4.dp, bottom = 4.dp))
@Composable internal fun Lede(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 12.dp))
@Composable
internal fun SectionTitle(t: String, detail: String?) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp)) {
Text(t.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant)
if (detail != null) Text(detail, fontSize = 12.sp, color = colors.onBackground)
Box(modifier = Modifier.weight(1f).height(1.dp).background(LocalPalette.current.line))
}
}
@Composable internal fun Note(t: String) = Text(t, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().clip(Shape12).background(MaterialTheme.colorScheme.surface).border(1.dp, LocalPalette.current.line2, Shape12).padding(12.dp))
/** A page's buttons: they wrap onto a second line rather than run off a narrow page. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun Actions(content: @Composable () -> Unit) = FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) { content() }
/** A status chip in a row of buttons, centred on their height. */
@Composable
internal fun ActionChip(t: String, ok: Boolean) = Box(contentAlignment = Alignment.Center, modifier = Modifier.heightIn(min = 44.dp)) { Chip(t, ok) }
internal val Shape16 = RoundedCornerShape(16.dp)
/** One level up, as Back and B do: a real button, big enough to hit ([compact]: for the tightest layouts). */
@Composable
internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val shape = RoundedCornerShape(22.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 36.dp else 44.dp)
.clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f))
.border(2.dp, if (hot) pal.signal else Color.Transparent, shape)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(start = 8.dp, end = 16.dp),
) {
Icon(Icons.Filled.ChevronLeft, contentDescription = null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp))
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
internal fun Chip(t: String, ok: Boolean) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Text(
t, fontSize = 12.sp, color = if (ok) pal.good else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(colors.surfaceVariant).border(1.dp, if (ok) pal.good.copy(alpha = 0.3f) else pal.line, RoundedCornerShape(99.dp)).padding(horizontal = 9.dp, vertical = 4.dp),
)
}
@Composable
internal fun rememberHot(src: MutableInteractionSource): Boolean = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
@Composable
internal fun PrimaryButton(
text: String,
enabled: Boolean = true,
main: Boolean = false,
compact: Boolean = false,
modifier: Modifier = Modifier,
/** The Steam tab's own Play: bigger than any other button on a page. */
large: Boolean = false,
icon: ImageVector? = null,
onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// The page's main button is where the pane is entered from the rail.
val frontFocus = if (main) LocalFrontFocus.current else null
if (frontFocus != null) DisposableEffect(Unit) {
frontFocus.primaryAttached++
onDispose { frontFocus.primaryAttached-- }
}
val track = (if (frontFocus == null) Modifier.paneItem("btn:$text") else Modifier).downToFirstTile()
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "btnScale")
val lift by animateFloatAsState(if (hot) 14f else 6f, Motion.tw(300), label = "btnLift")
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.then(track)
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.primary).onFocusChanged { if (it.isFocused) frontFocus.last = FrontFocus.PRIMARY } else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale; shadowElevation = if (enabled) lift.dp.toPx() else 0f; shape = Shape12; clip = false; ambientShadowColor = pal.signal; spotShadowColor = pal.signal }
.clip(Shape12)
.background(if (enabled) Brush.linearGradient(listOf(colors.primary, pal.primary2)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surfaceVariant)))
.shine(hot, 0.45f)
// The grow and shine alone barely show on the light fill: outline it when a
// controller is on it, as the other controls are.
.border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.padding(
start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp,
end = if (large) 26.dp else if (compact) 10.dp else 18.dp,
top = if (large) 16.dp else if (compact) 7.dp else 11.dp,
bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp,
),
) {
val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant
if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (large) 20.dp else 17.dp))
Text(
text, fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp,
fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, maxLines = 1,
)
}
}
@Composable
internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && enabled
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge")
val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill")
Box(
modifier = modifier.paneItem("btn:$text").downToFirstTile().graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12)
.alpha(if (enabled) 1f else 0.5f)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = if (compact) 10.dp else 16.dp, vertical = if (compact) 7.dp else 11.dp),
) { Text(text, fontSize = if (compact) 12.sp else 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.onBackground, maxLines = 1) }
}
@Composable
internal fun Cog(size: androidx.compose.ui.unit.Dp = 42.dp, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog")
val pal = LocalPalette.current
val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge")
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick),
) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) }
}
@@ -0,0 +1,268 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.runtime.produceState
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import com.droiddeck.launcher.frontend.Library
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.filled.Pause
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
// The session's loading screen, its paused screen and the HUD text style.
@Composable
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.padding(12.dp)
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */
private class LoadStage(val label: String, val detail: String)
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Session", "Starting the display and audio"),
LoadStage("Steam client", "Checking the Steam client and its compatibility tools"),
LoadStage("Opening Steam", "Waiting for Steam's first frame"),
) else listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Desktop", "Getting the desktop ready"),
LoadStage("Opening", "Waiting for the first frame"),
)
/**
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
* order, and the screen never steps back.
*/
private fun stageOf(step: String, steam: Boolean): Int {
val t = step.lowercase()
return when {
"linux runtime" in t -> 0
"starting the session" in t -> 1
!steam && "desktop" in t -> 1
steam && "starting the steam client" in t -> 3
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
else -> -1
}
}
/** Download and install lines are worth reading as they are; the script's own notes are not. */
private fun readableStep(step: String): Boolean {
val t = step.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
}
@Composable
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
) {
val colors = MaterialTheme.colorScheme
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
val at = stageOf(step, steam)
if (at > reached) reached = at
}
val active = reached.coerceAtMost(stages.lastIndex)
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail
val context = LocalContext.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
) {
CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f))
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(
Brush.radialGradient(
0f to colors.background.copy(alpha = 0.94f),
0.6f to colors.background.copy(alpha = 0.72f),
1f to colors.background.copy(alpha = 0.55f),
center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f,
),
)
},
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f))))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp),
) {
LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended)
Spacer(Modifier.height(30.dp))
Text(
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp),
)
if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp))
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp),
)
if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp))
}
}
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
@Composable
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f)
val run = rememberInfiniteTransition(label = "segment")
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
) {
repeat(count) { i ->
Box(
Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp))
.background(if (i < active) pal.signal else track)
.drawBehind {
if (i != active) return@drawBehind
if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height))
else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height))
},
)
}
}
}
/** Cancel as the pad's B, in the corner; a tap works too. */
@Composable
private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp))
.background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel)
.padding(horizontal = 12.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
/** A session paused in the background, ready to pick up where it left off. */
@Composable
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val interactionSource = remember { MutableInteractionSource() }
val resumeFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
runCatching { resumeFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(pal.background.copy(alpha = 0.94f))
.clickable(interactionSource = interactionSource, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.widthIn(max = 440.dp).padding(32.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) {
Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp))
}
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
PrimaryButton(
"Resume",
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
onClick = onResume,
)
}
}
}
@@ -1,31 +1,15 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.runtime.produceState
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import com.droiddeck.launcher.frontend.Library
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Settings
@@ -54,10 +38,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.AlertDialog
@@ -70,12 +52,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.HomeApp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -92,7 +70,6 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -121,8 +98,10 @@ import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings")
/** One icon per drawer page, in page order (QAM-style tabs). */
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend")
private class DrawerFocus {
@@ -145,205 +124,6 @@ private class DrawerFocus {
fun forget(page: Int) { last[page] = null }
}
@Composable
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.padding(12.dp)
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */
private class LoadStage(val label: String, val detail: String)
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Session", "Starting the display and audio"),
LoadStage("Steam client", "Checking the Steam client and its compatibility tools"),
LoadStage("Opening Steam", "Waiting for Steam's first frame"),
) else listOf(
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
LoadStage("Desktop", "Getting the desktop ready"),
LoadStage("Opening", "Waiting for the first frame"),
)
/**
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
* order, and the screen never steps back.
*/
private fun stageOf(step: String, steam: Boolean): Int {
val t = step.lowercase()
return when {
"linux runtime" in t -> 0
"starting the session" in t -> 1
!steam && "desktop" in t -> 1
steam && "starting the steam client" in t -> 3
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
else -> -1
}
}
/** Download and install lines are worth reading as they are; the script's own notes are not. */
private fun readableStep(step: String): Boolean {
val t = step.lowercase()
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
}
@Composable
fun LoadingOverlay(
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
) {
val colors = MaterialTheme.colorScheme
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
val at = stageOf(step, steam)
if (at > reached) reached = at
}
val active = reached.coerceAtMost(stages.lastIndex)
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail
val context = LocalContext.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
) {
CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f))
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(
Brush.radialGradient(
0f to colors.background.copy(alpha = 0.94f),
0.6f to colors.background.copy(alpha = 0.72f),
1f to colors.background.copy(alpha = 0.55f),
center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f,
),
)
},
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f))))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp),
) {
LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended)
Spacer(Modifier.height(30.dp))
Text(
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp),
)
if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp))
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp),
)
if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp))
}
}
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
@Composable
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f)
val run = rememberInfiniteTransition(label = "segment")
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
) {
repeat(count) { i ->
Box(
Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp))
.background(if (i < active) pal.signal else track)
.drawBehind {
if (i != active) return@drawBehind
if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height))
else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height))
},
)
}
}
}
/** Cancel as the pad's B, in the corner; a tap works too. */
@Composable
private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp))
.background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel)
.padding(horizontal = 12.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
/** A session paused in the background, ready to pick up where it left off. */
@Composable
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val interactionSource = remember { MutableInteractionSource() }
val resumeFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
androidx.compose.runtime.withFrameNanos { }
runCatching { resumeFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(pal.background.copy(alpha = 0.94f))
.clickable(interactionSource = interactionSource, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.widthIn(max = 440.dp).padding(32.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) {
Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp))
}
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
PrimaryButton(
"Resume",
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
onClick = onResume,
)
}
}
}
/** Everything the drawer shows and does. */
class DrawerActions(
val steam: Boolean,
@@ -859,6 +639,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
/** Display, Controls, Components, Settings. */
const val DRAWER_PAGES = 4
private const val DRAWER_PAGE_COMPONENTS = 2
/**
@@ -987,6 +768,9 @@ private fun <T> DrawerStackedChoice(
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
private const val DRAWER_TAB_SPACING_DP = 64f
private const val DRAWER_TAB_LEAN_DP = 12f
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.core
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class ArchivePathsTest {
private lateinit var root: File
private lateinit var dest: File
@Before fun setUp() {
root = Files.createTempDirectory("archive-paths").toFile()
dest = File(root, "dest").apply { mkdirs() }
}
@After fun tearDown() {
root.deleteRecursively()
}
@Test fun plainEntryLandsInside() {
assertEquals(File(dest, "usr/bin/sh").canonicalPath, ArchivePaths.inside(dest, "usr/bin/sh")!!.canonicalPath)
}
@Test fun dotEntryIsTheDestination() {
assertEquals(dest.canonicalPath, ArchivePaths.inside(dest, "./")!!.canonicalPath)
}
@Test fun parentTraversalIsRefused() {
assertNull(ArchivePaths.inside(dest, "../escape"))
assertNull(ArchivePaths.inside(dest, "usr/../../escape"))
}
@Test fun siblingWithSharedPrefixIsRefused() {
File(root, "dest-other").mkdirs()
assertNull(ArchivePaths.inside(dest, "../dest-other/x"))
}
@Test fun symlinkOutOfTheTreeIsRefused() {
Files.createSymbolicLink(File(dest, "link").toPath(), root.toPath())
assertNull(ArchivePaths.inside(dest, "link/escape"))
}
}
@@ -0,0 +1,41 @@
package com.droiddeck.launcher.core
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HashesTest {
private fun file(text: String): File =
Files.createTempFile("hashes", ".bin").toFile().apply { writeText(text); deleteOnExit() }
@Test fun sha256OfKnownInput() {
assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Hashes.sha256(file("abc")))
}
@Test fun sha512OfKnownInput() {
assertEquals(
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" +
"2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
Hashes.sha512(file("abc")),
)
}
@Test fun githubDigestIsRecognised() {
val hex = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
assertTrue(Hashes.isGithubSha256("sha256:$hex"))
assertEquals(hex, Hashes.githubSha256("sha256:$hex"))
assertEquals(hex.uppercase(), Hashes.githubSha256("SHA256:${hex.uppercase()}"))
}
@Test fun anythingElseIsNotADigest() {
assertFalse(Hashes.isGithubSha256(null))
assertFalse(Hashes.isGithubSha256(""))
assertFalse(Hashes.isGithubSha256("sha256:abc"))
assertFalse(Hashes.isGithubSha256("sha512:" + "0".repeat(64)))
assertNull(Hashes.githubSha256("md5:0123"))
}
}
@@ -0,0 +1,58 @@
package com.droiddeck.launcher.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LogRedactorTest {
@Test fun labelledJwtIsRedacted() {
val out = LogRedactor.redact("Using JWT 25484942796017334")
assertFalse(out.contains("25484942796017334"))
assertTrue(out.contains("<redacted:jwt>"))
}
@Test fun secretPairKeepsItsKey() {
val out = LogRedactor.redact("GET /x?sessionid=abcdef123456 200")
assertFalse(out.contains("abcdef123456"))
assertTrue(out.contains("sessionid="))
}
@Test fun plainKeyValueSurvives() {
assertEquals("language key=english", LogRedactor.redact("language key=english"))
}
@Test fun steamIdIsMaskedNotDeleted() {
assertEquals("user 76561********1234", LogRedactor.redact("user 76561198012341234"))
}
@Test fun webApiKeyIsRedactedButDepotChunkIsKept() {
val key = "0123456789abcdef0123456789abcdef"
val chunk = "0123456789abcdef0123456789abcdef01234567"
assertFalse(LogRedactor.redact("key $key").contains(key))
assertTrue(LogRedactor.redact("chunk $chunk").contains(chunk))
}
@Test fun emailIsRedacted() {
assertEquals("login <redacted:email> ok", LogRedactor.redact("login someone@example.com ok"))
}
@Test fun clockTimeIsNotAnAddress() {
val line = "[2026-09-27 10:03:05] Startup"
assertEquals(line, LogRedactor.redact(line))
}
@Test fun redactingTwiceChangesNothing() {
val lines = listOf(
"Using JWT 25484942796017334",
"token=abcdefghijklmnop sessionid=qrstuvwxyz123",
"user 76561198012341234 [U:1:52075506]",
"external address 2607:f8b0:4005:80a::200e",
"OnLoginStateChange someaccount 2 1 0 0",
)
for (line in lines) {
val once = LogRedactor.redact(line)
assertEquals(once, LogRedactor.redact(once))
}
}
}