mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
Merge pull request #40 from Droid-Deck/fix/review-hardening
Fix/review hardening
This commit is contained in:
41 files changed
+4795
-3740
No files matched your search
@@ -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
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¶ms_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, ¶ms_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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user