Compare commits

...
40 changed files with 2469 additions and 104 deletions

No files matched your search

+18
View File
@@ -118,6 +118,19 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<!-- A second compositor and Linux session need their own process-global JNI state and
runtime sockets, while the handheld's current session remains in the main process. -->
<activity
android:name=".ExternalSessionActivity"
android:process=":externalDisplay"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/SessionTheme"
android:preferMinimalPostProcessing="true"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<!--
Owns the session - gamescope, the Steam client, audio - so it survives the activity
going away. Without it Android demotes the process as soon as the last visible
@@ -128,6 +141,11 @@
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
<service
android:name=".session.ExternalSessionService"
android:process=":externalDisplay"
android:exported="false"
android:stopWithTask="false" />
<!-- Holds the process in the foreground while the Linux runtime downloads and unpacks. -->
<service
android:name=".runtime.RuntimeInstallService"
@@ -24,6 +24,7 @@ add_library(bannerwayland SHARED
generated/xdg-shell-protocol.c
generated/linux-dmabuf-v1-protocol.c
generated/viewporter-protocol.c
generated/single-pixel-buffer-v1-protocol.c
generated/banner-desktop-v1-protocol.c
generated/presentation-time-protocol.c
generated/pointer-constraints-unstable-v1-protocol.c
@@ -0,0 +1,25 @@
/* Protocol description for wp_single_pixel_buffer_manager_v1. */
#include <stddef.h>
#include "wayland-util.h"
extern const struct wl_interface wl_buffer_interface;
static const struct wl_interface *single_pixel_buffer_types[] = {
&wl_buffer_interface,
NULL,
NULL,
NULL,
NULL,
};
static const struct wl_message single_pixel_buffer_requests[] = {
{ "destroy", "", NULL },
{ "create_u32_rgba_buffer", "nuuuu", single_pixel_buffer_types },
};
WL_EXPORT const struct wl_interface wp_single_pixel_buffer_manager_v1_interface = {
"wp_single_pixel_buffer_manager_v1", 1,
2, single_pixel_buffer_requests,
0, NULL,
};
@@ -0,0 +1,24 @@
#ifndef SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
#define SINGLE_PIXEL_BUFFER_V1_SERVER_PROTOCOL_H
#include <stdint.h>
#include "wayland-server.h"
#ifdef __cplusplus
extern "C" {
#endif
struct wp_single_pixel_buffer_manager_v1_interface {
void (*destroy)(struct wl_client *client, struct wl_resource *resource);
void (*create_u32_rgba_buffer)(struct wl_client *client, struct wl_resource *resource,
uint32_t id, uint32_t red, uint32_t green,
uint32_t blue, uint32_t alpha);
};
extern const struct wl_interface wp_single_pixel_buffer_manager_v1_interface;
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="single_pixel_buffer_v1">
<copyright>
Copyright © 2022 Simon Ser
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
</copyright>
<description summary="single pixel buffer factory">
Create one-pixel buffers that clients can scale with the viewporter protocol.
</description>
<interface name="wp_single_pixel_buffer_manager_v1" version="1">
<request name="destroy" type="destructor">
<description summary="destroy the manager">Child buffers are unaffected.</description>
</request>
<request name="create_u32_rgba_buffer">
<description summary="create a 1x1 buffer from 32-bit RGBA values">
Channel values are premultiplied and range from 0 to 0xffffffff.
</description>
<arg name="id" type="new_id" interface="wl_buffer"/>
<arg name="r" type="uint"/>
<arg name="g" type="uint"/>
<arg name="b" type="uint"/>
<arg name="a" type="uint"/>
</request>
</interface>
</protocol>
+101 -1
View File
@@ -42,6 +42,7 @@
#include "sc_layer.h"
#include "ahb_swapchain.h"
#include "viewporter-server-protocol.h"
#include "single-pixel-buffer-v1-server-protocol.h"
#include "banner-desktop-v1-server-protocol.h"
#include "presentation-time-server-protocol.h"
#include "pointer-constraints-unstable-v1-server-protocol.h"
@@ -745,6 +746,23 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */
}
static int get_single_pixel_buffer(struct wl_resource *buffer, uint32_t *pixel);
/* Copy a wp_single_pixel_buffer into the surface's image and release the resource. */
static void take_single_pixel(struct surface *s, uint32_t pixel,
struct wl_resource *resource) {
if (s->shm_img && (vkp_image_width(s->shm_img) != 1 || vkp_image_height(s->shm_img) != 1)) {
vkp_image_destroy(s->shm_img);
s->shm_img = NULL;
}
if (!s->shm_img) s->shm_img = vkp_image_create_shm(1, 1);
if (s->shm_img) vkp_image_upload_shm(s->shm_img, &pixel, 4);
wl_buffer_send_release(resource);
s->buf_w = 1;
s->buf_h = 1;
s->has_content = s->shm_img != NULL;
}
/* The window the app's performance HUD follows: the latest one at least as big as the last to
* start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */
static struct surface *g_hud_surface;
@@ -1081,6 +1099,8 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
if (s->pending_attach) {
struct wl_resource *buffer = s->pending_buffer;
struct dmabuf_buffer *db = get_dmabuf(buffer);
uint32_t pixel = 0;
int has_single_pixel = get_single_pixel_buffer(buffer, &pixel);
struct wl_shm_buffer *shm = buffer && !db ? wl_shm_buffer_get(buffer) : NULL;
/* The previous content is replaced before reaching the screen. */
@@ -1099,10 +1119,12 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
* rather than composited, so it survives the zero-copy and HDR layer paths. */
if (!buffer) {
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
} else if (has_single_pixel) {
cursor_publish_pixels((const uint8_t *)&pixel, 1, 1, 4, g_cursor_hx, g_cursor_hy);
} else {
cursor_publish_buffer(s, buffer);
wl_buffer_send_release(buffer);
}
if (buffer) wl_buffer_send_release(buffer);
} else if (s->role == ROLE_NONE) {
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
if (buffer) surface_hold_idle(s, buffer);
@@ -1111,6 +1133,9 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
} else if (shm) {
drop_dmabuf(s, 1);
take_shm(s, shm, buffer);
} else if (has_single_pixel) {
drop_dmabuf(s, 1);
take_single_pixel(s, pixel, buffer);
} else {
drop_dmabuf(s, 1);
s->has_content = 0;
@@ -1414,6 +1439,80 @@ static void bind_viewporter(struct wl_client *c, void *data, uint32_t ver, uint3
wl_resource_set_implementation(r, &viewporter_impl, NULL, NULL);
}
/* ---------------------------------------------------------------- wp_single_pixel_buffer_manager_v1
* KWin's nested Wayland backend uses one-pixel buffers for its output background. */
struct single_pixel_buffer {
uint32_t pixel;
};
static void single_pixel_buffer_destroy_req(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static const struct wl_buffer_interface single_pixel_buffer_impl = {
.destroy = single_pixel_buffer_destroy_req,
};
static int get_single_pixel_buffer(struct wl_resource *buffer, uint32_t *pixel) {
if (!buffer || !wl_resource_instance_of(buffer, &wl_buffer_interface, &single_pixel_buffer_impl))
return 0;
struct single_pixel_buffer *single = wl_resource_get_user_data(buffer);
if (!single) return 0;
*pixel = single->pixel;
return 1;
}
static void single_pixel_buffer_resource_destroy(struct wl_resource *r) {
free(wl_resource_get_user_data(r));
}
static void single_pixel_manager_destroy(struct wl_client *c, struct wl_resource *r) {
wl_resource_destroy(r);
}
static uint32_t single_pixel_channel(uint32_t value) {
return (uint32_t)(((uint64_t)value * 255u + UINT32_MAX / 2u) / UINT32_MAX);
}
static void single_pixel_create_buffer(struct wl_client *c, struct wl_resource *r, uint32_t id,
uint32_t red, uint32_t green, uint32_t blue, uint32_t alpha) {
struct single_pixel_buffer *buffer = calloc(1, sizeof(*buffer));
if (!buffer) {
wl_client_post_no_memory(c);
return;
}
/* wl_shm's ARGB8888 word layout is 0xAARRGGBB. */
buffer->pixel = (single_pixel_channel(alpha) << 24)
| (single_pixel_channel(red) << 16)
| (single_pixel_channel(green) << 8)
| single_pixel_channel(blue);
struct wl_resource *resource = wl_resource_create(c, &wl_buffer_interface, 1, id);
if (!resource) {
free(buffer);
wl_client_post_no_memory(c);
return;
}
wl_resource_set_implementation(resource, &single_pixel_buffer_impl, buffer,
single_pixel_buffer_resource_destroy);
}
static const struct wp_single_pixel_buffer_manager_v1_interface single_pixel_manager_impl = {
.destroy = single_pixel_manager_destroy,
.create_u32_rgba_buffer = single_pixel_create_buffer,
};
static void bind_single_pixel_manager(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
struct wl_resource *r = wl_resource_create(c, &wp_single_pixel_buffer_manager_v1_interface, ver, id);
if (!r) {
wl_client_post_no_memory(c);
return;
}
wl_resource_set_implementation(r, &single_pixel_manager_impl, NULL, NULL);
}
/* ------------------------------------------------------------------ xdg_shell */
static void xdg_toplevel_destroy_req(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -3464,6 +3563,7 @@ int banner_wayland_run(void) {
wl_global_create(display, &wl_compositor_interface, 6, NULL, bind_compositor);
wl_global_create(display, &wl_subcompositor_interface, 1, NULL, bind_subcompositor);
wl_global_create(display, &wp_viewporter_interface, 1, NULL, bind_viewporter);
wl_global_create(display, &wp_single_pixel_buffer_manager_v1_interface, 1, NULL, bind_single_pixel_manager);
wl_display_init_shm(display); /* wl_shm global + pool/buffer handling */
/* libdecor binds wl_output at 4; a lower version is a protocol error for the client. */
wl_global_create(display, &wl_output_interface, 4, NULL, bind_output);
@@ -0,0 +1,4 @@
package com.droiddeck.launcher
/** SessionActivity hosted in its own process so the external display gets an independent compositor. */
class ExternalSessionActivity : SessionActivity()
@@ -125,6 +125,8 @@ class MainActivity : ComponentActivity() {
private var pkgStage by mutableStateOf<String?>(null)
private var pkgPercent by mutableIntStateOf(-1)
private var desktopInstalled by mutableStateOf(false)
private var plasmaMobileInstalled by mutableStateOf(false)
private var desktopEnvironment by mutableStateOf(SessionPrefs.DESKTOP_ENV_LXQT)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
@@ -949,6 +951,8 @@ class MainActivity : ComponentActivity() {
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
desktopEnvironment = if (mode == SessionService.MODE_DESKTOP) desktopEnvironment else null,
plasmaMobileInstalled = plasmaMobileInstalled,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
@@ -992,6 +996,10 @@ class MainActivity : ComponentActivity() {
}
},
onRenderer = { r -> SessionPrefs.setDesktopRenderer(this, r); renderer = r },
onDesktopEnvironment = { value ->
SessionPrefs.setDesktopEnvironment(this, value)
desktopEnvironment = SessionPrefs.desktopEnvironment(this)
},
onGameStorage = { path, label -> setGameStorage(path, label) },
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
@@ -1154,6 +1162,8 @@ class MainActivity : ComponentActivity() {
private fun refresh() {
if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
plasmaMobileInstalled = DesktopCatalog.plasmaMobileInstalled(this)
desktopEnvironment = SessionPrefs.desktopEnvironment(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
@@ -58,6 +58,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionProcess
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
@@ -88,7 +89,7 @@ import kotlin.math.abs
* since it is input rather than a menu), and one Compose layer on top for everything else -
* the HUD line, the loading overlay, the drawer and its dialogs.
*/
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
open class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var sessionOverlay: ComposeView
private lateinit var loading: LoadingState
@@ -262,7 +263,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this, SessionProcess.isExternalDisplay(this)), "dev/input"))
padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
@@ -287,12 +288,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val requestedMode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
val desktopMode = requestedMode == SessionService.MODE_DESKTOP || requestedMode == SessionService.MODE_PLASMA_MOBILE
val needDesktop = desktopMode &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
val wantsPlasma = requestedMode == SessionService.MODE_PLASMA_MOBILE ||
(requestedMode == SessionService.MODE_DESKTOP &&
SessionPrefs.desktopEnvironment(this) == SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE)
val plasmaInstalled = com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this)
val needPlasma = wantsPlasma && !plasmaInstalled && requestedMode == SessionService.MODE_DESKTOP
val needProton = requestedMode == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (requestedMode == SessionService.MODE_PLASMA_MOBILE && (!plasmaInstalled || needDesktop || needRuntime)) {
loading.showEnded("Open Desktop with KDE Plasma Mobile once before selecting it for the second screen.")
} else if (needRuntime || needDesktop || needPlasma || needProton) {
installThenStart(needRuntime, needDesktop, needPlasma, needProton)
}
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -309,6 +321,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
floodColor = it
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(it))
}
}
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
setContentView(root)
@@ -392,11 +405,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
directTouchOnly = SessionProcess.isExternalDisplay(this@SessionActivity),
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
plasmaMobileInstalled = com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this@SessionActivity) &&
com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this@SessionActivity),
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { mode ->
@@ -482,6 +498,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun refreshHomeApp() {
isHomeApp = HomeApp.isDefault(this)
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
@@ -612,7 +629,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun readPrefs() {
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
touchMode = SessionPrefs.touchMode(this)
touchMode = if (SessionProcess.isExternalDisplay(this)) SessionPrefs.TOUCH_DIRECT
else SessionPrefs.touchMode(this)
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
@@ -636,17 +654,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* dialog, download or client restart. Not being able to fetch it stops nothing: the session
* then asks the client for it after sign-in, as it always has.
*/
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, plasmaMobile: Boolean, proton: Boolean) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = when {
runtime -> "runtime"
desktop -> "desktop"
plasmaMobile -> "plasma-mobile"
else -> "proton"
}
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
loading.step = if (runtime) "downloading the Linux runtime" else if (desktop) "downloading the desktop"
else "downloading Proton Experimental (ARM64)"
loading.step = when {
runtime -> "downloading the Linux runtime"
desktop -> "downloading the desktop"
plasmaMobile -> "installing KDE Plasma Mobile"
else -> "downloading Proton Experimental (ARM64)"
}
loading.percent = -1
Thread({
var failedComponent: String? = null
@@ -661,6 +688,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installDesktop()
if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop"
}
if (problem == null && plasmaMobile && !SessionState.stopRequested) {
SessionState.installing = "plasma-mobile"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "plasma-mobile.installing", mapOf("component" to "plasma-mobile"))
problem = installPlasmaMobile()
if (problem == null) SessionEvents.record("plasma-mobile.ready") else failedComponent = "plasma-mobile"
}
if (problem == null && proton && !SessionState.stopRequested) {
SessionState.installing = "proton"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "proton.installing", mapOf("component" to "proton"))
@@ -668,6 +701,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (protonProblem == null) SessionEvents.record("proton.ready")
else SessionEvents.record("proton.skipped", mapOf("reason" to protonProblem))
}
}
uiHandler.post {
installingRuntime = false
SessionState.installing = null
@@ -679,7 +713,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"plasma-mobile" -> "PLASMA_MOBILE_INSTALL_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -734,6 +772,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return problem?.let { "The desktop did not install ($it). Check the connection and press Desktop again." }
}
private fun installPlasmaMobile(): String? {
uiHandler.post { loading.percent = -1; loading.step = "installing KDE Plasma Mobile packages" }
val problem = com.droiddeck.launcher.runtime.PlasmaMobileInstaller.install(
this,
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post { loading.percent = percent; loading.step = stage }
},
)
return problem?.let { "KDE Plasma Mobile did not install: $it. Press Desktop again to retry." }
}
/** Null when the ARM64 Proton is in, else why not; the session goes on either way. */
private fun installProton(): String? {
uiHandler.post { loading.percent = -1; loading.step = "downloading Proton Experimental (ARM64)" }
@@ -742,13 +791,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?: return "catalog unreachable"
return catalog.install(this, entry, progressFor("Proton Experimental (ARM64)", entry.size / 1_000_000))
}
}
override fun surfaceCreated(holder: SurfaceHolder) {
if (installingRuntime || SessionState.stopRequested) return
if (!SessionState.running) {
SessionState.mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
SessionEvents.transition(SessionPhase.STARTING_COMPOSITOR, "compositor.starting")
}
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
val externalDisplay = SessionProcess.isExternalDisplay(this)
val runtimeDir = File(filesDir, if (externalDisplay) ".wayland-rt-external-display" else ".wayland-rt").apply { mkdirs() }
// The compositor hands this keymap to wl_keyboard clients, which is how the guest reads
// the evdev codes we inject.
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
@@ -865,6 +917,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
// Plasma Mobile on a secondary display is a full external desktop. Use that display's
// logical panel size directly; the handheld's saved 16:9 floor and 720p cap otherwise
// leave large bars on the external screen.
if (SessionProcess.isExternalDisplay(this)) {
return Pair(panelW.toInt() and 1.inv(), panelH.toInt() and 1.inv())
}
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
// square display draws for the frame it was made for and cuts the sides off itself.
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
@@ -973,7 +1031,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun onSessionEnded(status: Int) {
closeSecondScreen(reset = true)
closeSecondScreen(reset = true, stopPlasma = true)
if (status == 0) {
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
@@ -1352,29 +1410,38 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
secondScreenDisplays = candidates
val newIds = candidates.map { it.id }.toSet()
if (secondScreenMode != SecondScreenMode.NONE && oldIds.isNotEmpty() && selectedSecondScreenDisplay !in newIds) {
closeSecondScreen(reset = true)
closeSecondScreen(reset = true, stopPlasma = true)
return
}
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
// A choice kept while its display was away (asleep, waking): back as soon as it is.
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) {
closeSecondScreen(reset = true, stopPlasma = true)
} else if (started && secondScreenMode != SecondScreenMode.NONE &&
secondScreenMode != SecondScreenMode.PLASMA_MOBILE && secondScreenPresentation == null) {
// A regular presentation can be restored when its display returns. Plasma Mobile owns
// a separate activity and is deliberately not relaunched from display callbacks.
showSecondScreen(secondScreenMode)
}
}
private fun selectSecondScreenDisplay(displayId: Int) {
if (secondScreenDisplays.none { it.id == displayId }) return
val movePlasma = secondScreenMode == SecondScreenMode.PLASMA_MOBILE && selectedSecondScreenDisplay != displayId
if (movePlasma) closeSecondScreen(reset = true, stopPlasma = true)
selectedSecondScreenDisplay = displayId
SessionState.secondScreenDisplay = displayId
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
if (movePlasma) {
uiHandler.postDelayed({ if (displayId in secondScreenDisplays.map { it.id }) showSecondScreen(SecondScreenMode.PLASMA_MOBILE) }, 700)
} else if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
}
private fun selectSecondScreenMode(mode: SecondScreenMode) {
if (mode == SecondScreenMode.NONE) {
closeSecondScreen(reset = true)
closeSecondScreen(reset = true, stopPlasma = true)
return
}
if (secondScreenDisplays.isEmpty()) {
secondScreenMode = SecondScreenMode.NONE
closeSecondScreen(reset = true, stopPlasma = true)
return
}
if (selectedSecondScreenDisplay !in secondScreenDisplays.map { it.id }) {
@@ -1386,11 +1453,49 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showSecondScreen(mode: SecondScreenMode) {
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
// Not there right now - a panel still waking, say. The choice is kept; the display
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
closeSecondScreen(reset = false)
if (mode == SecondScreenMode.PLASMA_MOBILE) {
closeSecondScreen(reset = true, stopPlasma = true)
refreshSecondScreenDisplays()
} else {
// A regular presentation may be restored when its display wakes or returns.
closeSecondScreen(reset = false)
}
return
}
if (mode == SecondScreenMode.PLASMA_MOBILE) {
if (!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this) ||
!com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this)) {
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
Toast.makeText(this, "Open Desktop with KDE Plasma Mobile once to install it.", Toast.LENGTH_LONG).show()
return
}
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
try {
val launch = Intent(this, ExternalSessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_PLASMA_MOBILE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
val options = android.app.ActivityOptions.makeBasic()
.setLaunchDisplayId(target.displayId)
.toBundle()
startActivity(launch, options)
secondScreenMode = mode
SessionState.secondScreenMode = mode
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
} catch (e: Exception) {
Log.w(TAG, "could not open Plasma Mobile on ${target.name}", e)
Toast.makeText(this, "Could not open KDE Plasma Mobile on ${target.name}.", Toast.LENGTH_LONG).show()
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
}
return
}
if (secondScreenMode == SecondScreenMode.PLASMA_MOBILE) {
SessionService.stopExternalDisplaySession(this)
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
}
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
presentation?.closeControls()
@@ -1438,7 +1543,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun closeSecondScreen(reset: Boolean) {
private fun closeSecondScreen(reset: Boolean, stopPlasma: Boolean = false) {
if (stopPlasma && secondScreenMode == SecondScreenMode.PLASMA_MOBILE) {
SessionService.stopExternalDisplaySession(this)
}
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
if (reset) {
@@ -1447,11 +1555,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) {
SessionPrefs.TOUCH_PAD -> true
SessionPrefs.TOUCH_DIRECT -> false
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
/** Direct touch on external desktops; otherwise use the user's touch setting. */
private fun usingTouchpad(): Boolean {
if (SessionProcess.isExternalDisplay(this)) return false
return when (SessionPrefs.touchMode(this)) {
SessionPrefs.TOUCH_PAD -> true
SessionPrefs.TOUCH_DIRECT -> false
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
}
}
/** The picture's rectangle inside the view: where the pointer may go. */
@@ -1541,7 +1652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Auto: the touch pad when there is no controller - except on the desktop, where the
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
else -> !PadBridge.anyControllerConnected() &&
SessionState.mode != SessionService.MODE_DESKTOP && SessionState.mode != SessionService.MODE_PLASMA_MOBILE
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
@@ -1560,7 +1672,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
// The external activity owns Plasma Mobile on the second display. Reopening it whenever
// this activity returns caused a failed guest to relaunch in a loop.
if (secondScreenMode != SecondScreenMode.NONE && secondScreenMode != SecondScreenMode.PLASMA_MOBILE &&
secondScreenPresentation == null) {
showSecondScreen(secondScreenMode)
}
SessionService.setActivityVisible(this, true)
}
@@ -1568,8 +1685,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
started = false
displayManager.unregisterDisplayListener(displayListener)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
// onStart puts it back.
closeSecondScreen(reset = false)
// onStart puts a regular presentation back. Plasma Mobile runs in its own activity.
if (secondScreenMode != SecondScreenMode.PLASMA_MOBILE) closeSecondScreen(reset = false)
SessionService.setActivityVisible(this, false)
super.onStop()
}
@@ -8,6 +8,7 @@ import com.droiddeck.launcher.core.FileUtils;
import com.droiddeck.launcher.core.SessionPart;
import com.droiddeck.launcher.core.HostProcess;
import com.droiddeck.launcher.core.TarZst;
import com.droiddeck.launcher.session.SessionProcess;
import java.io.File;
import java.io.InputStream;
@@ -50,7 +51,8 @@ public class PulseAudioComponent extends SessionPart {
/** As above, with a microphone fed from {@code micFifoPath}; null for output only. */
public PulseAudioComponent(Context context, String micFifoPath) {
this.workingDir = new File(context.getFilesDir(), "pulseaudio");
this.workingDir = new File(context.getFilesDir(),
SessionProcess.INSTANCE.isExternalDisplay(context) ? "pulseaudio-external-display" : "pulseaudio");
this.micFifoPath = micFifoPath;
}
@@ -52,7 +52,12 @@ object DeviceReport {
b.append("DroidDeck session report\n")
b.append("========================\n")
k("Written", SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date()))
k("Session mode", when (mode) { SessionService.MODE_DESKTOP -> "desktop (labwc/LXQt)"; SessionService.MODE_RUN -> "a program under gamescope"; else -> "Steam client (gamescope)" })
k("Session mode", when (mode) {
SessionService.MODE_DESKTOP -> if (SessionPrefs.desktopEnvironment(context) == SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE) "desktop (KDE Plasma Mobile)" else "desktop (labwc/LXQt)"
SessionService.MODE_PLASMA_MOBILE -> "secondary desktop (KDE Plasma Mobile)"
SessionService.MODE_RUN -> "a program under gamescope"
else -> "Steam client (gamescope)"
})
h("App")
runCatching {
@@ -8,6 +8,7 @@ enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
PLASMA_MOBILE("plasma-mobile", "KDE Plasma Mobile"),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
}
@@ -18,6 +18,8 @@ import java.io.File
*/
object DesktopCatalog {
private const val TAG = "DesktopCatalog"
/** Advance this when the Plasma root's required package set changes. */
internal const val PLASMA_MOBILE_PACKAGE_REVISION = "archlinuxarm-plasma-apps-v2"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
/**
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
@@ -78,6 +80,19 @@ object DesktopCatalog {
fun desktopInstalled(context: Context): Boolean =
File(LinuxRuntime.rootDir(context), "usr/bin/labwc").isFile
/** Plasma Mobile lives in a copied Arch root so its package updates never touch Steam's root. */
fun plasmaMobileInstalled(context: Context): Boolean {
val root = LinuxRuntime.plasmaRootDir(context)
return LinuxRuntime.isPlasmaRootCurrent(context) &&
FileUtils.readString(plasmaMobileMarker(context))?.trim() == PLASMA_MOBILE_PACKAGE_REVISION &&
File(root, "usr/bin/kwin_wayland").isFile &&
File(root, "usr/bin/plasmashell").isFile &&
File(root, "usr/share/plasma/shells/org.kde.plasma.mobileshell").isDirectory
}
internal fun plasmaMobileMarker(context: Context): File =
File(context.filesDir, ".droiddeck-plasma-mobile-installed")
/** Downloads, verifies and installs one package. Returns null on success, else a message. */
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
@@ -89,7 +89,12 @@ public final class LinuxRuntime {
/** The Vulkan ICD manifest the rootfs ships for the device GPU, or null when it has none. */
public static File vulkanIcd(Context context) {
File icdDir = new File(rootDir(context), "usr/share/vulkan/icd.d");
return vulkanIcd(rootDir(context));
}
/** The Vulkan ICD manifest under a selected rootfs, or null when it has none. */
public static File vulkanIcd(File root) {
File icdDir = new File(root, "usr/share/vulkan/icd.d");
File[] manifests = icdDir.listFiles((dir, name) -> name.endsWith(".json"));
if (manifests == null) return null;
for (File manifest : manifests) {
@@ -114,11 +119,25 @@ public final class LinuxRuntime {
public static List<String> command(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds,
List<String> guestCommand) {
File root = rootDir(context);
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
return commandForRoot(context, rootDir(context), sessionRoot, runtimeDir, externalStorage, extraBinds,
guestCommand, false);
}
/** Builds a command for a selected rootfs; installers may also request PRoot's fake-root identity. */
public static List<String> commandForRoot(Context context, File root, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds,
List<String> guestCommand) {
return commandForRoot(context, root, sessionRoot, runtimeDir, externalStorage, extraBinds,
guestCommand, false);
}
public static List<String> commandForRoot(Context context, File root, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds,
List<String> guestCommand, boolean fakeRoot) {
List<String> binds = binds(context, root, sessionRoot, runtimeDir, externalStorage, extraBinds);
// A session's binds are the view its programs have; a one-off command's are not recorded.
if (sessionRoot != null) lastBinds = binds;
List<String> cmd = prootPrefix(context, root, "/root");
List<String> cmd = prootPrefix(context, root, "/root", fakeRoot);
for (String spec : binds) bind(cmd, spec);
cmd.addAll(guestCommand);
return cmd;
@@ -172,7 +191,12 @@ public final class LinuxRuntime {
/** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
File root = rootDir(context);
return binds(context, rootDir(context), sessionRoot, runtimeDir, externalStorage, extraBinds);
}
/** As above, using the selected rootfs for files prepared inside the guest. */
public static List<String> binds(Context context, File root, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
List<String> cmd = new ArrayList<>();
bind(cmd, "/dev");
bind(cmd, "/proc");
@@ -0,0 +1,172 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.file.Files
/** Installs the optional Plasma Mobile shell into its isolated Arch Linux ARM package root. */
object PlasmaMobileInstaller {
private val packages = listOf("plasma-mobile", "plasma-settings", "angelfish")
@Synchronized
fun install(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
if (!LinuxRuntime.isInstalled(context)) return "The Linux runtime is not installed"
if (!DesktopCatalog.desktopInstalled(context)) return "The Linux desktop package is not installed"
listener?.onProgress("Preparing an isolated Linux runtime for KDE", -1)
LinuxRuntime.preparePlasmaRoot(context, listener)?.let { return it }
val root = LinuxRuntime.plasmaRootDir(context)
if (!File(root, "usr/bin/pacman").isFile) return "This Linux runtime has no pacman package manager"
val pacmanConfig = File(root, "etc/pacman.conf")
if (!pacmanConfig.isFile) return "This Linux runtime has no pacman configuration"
val originalPacmanConfig = try {
pacmanConfig.readText()
} catch (e: Exception) {
return "Could not read the pacman configuration: ${e.message ?: e.javaClass.simpleName}"
}
val sessionRoot = File(context.filesDir, "plasma-install-session").apply { mkdirs() }
val runtimeDir = File(context.filesDir, ".plasma-install-rt").apply { mkdirs() }
val packageLock = File(root, "var/lib/pacman/db.lck")
if (packageLock.exists()) {
val age = System.currentTimeMillis() - packageLock.lastModified()
if (age < 60_000L) return "The isolated package manager is still active; wait a moment and retry"
if (!packageLock.delete()) return "Could not clear the stale isolated package-manager lock"
}
prepareKeyringPermissions(File(root, "etc/pacman.d/gnupg"))?.let {
return "Could not make the isolated Arch package keyring writable: $it"
}
val guest = listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root",
"PATH=/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8", "TERM=xterm",
// The isolated root inherits sync databases from the runtime image. Force fresh
// repository databases so the rolling package filenames match the live mirrors.
// Replace only paths present in incoming packages when the cloned runtime has
// unowned files from its base image; the Steam root remains untouched.
"/bin/sh", "-c",
"/usr/bin/pacman-key --init && /usr/bin/pacman-key --populate archlinuxarm && " +
"/usr/bin/pacman -Syyu --needed --noconfirm --overwrite '*' ${packages.joinToString(" ")} && " +
// Keep Firefox and Discover out of the Plasma-only clone even if the base root
// already installed them; the original desktop root remains unchanged.
"for excluded in firefox discover; do " +
"if /usr/bin/pacman -Qq \"\$excluded\" >/dev/null 2>&1; then " +
"/usr/bin/pacman -Rns --noconfirm \"\$excluded\" || exit; fi; done",
)
val command = LinuxRuntime.commandForRoot(
context, root, sessionRoot, runtimeDir, null, null, guest, true,
)
return try {
pacmanConfig.writeText(withInstallerDownloadSettings(originalPacmanConfig))
listener?.onProgress("Preparing KDE Plasma Mobile", -1)
val builder = ProcessBuilder(command).redirectErrorStream(true)
builder.directory(root)
val hostEnv = builder.environment()
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(context).path
hostEnv["PROOT_TMP_DIR"] = context.cacheDir.path
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let {
hostEnv["LD_LIBRARY_PATH"] = it
}
val process = builder.start()
val tail = ArrayDeque<String>()
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
while (true) {
val line = reader.readLine() ?: break
if (line.isNotBlank()) {
if (tail.size == 5) tail.removeFirst()
tail.addLast(line.take(180))
val lower = line.lowercase()
listener?.onProgress(
when {
"synchronizing" in lower || "database" in lower -> "Refreshing KDE package list"
"downloading" in lower || "retrieving" in lower -> "Downloading KDE Plasma Mobile packages"
"installing" in lower || "checking" in lower -> "Installing KDE Plasma Mobile"
else -> "Installing KDE Plasma Mobile"
},
-1,
)
}
}
}
val status = process.waitFor()
if (status != 0) {
val detail = tail.joinToString(" · ")
return "pacman exited with status $status" + if (detail.isNotEmpty()) ": $detail" else ""
}
val packageMarker = DesktopCatalog.plasmaMobileMarker(context)
FileUtils.writeString(packageMarker, "${DesktopCatalog.PLASMA_MOBILE_PACKAGE_REVISION}\n")
if (!packageMarker.isFile) return "Plasma Mobile packages installed, but DroidDeck could not save the installation marker"
if (!DesktopCatalog.plasmaMobileInstalled(context)) {
packageMarker.delete()
return "pacman finished, but the Plasma Mobile shell was not found in the runtime"
}
null
} catch (e: Exception) {
"Could not install KDE Plasma Mobile: ${e.message ?: e.javaClass.simpleName}"
} finally {
try {
pacmanConfig.writeText(originalPacmanConfig)
} catch (e: Exception) {
android.util.Log.e("PlasmaMobileInstaller", "could not restore pacman config", e)
}
}
}
/** Pacman downloads as its caller, without Landlock, inside the confined guest root. */
private fun withInstallerDownloadSettings(config: String): String {
val lines = config.lines().toMutableList()
val optionsIndex = lines.indexOfFirst { it.trim().equals("[options]", ignoreCase = true) }
if (optionsIndex < 0) {
lines.add(0, "[options]")
lines.add(1, "#DownloadUser =")
lines.add(2, "DisableSandbox = true")
return lines.joinToString("\n")
}
var sectionEnd = optionsIndex + 1
while (sectionEnd < lines.size) {
val line = lines[sectionEnd].trim()
if (line.startsWith("[") && line.endsWith("]")) break
sectionEnd++
}
val overrides = listOf("DownloadUser" to "#DownloadUser =", "DisableSandbox" to "DisableSandbox = true")
for ((name, setting) in overrides) {
val matcher = Regex("^\\s*#?\\s*${name}\\s*=.*$", RegexOption.IGNORE_CASE)
var replaced = false
for (index in optionsIndex + 1 until sectionEnd) {
if (matcher.matches(lines[index])) {
lines[index] = setting
replaced = true
break
}
}
if (!replaced) lines.add(sectionEnd++, setting)
}
return lines.joinToString("\n")
}
/** Keep GnuPG's private keyring writable by this app-private PRoot root. */
private fun prepareKeyringPermissions(directory: File): String? {
if (!directory.exists() && !directory.mkdirs()) return directory.path
if (Files.isSymbolicLink(directory.toPath()) || !directory.isDirectory) return directory.path
fun makeOwnerWritable(file: File): String? {
if (Files.isSymbolicLink(file.toPath())) return file.path
val ok = if (file.isDirectory) {
file.setReadable(true, true) && file.setWritable(true, true) &&
file.setExecutable(true, true)
} else {
file.setReadable(true, true) && file.setWritable(true, true)
}
if (!ok) return file.path
if (file.isDirectory) {
val children = file.listFiles() ?: return file.path
for (child in children) makeOwnerWritable(child)?.let { return it }
}
return null
}
return makeOwnerWritable(directory)
}
}
@@ -35,7 +35,7 @@ object CrashHandler {
worker.join(8000)
}
// The session's tree would outlive us otherwise, and block the next launch.
OrphanReaper.reap("app crash")
OrphanReaper.reap("app crash", context)
} catch (e: Throwable) {
// Nothing more to do for a crash inside the crash handler.
} finally {
@@ -0,0 +1,4 @@
package com.droiddeck.launcher.session
/** Separate service process for a full Linux desktop running on an external Android display. */
class ExternalSessionService : SessionService()
@@ -1,22 +1,22 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import java.io.File
/**
* Kills every process of this app's uid other than the app itself.
* Kills leftover Linux processes owned by this app while preserving live DroidDeck process trees.
*
* A session is a tree of processes under proot, and proot dies with the app only when the app's
* own teardown runs. When the app is killed or crashes instead, the tree stays: proot holding the
* rootfs, gamescope holding the GPU, Steam holding its lock file - and the next session never
* comes up, or comes up beside a client that is already running. A user on a Thor Pro had to
* force-stop the app before every launch. Everything of ours that should be alive between
* sessions is inside the app process, so at session start anything else under our uid is a
* leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name.
*
* The one exception is work the app started outside any session and is still waiting on - a
* Flatpak install from the store - registered with [keep]: that process and everything under it
* are left to finish.
* sessions lives in Android processes, so another active session can own Linux children under
* the same uid. Those trees are protected by skipping the sweep while a sibling app process is
* active. Work started outside a session and still waiting - such as a Flatpak install - is
* registered with [keep] and its process tree is spared. Each pid is checked by uid at kill time,
* never by name.
*/
object OrphanReaper {
private const val TAG = "OrphanReaper"
@@ -27,7 +27,11 @@ object OrphanReaper {
fun release(pid: Int) { kept.remove(pid) }
fun reap(reason: String): Int {
fun reap(reason: String, context: Context): Int {
if (SessionProcess.hasSiblingProcess(context)) {
Log.i(TAG, "skipped process sweep while a sibling app process is active - $reason")
return 0
}
val me = android.os.Process.myPid()
val uid = android.os.Process.myUid()
val spared = descendants(kept)
@@ -78,4 +82,5 @@ object OrphanReaper {
-1
}
}
}
@@ -69,6 +69,13 @@ object SessionFiles {
// staging them into a runtime without it would make the desktop look present when it is not.
val desktop = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
"usr/local/bin/droiddeck-plasma-mobile" to "usr/local/bin/droiddeck-plasma-mobile",
// Plasma Mobile 6.7.5 backports: populate the Folio app catalog without a Wayland
// connection handle and reset the native mobile task switcher's gesture state.
"usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" to "usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so",
"usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" to "usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so",
"usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml" to "usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml",
"usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml" to "usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml",
// Games and emulators from the menu, full screen in a gamescope of their own.
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
@@ -41,7 +41,8 @@ object SessionPaths {
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context)
else File(context.cacheDir, "session-logs")
val baseName = "session-$stamp"
val scope = if (SessionProcess.isExternalDisplay(context)) "external-" else ""
val baseName = "session-$scope$stamp"
var made = File(parent, baseName)
var suffix = 2
while (made.exists()) made = File(parent, "$baseName-${suffix++}")
@@ -552,6 +552,25 @@ object SessionPrefs {
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-renderer-failed").delete()
}
const val DESKTOP_ENV_LXQT = "lxqt"
const val DESKTOP_ENV_PLASMA_MOBILE = "plasma-mobile"
val desktopEnvironmentChoices = listOf(
DESKTOP_ENV_LXQT to "LXQt",
DESKTOP_ENV_PLASMA_MOBILE to "KDE Plasma Mobile",
)
/** Shell used when the user opens the primary Desktop button. LXQt remains the default. */
fun desktopEnvironment(context: Context): String =
prefs(context).getString("desktopEnvironment", DESKTOP_ENV_LXQT)
?.takeIf { value -> desktopEnvironmentChoices.any { it.first == value } }
?: DESKTOP_ENV_LXQT
fun setDesktopEnvironment(context: Context, environment: String) {
val value = environment.takeIf { candidate -> desktopEnvironmentChoices.any { it.first == candidate } }
?: DESKTOP_ENV_LXQT
prefs(context).edit().putString("desktopEnvironment", value).apply()
}
/**
* HDR10 output for MODE_STEAM / MODE_DESKTOP. Off by default. Honoured only when the panel
* lists HDR10 (HdrSupport), and decided when the compositor starts, which is once per app
@@ -583,7 +602,11 @@ object SessionPrefs {
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
*/
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun prefMode(mode: String): String = when (mode) {
SessionService.MODE_RUN -> SessionService.MODE_STEAM
SessionService.MODE_PLASMA_MOBILE -> SessionService.MODE_DESKTOP
else -> mode
}
fun suspendPolicy(context: Context, mode: String): String =
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
@@ -0,0 +1,124 @@
package com.droiddeck.launcher.session
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.os.Build
import android.util.Log
import java.io.File
import java.io.RandomAccessFile
import java.nio.channels.FileLock
import java.nio.channels.OverlappingFileLockException
/** Identifies the dedicated Android process used to host a second-display Linux session. */
object SessionProcess {
private const val TAG = "SessionProcess"
private const val SECONDARY_SUFFIX = ":externalDisplay"
private const val MAIN_SESSION_LOCK = ".droiddeck-main-session.lock"
private const val EXTERNAL_SESSION_LOCK = ".droiddeck-external-session.lock"
@Volatile private var heldLock: FileLock? = null
@Volatile private var heldLockFile: RandomAccessFile? = null
fun isExternalDisplay(context: Context): Boolean = processName(context) == context.packageName + SECONDARY_SUFFIX
/**
* Keeps the sibling session discoverable even when Android restricts cross-process /proc
* reads. The service holds this process-specific file lock for the whole Linux session.
*/
@Synchronized
fun acquireSessionLock(context: Context): Boolean {
if (heldLock?.isValid == true) return true
val path = File(context.filesDir, ownLockName(context))
val file = RandomAccessFile(path, "rw")
val lock = try {
file.channel.tryLock()
} catch (e: OverlappingFileLockException) {
Log.w(TAG, "another session process already owns ${path.path}", e)
null
} catch (e: Exception) {
Log.e(TAG, "could not lock ${path.path}", e)
null
}
if (lock == null) {
file.close()
return false
}
heldLockFile = file
heldLock = lock
return true
}
@Synchronized
fun releaseSessionLock() {
try {
heldLock?.release()
} catch (e: Exception) {
Log.w(TAG, "could not release the session lock", e)
}
try {
heldLockFile?.close()
} catch (e: Exception) {
Log.w(TAG, "could not close the session lock file", e)
}
heldLock = null
heldLockFile = null
}
/** A sibling lock means its guest process tree must not be swept as an orphan. */
fun hasSiblingSession(context: Context): Boolean = isLocked(File(context.filesDir, siblingLockName(context)))
/** Includes an idle app process during display handoff, so legacy name-based sweeps stay safe. */
fun hasSiblingProcess(context: Context): Boolean {
if (hasSiblingSession(context)) return true
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return true
val processes = try {
manager.runningAppProcesses ?: return true
} catch (e: Exception) {
Log.w(TAG, "could not inspect sibling app processes; skipping cleanup", e)
return true
}
return processes.any { process ->
process.pid != android.os.Process.myPid() &&
(process.processName == context.packageName || process.processName.startsWith(context.packageName + ":"))
}
}
private fun isLocked(file: File): Boolean {
val probe = try {
RandomAccessFile(file, "rw")
} catch (e: Exception) {
Log.w(TAG, "could not inspect sibling session lock ${file.path}; skipping cleanup", e)
return true
}
return try {
val lock = probe.channel.tryLock()
if (lock == null) true else {
lock.release()
false
}
} catch (_: OverlappingFileLockException) {
true
} catch (e: Exception) {
Log.w(TAG, "could not inspect sibling session lock ${file.path}; skipping cleanup", e)
true
} finally {
try { probe.close() } catch (_: Exception) { }
}
}
private fun ownLockName(context: Context): String =
if (isExternalDisplay(context)) EXTERNAL_SESSION_LOCK else MAIN_SESSION_LOCK
private fun siblingLockName(context: Context): String =
if (isExternalDisplay(context)) MAIN_SESSION_LOCK else EXTERNAL_SESSION_LOCK
private fun processName(context: Context): String =
if (Build.VERSION.SDK_INT >= 28) Application.getProcessName() else readCommandLine(android.os.Process.myPid())
private fun readCommandLine(pid: Int): String = try {
File("/proc/$pid/cmdline").readText().substringBefore('\u0000')
} catch (_: Exception) {
""
}
}
@@ -23,6 +23,7 @@ import android.os.PowerManager
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.SessionActivity
import com.droiddeck.launcher.ExternalSessionActivity
import com.droiddeck.launcher.audio.DirectAudioRelayComponent
import com.droiddeck.launcher.audio.PulseAudioComponent
import com.droiddeck.launcher.core.CpuCores
@@ -56,7 +57,7 @@ import java.util.Locale
*
* The activity comes and goes on top of this; see [com.droiddeck.launcher.wayland.CompositorHost].
*/
class SessionService : Service() {
open class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
@@ -164,14 +165,14 @@ class SessionService : Service() {
return START_NOT_STICKY
}
}
startForeground(NOTIFICATION_ID, buildNotification())
if (!SessionState.running) SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
startForeground(notificationId(), buildNotification())
if (SessionState.running) return START_NOT_STICKY
if (SessionState.stopRequested) {
SessionState.running = true
stopSession(0)
return START_NOT_STICKY
}
SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
@@ -200,12 +201,21 @@ class SessionService : Service() {
// replaces can report its own exit in the gap between the two, and that exit must not
// be taken as this one's.
val gen = ++sessionGen
if (!SessionProcess.acquireSessionLock(this)) {
SessionState.running = false
Log.e(TAG, "another ${if (SessionProcess.isExternalDisplay(this)) "external" else "handheld"} session already owns its process lock")
SessionEvents.fail("SESSION_ALREADY_RUNNING", "Another DroidDeck session is already running", -1)
SessionState.notifyEnded(-1)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
acquireLocks()
Thread({
// A tree the last session left behind (the app was killed or crashed, so its teardown
// never ran) would hold the rootfs, the GPU and Steam's lock: nothing of ours should
// be alive between sessions outside this process.
OrphanReaper.reap("session starting")
OrphanReaper.reap("session starting", this)
runSession(gen)
}, "session-start").start()
// The activity or the notification stops us; the system must not resurrect a session whose
@@ -230,6 +240,26 @@ class SessionService : Service() {
}
}
private fun readPlasmaShellPid(directory: File?): Int {
val pidFile = directory?.let { File(it, "plasma-shell.pid") } ?: return -1
return runCatching { pidFile.readText().trim().toInt() }.getOrDefault(-1)
}
/** Plasma can outlive KWin when PRoot tears down, so stop this session's recorded shell. */
private fun stopPlasmaShell(pid: Int, directory: File?) {
if (pid < 2 || directory == null) return
val proc = File("/proc/$pid")
val command = runCatching {
File(proc, "cmdline").readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ')
}.getOrDefault("")
val environment = runCatching {
File(proc, "environ").readBytes().toString(Charsets.UTF_8)
}.getOrDefault("")
if (!command.contains("plasmashell") || !environment.contains(directory.name)) return
android.os.Process.killProcess(pid)
Log.i(TAG, "stopped Plasma Mobile shell process $pid")
}
private fun extraEnv(): List<String> {
val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList()
val lines = FileUtils.readString(file)?.lines().orEmpty()
@@ -253,18 +283,27 @@ class SessionService : Service() {
private fun runSession(gen: Int) {
SessionTerminal.clear()
val externalDisplay = SessionProcess.isExternalDisplay(this)
val plasmaDesktop = externalDisplay || SessionState.mode == MODE_PLASMA_MOBILE ||
(SessionState.mode == MODE_DESKTOP &&
SessionPrefs.desktopEnvironment(this) == SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE)
val root = if (plasmaDesktop) LinuxRuntime.plasmaRootDir(this) else LinuxRuntime.rootDir(this)
if (plasmaDesktop && !com.droiddeck.launcher.runtime.DesktopCatalog.plasmaMobileInstalled(this)) {
Log.e(TAG, "Plasma Mobile's isolated runtime is not installed or is out of date")
stopSession(-1)
return
}
try {
LinuxRuntime.writeAccounts(this)
LinuxRuntime.writeAccounts(this, root)
} catch (e: Exception) {
Log.e(TAG, "could not write the guest's passwd/group", e)
stopSession(-1)
return
}
val root = LinuxRuntime.rootDir(this)
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
val sessionRoot = LinuxRuntime.sessionRoot(this, externalDisplay).apply { mkdirs() }
val runtimeDir = File(filesDir, if (externalDisplay) ".wayland-rt-external-display" else ".wayland-rt").apply { mkdirs() }
if (!SessionProcess.hasSiblingProcess(this)) killStragglers()
SessionFiles.stage(this, root)
val sessionDir = openSessionFolder()
@@ -277,12 +316,17 @@ class SessionService : Service() {
// 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
addClientEnvironment(guest, runtimeDir, steamHere)
addClientEnvironment(guest, runtimeDir, steamHere, root)
val pulse = startAudio(guest, sessionDir)
guest.add("BL_WIDTH=" + size.first)
guest.add("BL_HEIGHT=" + size.second)
if (SessionState.mode == MODE_DESKTOP) {
guest.add("BL_DESKTOP_ENV=" + SessionPrefs.desktopEnvironment(this))
} else if (SessionState.mode == MODE_PLASMA_MOBILE) {
guest.add("BL_DESKTOP_ENV=" + SessionPrefs.DESKTOP_ENV_PLASMA_MOBILE)
}
if (SessionState.hdr) {
// The activity opened the compositor's HDR gate: gamescope offers HDR to its clients
// and DXVK takes the HDR10 swapchain when a game asks for one.
@@ -360,12 +404,12 @@ class SessionService : Service() {
// Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
FileUtils.clear(File(cacheDir, if (externalDisplay) "shm-external-display" else "shm"))
val binds = sessionBinds(controllersOn, fakeInputDir)
val binds = sessionBinds(controllersOn, fakeInputDir, root, externalDisplay)
val command = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
val command = LinuxRuntime.commandForRoot(
this, root, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
)
val hostEnv = HostEnvironment()
@@ -382,8 +426,8 @@ class SessionService : Service() {
val prootLibs = LinuxRuntime.prootLibraryPath(this)
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
val terminalCommand = LinuxRuntime.command(
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, shellGuest,
val terminalCommand = LinuxRuntime.commandForRoot(
this, root, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, shellGuest,
)
val terminalHostEnvironment = LinkedHashMap(System.getenv())
hostEnv.asArray().forEach { entry ->
@@ -483,7 +527,7 @@ class SessionService : Service() {
}
/** 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) {
private fun addClientEnvironment(guest: MutableList<String>, runtimeDir: File, steamHere: Boolean, root: File) {
guest.add("/usr/bin/env")
guest.add("-i")
guest.add("HOME=/root")
@@ -502,7 +546,7 @@ class SessionService : Service() {
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) }
LinuxRuntime.vulkanIcd(root)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip, when one is set (by the user, or by Auto): 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
@@ -576,7 +620,7 @@ class SessionService : Service() {
// 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) &&
val wantsMic = SessionState.mode == MODE_STEAM && 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.
@@ -666,7 +710,7 @@ class SessionService : Service() {
}
/** 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> {
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File, root: File, externalDisplay: Boolean): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
@@ -687,8 +731,10 @@ class SessionService : Service() {
// 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")
val home = if (externalDisplay) File(filesDir, "plasma-mobile-home") else File(root, "root")
home.mkdirs()
File(home, "Storage").mkdirs()
if (externalDisplay) binds.add("${home.path}:/root")
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
@@ -697,10 +743,10 @@ class SessionService : Service() {
if (library != null) {
val problem = GameStorage.prepare(library.path)
if (problem == null) {
File(LinuxRuntime.rootDir(this), "mnt/droiddeck-sd").mkdirs()
File(root, "mnt/droiddeck-sd").mkdirs()
binds.add("${library.path}:/mnt/droiddeck-sd")
// Links, prefixes and Steam entries made before the rename still name the old path.
File(LinuxRuntime.rootDir(this), "mnt/bannerlator-sd").mkdirs()
File(root, "mnt/bannerlator-sd").mkdirs()
binds.add("${library.path}:/mnt/bannerlator-sd")
Log.i(TAG, "game storage: ${library.path} -> /mnt/droiddeck-sd (\"${library.label}\")")
} else {
@@ -711,13 +757,13 @@ class SessionService : Service() {
}
// 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}")
for (addedRoot in com.droiddeck.launcher.frontend.AddedGames.roots(this)) {
if (addedRoot.host.isDirectory && addedRoot.host.canRead()) {
File(root, addedRoot.guest.removePrefix("/")).mkdirs()
binds.add(addedRoot.host.path + ":" + addedRoot.guest)
Log.i(TAG, "added games: ${addedRoot.host} -> ${addedRoot.guest}")
} else {
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
Log.w(TAG, "added games: ${addedRoot.host} is not a readable folder this session")
}
}
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
@@ -973,6 +1019,7 @@ class SessionService : Service() {
SessionEvents.fail(code, message, failureStatus)
}
SessionState.notifyEnded(status)
SessionProcess.releaseSessionLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
@@ -1004,6 +1051,7 @@ class SessionService : Service() {
}
private fun stopSession(status: Int) {
if (!SessionProcess.isExternalDisplay(this)) stopExternalDisplaySession(this)
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
@@ -1037,6 +1085,7 @@ class SessionService : Service() {
// dump the crash buffer. That was about four and a half seconds of blocked main thread,
// and Android ANR'd the app for it: the desktop session that would not let go.
val ended = SessionPaths.take()
val plasmaShellPid = readPlasmaShellPid(ended)
if (ended != null) Thread({ collectSessionArtifacts(ended) }, "session-collect").start()
components.reversed().forEach {
try {
@@ -1054,10 +1103,12 @@ class SessionService : Service() {
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
stopPlasmaShell(plasmaShellPid, ended)
killGuestLeftovers()
finishSessionStop(status, stoppedGen)
}, "session-teardown").start()
} else {
stopPlasmaShell(plasmaShellPid, ended)
finishSessionStop(status, stoppedGen)
}
}
@@ -1077,6 +1128,7 @@ class SessionService : Service() {
override fun onDestroy() {
stopSession(0)
if (!SessionState.running) SessionProcess.releaseSessionLock()
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
@@ -1138,8 +1190,9 @@ class SessionService : Service() {
private fun buildNotification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
val channelId = notificationChannelId()
// IMPORTANCE_LOW: it must never make a sound or push a heads-up over a game.
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.session_channel),
val channel = NotificationChannel(channelId, getString(R.string.session_channel),
NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.session_channel_description)
setShowBadge(false)
@@ -1149,23 +1202,23 @@ class SessionService : Service() {
manager?.createNotificationChannel(channel)
val open = PendingIntent.getActivity(
this, 0,
Intent(this, SessionActivity::class.java)
this, notificationId(),
Intent(this, sessionActivityClass())
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE,
)
val stop = PendingIntent.getService(
this, 1, Intent(this, SessionService::class.java).setAction(ACTION_STOP),
this, notificationId() + 1, Intent(this, sessionServiceClass()).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
val resume = PendingIntent.getActivity(
this, 2,
Intent(this, SessionActivity::class.java)
this, notificationId() + 2,
Intent(this, sessionActivityClass())
.setAction(ACTION_RESUME)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
return Notification.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(if (SessionState.suspended) R.string.session_notification_paused else R.string.session_notification))
@@ -1183,9 +1236,20 @@ class SessionService : Service() {
}
private fun refreshNotification() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
getSystemService(NotificationManager::class.java)?.notify(notificationId(), buildNotification())
}
private fun notificationId(): Int = if (SessionProcess.isExternalDisplay(this)) EXTERNAL_NOTIFICATION_ID else NOTIFICATION_ID
private fun notificationChannelId(): String =
if (SessionProcess.isExternalDisplay(this)) EXTERNAL_CHANNEL_ID else CHANNEL_ID
private fun sessionActivityClass(): Class<*> =
if (SessionProcess.isExternalDisplay(this)) ExternalSessionActivity::class.java else SessionActivity::class.java
private fun sessionServiceClass(): Class<*> =
if (SessionProcess.isExternalDisplay(this)) ExternalSessionService::class.java else SessionService::class.java
companion object {
private const val TAG = "SessionService"
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
@@ -1196,6 +1260,8 @@ class SessionService : Service() {
private const val ENV_SWITCH = "Download/droiddeck-env"
private const val CHANNEL_ID = "session"
private const val NOTIFICATION_ID = 1001
private const val EXTERNAL_CHANNEL_ID = "session-external-display"
private const val EXTERNAL_NOTIFICATION_ID = 1002
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
@@ -1208,7 +1274,7 @@ class SessionService : Service() {
private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED"
private const val EXTRA_AUXILIARY_PID = "auxiliaryPid"
/** Command lines that can only belong to a session of ours. */
private val STRAGGLERS = listOf("bannerlator-session", "gamescope", "Xwayland", "steamrtarm64",
private val STRAGGLERS = listOf("bannerlator-session", "gamescope", "Xwayland", "kwin_wayland", "kwin_x11", "plasmashell", "steamrtarm64",
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
/** How long proot gets to run its own cleanup before it is killed outright. */
private const val GRACE_MS = 1200L
@@ -1224,6 +1290,7 @@ class SessionService : Service() {
const val EXTRA_MODE = "mode"
const val MODE_STEAM = "steam"
const val MODE_DESKTOP = "lxqt"
const val MODE_PLASMA_MOBILE = "plasma-mobile"
/** A program inside the runtime, fullscreen under gamescope (EXTRA_PROGRAM = its path). */
const val MODE_RUN = "run"
const val EXTRA_PROGRAM = "program"
@@ -1234,11 +1301,16 @@ class SessionService : Service() {
const val EXTRA_STEAM_UI = "steamUi"
const val EXTRA_STEAM_URL = "steamUrl"
private fun serviceClass(mode: String): Class<*> =
if (mode == MODE_PLASMA_MOBILE) ExternalSessionService::class.java else SessionService::class.java
private fun currentServiceClass(): Class<*> = serviceClass(SessionState.mode)
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
) {
val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode)
val intent = Intent(context, serviceClass(mode)).putExtra(EXTRA_MODE, mode)
if (program != null) intent.putExtra(EXTRA_PROGRAM, program)
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
@@ -1252,29 +1324,33 @@ class SessionService : Service() {
SessionState.stopRequested = true
return
}
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_STOP))
context.startService(Intent(context, currentServiceClass()).setAction(ACTION_STOP))
}
/** Stops the independent Linux desktop hosted on the other Android display, if running. */
fun stopExternalDisplaySession(context: Context): Boolean =
context.stopService(Intent(context, ExternalSessionService::class.java))
fun setActivityVisible(context: Context, visible: Boolean) {
if (!SessionState.running) return
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action))
context.startService(Intent(context, currentServiceClass()).setAction(action))
}
fun resume(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
context.startService(Intent(context, currentServiceClass()).setAction(ACTION_RESUME))
}
fun suspendPolicyChanged(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SUSPEND_POLICY_CHANGED))
context.startService(Intent(context, currentServiceClass()).setAction(ACTION_SUSPEND_POLICY_CHANGED))
}
/** Let the session service clean up the PTY's proot tree if the control screen closes. */
fun stopTerminalProcess(context: Context, pid: Int) {
if (pid > 1) context.startService(
Intent(context, SessionService::class.java)
Intent(context, currentServiceClass())
.setAction(ACTION_STOP_AUXILIARY)
.putExtra(EXTRA_AUXILIARY_PID, pid),
)
@@ -1282,7 +1358,7 @@ class SessionService : Service() {
fun registerTerminalProcess(context: Context, pid: Int) {
if (pid > 1) context.startService(
Intent(context, SessionService::class.java)
Intent(context, currentServiceClass())
.setAction(ACTION_TRACK_AUXILIARY)
.putExtra(EXTRA_AUXILIARY_PID, pid),
)
@@ -1290,7 +1366,7 @@ class SessionService : Service() {
fun terminalProcessExited(context: Context, pid: Int) {
if (pid > 1) context.startService(
Intent(context, SessionService::class.java)
Intent(context, currentServiceClass())
.setAction(ACTION_AUXILIARY_EXITED)
.putExtra(EXTRA_AUXILIARY_PID, pid),
)
@@ -63,6 +63,8 @@ class ModeSettings(
val clientDirectAudio: Boolean = false,
val mic: Boolean?,
val renderer: String?,
val desktopEnvironment: String? = null,
val plasmaMobileInstalled: Boolean = false,
val gameStorage: String? = null,
val storageOptions: List<Pair<String, String>> = emptyList(),
val fexPreset: String? = null,
@@ -113,6 +115,7 @@ class ModeSettingsActions(
val onClientDirectAudio: (Boolean) -> Unit = {},
val onMic: (Boolean) -> Unit,
val onRenderer: (String) -> Unit,
val onDesktopEnvironment: (String) -> Unit = {},
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
val onPickGameStorageFolder: () -> Unit = {},
val onFexPreset: (String) -> Unit = {},
@@ -191,6 +194,19 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
)
}
if (s.desktopEnvironment != null) SettingsGroup("Desktop environment") {
ChoiceRow(
host, "desktop-environment", "Desktop", null,
SessionPrefs.desktopEnvironmentChoices, s.desktopEnvironment,
note = "KDE Plasma Mobile installs on first launch and can also run on a second screen.",
onPick = a.onDesktopEnvironment,
)
SettingsRow(
"KDE Plasma Mobile",
if (s.plasmaMobileInstalled) "Installed and ready."
else "Downloads packages from Arch Linux ARM the first time you open Desktop.",
) { }
}
SettingsGroup("Drivers") {
SettingsRow("GPU drivers", "Shared by Steam and the desktop, on the Components page.") {
ValueChip(s.gpuDrivers, open = false) { a.onGpuDrivers() }
@@ -114,6 +114,7 @@ class SecondScreenPresentation(
SecondScreenMode.TERMINAL -> renderTerminal()
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams())
SecondScreenMode.NONE -> Unit
SecondScreenMode.PLASMA_MOBILE -> Unit
}
}
@@ -166,6 +166,7 @@ class DrawerActions(
val backActionsInverted: Boolean,
val touchMode: String,
val touchAuto: String,
val directTouchOnly: Boolean,
val shapeMode: String,
val fexPreset: String,
/** Steam only: games stretched to the screen's size, changed live (null = not Steam). */
@@ -173,6 +174,7 @@ class DrawerActions(
val secondScreenMode: SecondScreenMode,
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
val plasmaMobileInstalled: Boolean = false,
val onHud: (Boolean) -> Unit,
val onFrameGenPick: (FrameGen.Mode) -> Unit,
val onImportLossless: () -> Unit,
@@ -358,7 +360,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
DRAWER_PAGE_COMPONENTS -> ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
1 -> {
SettingsGroup("Controls") {
ChoiceRow(host, "touch", "Touch", null,
if (a.directTouchOnly) {
SettingsRow("Touch", "Direct input to the screen") {
Text("Direct", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
} else ChoiceRow(host, "touch", "Touch", null,
listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
ChoiceRow(host, "osc", "On-screen controls", null,
@@ -380,10 +386,11 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
val secondScreenModes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()) +
(if (a.plasmaMobileInstalled) listOf(SecondScreenMode.PLASMA_MOBILE) else emptyList())
ChoiceRow(host, "second-screen-mode", "Shows", null,
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
.map { it to it.label },
secondScreenModes.map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
+3
View File
@@ -124,6 +124,7 @@ docker run --rm --platform linux/amd64 \
[ -f "$f" ] && install -Dm644 "$f" "$d/${f#tools/linuxfs/overlay/}"
done
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$d/usr/local/bin/droiddeck-desktop"
install -Dm644 tools/linuxfs/desktop/droiddeck-plasma-mobile "$d/usr/local/bin/droiddeck-plasma-mobile"
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$d/usr/local/bin/droiddeck-gpu"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$d/usr/local/bin/droiddeck-desktop-gpu"
install -Dm644 tools/linuxfs/desktop/autostart "$d/etc/xdg/labwc/autostart"
@@ -158,6 +159,8 @@ docker run --rm --platform linux/amd64 \
test -f "$d/usr/local/bin/bannerlator-proton-extra"
'
"${repo_root}/tools/plasma-mobile/build.sh"
github_repo=${DROIDDECK_GITHUB_REPOSITORY:-}
if [[ -z "${github_repo}" ]]; then
origin_url=$(git -C "${repo_root}" remote get-url origin)
@@ -0,0 +1,148 @@
#!/bin/bash
# Run Plasma Mobile inside a nested KWin Wayland session.
set -u
export XDG_SESSION_TYPE=wayland
export XDG_SESSION_DESKTOP=KDE
export XDG_CURRENT_DESKTOP=KDE
export QT_QPA_PLATFORM=wayland
export QT_QPA_PLATFORMTHEME=KDE
export QT_WAYLAND_DISABLE_WINDOWDECORATION=1
export EGL_PLATFORM=wayland
export QT_QUICK_CONTROLS_STYLE=org.kde.breeze
export QT_ENABLE_GLYPH_CACHE_WORKAROUND=1
export QT_QUICK_CONTROLS_MOBILE=1
export KSCREEN_BACKEND=QScreen
export KDE_FULL_SESSION=1
export KDE_SESSION_VERSION=6
export XDG_MENU_PREFIX=plasma-
export PLASMA_INTEGRATION_USE_PORTAL=1
export PLASMA_PLATFORM=phone:handset
export PLASMA_DEFAULT_SHELL=org.kde.plasma.mobileshell
export XDG_CONFIG_DIRS="$HOME/.config/plasma-mobile:/etc/xdg:${XDG_CONFIG_DIRS:-}"
export GDK_BACKEND=wayland,x11
export SDL_VIDEODRIVER=wayland,x11
if [ -n "${BL_DEBUG_DIR:-}" ]; then
mkdir -p "$BL_DEBUG_DIR" 2>/dev/null || true
exec >>"$BL_DEBUG_DIR/desktop.log" 2>&1
echo "== droiddeck-plasma-mobile $(date -u '+%Y-%m-%d %H:%M:%S UTC') ${BL_WIDTH:-1280}x${BL_HEIGHT:-720}"
fi
if ! command -v plasmashell >/dev/null 2>&1 || ! command -v dbus-run-session >/dev/null 2>&1; then
echo "== Plasma Mobile packages are missing; choose KDE Plasma Mobile in Desktop settings and start Desktop once"
exit 66
fi
plasma_log="${BL_DEBUG_DIR:-/tmp}/plasma.log"
echo "== Plasma $(plasmashell --version 2>&1)"
echo "== Wayland display ${WAYLAND_DISPLAY:-wayland-0}; XDG runtime $XDG_RUNTIME_DIR"
mkdir -p /tmp || exit 73
chmod 1777 /tmp || echo "== could not set standard temporary-directory permissions"
export TMPDIR=/tmp
echo "== temporary directories: $(ls -ld /tmp /dev/shm 2>&1)"
echo "== preparing the KDE Plasma Mobile Wayland session"
: >"$plasma_log"
# Gamescope's Zink override is for game clients. KWin owns the Plasma Mobile session and presents
# one nested Wayland window through Bannerlator's host compositor.
export QT_QUICK_BACKEND=software
unset MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER LIBGL_KOPPER_DRI2 ZINK_DESCRIPTORS mesa_glthread MESA_NO_ERROR
# The PRoot guest has no systemd user manager. Hide only its session-bus activation entry while
# KDE starts, and restore it on every exit.
systemd_dbus_service=/usr/share/dbus-1/services/org.freedesktop.systemd1.service
systemd_dbus_service_disabled="$systemd_dbus_service.droiddeck-disabled"
restore_systemd_dbus_service() {
if [ -e "$systemd_dbus_service_disabled" ] && [ ! -e "$systemd_dbus_service" ]; then
mv "$systemd_dbus_service_disabled" "$systemd_dbus_service" || echo "== could not restore systemd D-Bus service file"
fi
}
restore_systemd_dbus_service
if [ -f "$systemd_dbus_service" ]; then
mv "$systemd_dbus_service" "$systemd_dbus_service_disabled" || echo "== could not disable session-bus activation for systemd1"
fi
trap restore_systemd_dbus_service EXIT
dbus-run-session -- env XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" plasma_log="$plasma_log" sh -c '
export WAYLAND_DISPLAY=wayland-0
width=${BL_WIDTH:-1280}
height=${BL_HEIGHT:-720}
rm -f "$XDG_RUNTIME_DIR/wayland-1" "$XDG_RUNTIME_DIR/wayland-1.lock"
# D-Bus activation inherited the outer compositor socket. KDE services activated later must
# connect to the nested socket, while KWin itself continues to connect to the outer one.
if command -v dbus-update-activation-environment >/dev/null 2>&1; then
WAYLAND_DISPLAY=wayland-1 dbus-update-activation-environment WAYLAND_DISPLAY >>"$plasma_log" 2>&1
echo "== D-Bus activation Wayland display update status $?"
fi
if command -v plasma-mobile-envmanager >/dev/null 2>&1; then
echo "== applying Plasma Mobile device settings"
QT_QPA_PLATFORM=offscreen plasma-mobile-envmanager --apply-settings >>"$plasma_log" 2>&1
echo "== Plasma Mobile environment setup status $?"
else
echo "== plasma-mobile-envmanager is missing"
fi
# This guest has no interactive login from which the user can set a Linux password. The Plasma
# Mobile PIN keypad authenticates that account password, so an idle/lock event can strand a
# device user at a screen they cannot unlock. Keep session locking disabled for this embedded
# desktop and let Android own the device lock instead.
lock_config="${XDG_CONFIG_HOME:-$HOME/.config}/kscreenlockerrc"
mkdir -p "$(dirname "$lock_config")"
if command -v kwriteconfig6 >/dev/null 2>&1; then
kwriteconfig6 --file "$lock_config" --group Daemon --key Autolock false
kwriteconfig6 --file "$lock_config" --group Daemon --key LockOnResume false
kwriteconfig6 --file "$lock_config" --group Daemon --key RequirePassword false
kwriteconfig6 --file "$lock_config" --group Daemon --key Timeout 0
echo "== disabled Plasma screen locking in $lock_config"
else
echo "== kwriteconfig6 is missing; could not disable Plasma screen locking"
fi
if command -v kbuildsycoca6 >/dev/null 2>&1; then
echo "== building KDE service cache"
QT_QPA_PLATFORM=offscreen kbuildsycoca6 --noincremental >>"$plasma_log" 2>&1
cache_status=$?
echo "== KDE service cache status $cache_status"
cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}
for cache_file in "$cache_dir"/ksycoca6_*; do
[ -f "$cache_file" ] || continue
# KDE cache files land write-only under this PRoot user and cannot be reopened by service lookups.
chmod u+rw "$cache_file" || echo "== could not make KDE cache readable: $cache_file"
done
else
echo "== kbuildsycoca6 is missing"
fi
effect_config="${XDG_CONFIG_HOME:-$HOME/.config}/kwinrc"
if command -v kwriteconfig6 >/dev/null 2>&1; then
# The overview control in Plasma Mobile invokes this KWin effect. Keep it explicitly enabled
# so the patched state/gesture initialization is active in the embedded session.
kwriteconfig6 --file "$effect_config" --group Plugins --key mobiletaskswitcherEnabled true
fi
if command -v kreadconfig6 >/dev/null 2>&1; then
effect_setting=$(kreadconfig6 --file "$effect_config" \
--group Plugins --key mobiletaskswitcherEnabled 2>&1 || true)
echo "== mobile task-switcher setting in $effect_config: ${effect_setting:-unset}"
else
echo "== kreadconfig6 is missing; cannot inspect the mobile task-switcher setting"
fi
echo "== starting nested KWin and Plasma Mobile on wayland-0 (${width}x${height}), socket wayland-1"
# The mobile task switcher queries Workspace::activities() while filtering tasks.
# Keep KActivities initialized; --no-kactivities makes the overview crash on activation.
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 kwin_wayland \
--xwayland \
--wayland-display=wayland-0 \
--socket=wayland-1 \
--width="$width" --height="$height" --fullscreen \
--no-lockscreen \
--exit-with-session "plasmashell -p org.kde.plasma.mobileshell --replace" \
>>"$plasma_log" 2>&1 &
kwin_pid=$!
cleanup() {
if [ -n "$kwin_pid" ]; then kill "$kwin_pid" 2>/dev/null || true; wait "$kwin_pid" 2>/dev/null || true; fi
}
trap cleanup EXIT INT TERM
wait "$kwin_pid"
' >>"$plasma_log" 2>&1
result=$?
restore_systemd_dbus_service
trap - EXIT
echo "== Plasma Mobile session exited (status $result)"
exit "$result"
@@ -126,8 +126,17 @@ if [ -z "${BL_INSIDE:-}" ]; then
wayland-info >/dev/null 2>&1 && break
sleep 0.1
done
# The desktop (DroidDeck's hosted package) is its own nested compositor, labwc, drawing straight
# into the app's: no gamescope in between.
# LXQt uses its nested compositor; Plasma Mobile starts a nested KWin session. The selected
# primary desktop comes through BL_DESKTOP_ENV, while the independent secondary-display process
# starts directly in plasma-mobile mode.
if [ "$mode" = lxqt ] && [ "${BL_DESKTOP_ENV:-lxqt}" = plasma-mobile ]; then
mode=plasma-mobile
fi
if [ "$mode" = plasma-mobile ]; then
step "starting KDE Plasma Mobile"
[ -x /usr/local/bin/droiddeck-plasma-mobile ] || { echo "== Plasma Mobile launcher is missing"; exit 66; }
exec /usr/local/bin/droiddeck-plasma-mobile
fi
if [ "$mode" = lxqt ]; then
step "starting the desktop"
[ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; }
+13
View File
@@ -0,0 +1,13 @@
FROM menci/archlinuxarm:base-devel
# Pacman 7's Landlock sandbox cannot start in this Docker kernel. Package builds still run as the
# container user; disable only the unavailable sandbox paths for the dependency-install layer.
RUN sed -i \
's/^[[:space:]]*#DownloadUser.*/DownloadUser =/; s/^#DisableSandboxFilesystem/DisableSandboxFilesystem/; s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/' \
/etc/pacman.conf \
&& pacman -Syu --noconfirm --needed \
base-devel cmake extra-cmake-modules git ninja pkgconf \
plasma-mobile plasma-wayland-protocols vulkan-headers \
&& pacman -Scc --noconfirm
WORKDIR /src
+17
View File
@@ -0,0 +1,17 @@
# Plasma Mobile 6.7.5 runtime backports
The Thor root installs Plasma Mobile 6.7.5. The app build backports KDE's
[`Use effect caching` change](https://invent.kde.org/plasma/plasma-mobile/-/commit/73c5b4fd2815dc5d845c09cf578f60a9af3e708b)
to fix the mobile task switcher state, and removes Folio's requirement for a
shared KWayland client connection before it queries installed applications.
`tools/build_local.sh` stages the checked-in ARM64 modules under the generated
`app/src/main/assets/linuxfs` tree. To rebuild those modules, run
`tools/plasma-mobile/rebuild.sh`; it builds the affected targets in the Arch
Linux ARM environment described by `Dockerfile` and refreshes `prebuilt/`.
This keeps the changes isolated to the Plasma runtime; the LXQt/Steam root is
not modified. The module sources and patches target tag `v6.7.5`.
The app-catalog patch currently logs whether Folio receives a KWayland
connection and how many KService entries it finds. Those diagnostics are for
Thor dogfooding and can be removed after the empty-drawer cause is confirmed.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
plasma_version=6.7.5
source_dir="${TMPDIR:-/tmp}/plasma-mobile-${plasma_version}"
build_dir="${TMPDIR:-/tmp}/plasma-mobile-build-${plasma_version}"
asset_root="${repo_root}/app/src/main/assets/linuxfs"
installed_version=$(pacman -Q plasma-mobile | awk '{print $2}')
case "${installed_version}" in
"${plasma_version}"-*) ;;
*)
echo "Expected plasma-mobile ${plasma_version}, found ${installed_version}." >&2
exit 1
;;
esac
rm -rf -- "${source_dir}" "${build_dir}"
git clone --quiet --depth 1 --branch "v${plasma_version}" \
https://invent.kde.org/plasma/plasma-mobile.git "${source_dir}"
git -C "${source_dir}" apply --check \
"${repo_root}/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch" \
"${repo_root}/tools/plasma-mobile/patches/0002-folio-app-model.patch"
git -C "${source_dir}" apply \
"${repo_root}/tools/plasma-mobile/patches/0001-mobile-task-switcher.patch" \
"${repo_root}/tools/plasma-mobile/patches/0002-folio-app-model.patch"
cmake -S "${source_dir}" -B "${build_dir}" -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
cmake --build "${build_dir}" --parallel "${DROIDDECK_PLASMA_BUILD_JOBS:-4}" \
--target mobiletaskswitcherplugin org.kde.plasma.mobile.homescreen.folio
install -Dm644 "${build_dir}/bin/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" \
"${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so"
install -Dm644 "${build_dir}/kwin/mobiletaskswitcher/plugin/libmobiletaskswitcherplugin.so" \
"${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so"
install -Dm644 "${source_dir}/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml" \
"${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml"
install -Dm644 "${source_dir}/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml" \
"${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml"
strip --strip-unneeded \
"${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" \
"${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so"
echo "Built Plasma Mobile ${plasma_version} app-catalog and task-switcher backports."
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
source_dir="${repo_root}/tools/plasma-mobile/prebuilt"
asset_root="${repo_root}/app/src/main/assets/linuxfs"
install -Dm644 "${source_dir}/org.kde.plasma.mobile.homescreen.folio.so" \
"${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so"
install -Dm644 "${source_dir}/libmobiletaskswitcherplugin.so" \
"${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so"
install -Dm644 "${source_dir}/FlickContainer.qml" \
"${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml"
install -Dm644 "${source_dir}/TaskSwitcher.qml" \
"${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml"
echo "Staged the Plasma Mobile 6.7.5 ARM64 backports. Run tools/plasma-mobile/rebuild.sh to rebuild them."
@@ -0,0 +1,278 @@
diff --git a/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml b/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml
index 8f89960..8a80991 100644
--- a/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml
+++ b/kwin/mobiletaskswitcher/package/contents/ui/FlickContainer.qml
@@ -25,10 +25,16 @@ Flickable {
readonly property real startContentX: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount
property bool movingRight: false // TODO needed for flickable not flicking workaround
+ property bool resettingPosition: false
// update position from horizontal flickable movement
property real oldContentX
onContentXChanged: {
+ if (resettingPosition) {
+ oldContentX = contentX;
+ return;
+ }
+
// disable if animations are running to prevent bugs
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
@@ -43,6 +49,8 @@ Flickable {
onMovementStarted: {
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
+ oldContentX = contentX;
+ movingRight = false;
taskSwitcherHelpers.cancelAnimations();
}
onMovementEnded: {
@@ -63,6 +71,8 @@ Flickable {
onDraggingChanged: {
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
if (dragging) {
+ oldContentX = contentX;
+ movingRight = false;
taskSwitcherHelpers.cancelAnimations();
} else {
resetPosition();
@@ -70,7 +80,14 @@ Flickable {
}
function resetPosition(): void {
+ resettingPosition = true;
+ cancelFlick();
oldContentX = startContentX;
contentX = startContentX;
+ movingRight = false;
+ Qt.callLater(() => {
+ oldContentX = contentX;
+ resettingPosition = false;
+ });
}
}
diff --git a/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml b/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml
index e614639..ca3908e 100644
--- a/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml
+++ b/kwin/mobiletaskswitcher/package/contents/ui/TaskSwitcher.qml
@@ -85,11 +85,12 @@ FocusScope {
Keys.onEscapePressed: hide();
- Component.onCompleted: {
- initialSetup();
- }
-
function initialSetup(): void {
+ closeAnim.stop();
+ container.opacity = 1;
+ closeAllButton.closeRequested = false;
+ oldTasksCount = tasksCount;
+
taskSwitcherHelpers.cancelAnimations();
state.updateWasInActiveTask(KWinComponents.Workspace.activeWindow);
@@ -102,12 +103,14 @@ FocusScope {
taskSwitcherHelpers.hasVibrated = false;
taskSwitcherHelpers.closingFactor = 1;
+ taskSwitcherHelpers.closingScalingFactor = 1;
taskSwitcherHelpers.taskSwitchCanLaunch = false;
taskSwitchCanLaunchTimer.restart()
taskList.taskOffsetEasing = Easing.InOutQuart;
taskList.homeTouchPositionX = 0;
+ flickable.resetPosition();
backgroundColorOpacityAn.enabled = false;
backgroundColorOpacity = state.wasInActiveTask ? 1 : 0;
@@ -161,6 +164,11 @@ FocusScope {
Connections {
target: root.state
+ function onActivated(): void {
+ root.tasksModel.refresh();
+ root.initialSetup();
+ }
+
// task scrub mode allows scrubbing through a number of tasks with a mostly horizontal motion
function taskScrubMode(): void {
taskList.setTaskOffsetValue(0, false, Easing.OutQuart);
@@ -827,4 +835,3 @@ FocusScope {
}
}
}
-
diff --git a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp
index 5c2f376..e8baaff 100644
--- a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp
+++ b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.cpp
@@ -35,8 +35,11 @@ void MobileTaskSwitcherState::init(KWin::QuickSceneEffect *parent)
m_border = new EffectTouchBorder{m_effectState};
m_taskModel = new TaskModel{parent};
m_effect = parent;
+ m_effect->setViewCachingEnabled(true);
// Connect signals
+ connect(m_effect, &QuickSceneEffect::activated, this, &MobileTaskSwitcherState::activated);
+ connect(m_effect, &QuickSceneEffect::deactivated, this, &MobileTaskSwitcherState::deactivated);
connect(this, &MobileTaskSwitcherState::gestureEnabledChanged, this, &MobileTaskSwitcherState::refreshBorders);
connect(m_border, &EffectTouchBorder::touchPositionChanged, this, &MobileTaskSwitcherState::processTouchPositionChanged);
connect(this, &MobileTaskSwitcherState::gestureInProgressChanged, this, [this]() {
@@ -223,13 +226,10 @@ void MobileTaskSwitcherState::restartDoubleClickTimer()
void MobileTaskSwitcherState::calculateFilteredVelocity(qreal primaryDelta, qreal orthogonalDelta)
{
- static qreal prevPrimaryDelta = 0;
- static qreal prevOrthogonalDelta = 0;
-
qint64 frameTime = 0;
if (!m_frameTimer.isValid()) {
- prevPrimaryDelta = 0;
- prevOrthogonalDelta = 0;
+ m_previousPrimaryDelta = primaryDelta;
+ m_previousOrthogonalDelta = orthogonalDelta;
m_frameTimer.start();
return;
}
@@ -239,10 +239,10 @@ void MobileTaskSwitcherState::calculateFilteredVelocity(qreal primaryDelta, qrea
return;
}
- qreal framePrimaryDelta = primaryDelta - prevPrimaryDelta;
- qreal frameOrthogonalDelta = orthogonalDelta - prevOrthogonalDelta;
- prevPrimaryDelta = primaryDelta;
- prevOrthogonalDelta = orthogonalDelta;
+ qreal framePrimaryDelta = primaryDelta - m_previousPrimaryDelta;
+ qreal frameOrthogonalDelta = orthogonalDelta - m_previousOrthogonalDelta;
+ m_previousPrimaryDelta = primaryDelta;
+ m_previousOrthogonalDelta = orthogonalDelta;
// Implements an exponentially weighted moving average (EWMA) filter (= exponential smoothing)
// Smoothing factor is approximated each event to achieve a chosen filter time constant
@@ -253,6 +253,24 @@ void MobileTaskSwitcherState::calculateFilteredVelocity(qreal primaryDelta, qrea
Q_EMIT velocityChanged();
}
+void MobileTaskSwitcherState::clearVelocityFilter()
+{
+ m_frameTimer.invalidate();
+ m_previousPrimaryDelta = 0;
+ m_previousOrthogonalDelta = 0;
+ m_xVelocity = 0;
+ m_yVelocity = 0;
+ m_totalSquaredVelocity = 0;
+ Q_EMIT velocityChanged();
+}
+
+void MobileTaskSwitcherState::resetGestureState()
+{
+ m_touchXPosition = 0;
+ m_touchYPosition = 0;
+ clearVelocityFilter();
+}
+
void MobileTaskSwitcherState::processTouchPositionChanged(qreal primaryDelta, qreal orthogonalDelta)
{
calculateFilteredVelocity(primaryDelta, orthogonalDelta);
@@ -289,6 +307,7 @@ void MobileTaskSwitcherState::activate()
return;
}
+ resetGestureState();
m_effectState->setInProgress(false);
invokeEffect();
}
@@ -339,6 +358,8 @@ void MobileTaskSwitcherState::setDBusState(bool active)
void MobileTaskSwitcherState::invokeEffect()
{
+ m_shutdownTimer->stop();
+ resetGestureState();
setInitialTaskIndex(currentTaskIndex()); // TODO! this is only until the crashing bug is fixed and recency sorting is in
m_effect->setRunning(true);
setDBusState(true);
diff --git a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h
index d85f1cc..0aae2a7 100644
--- a/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h
+++ b/kwin/mobiletaskswitcher/plugin/mobiletaskswitchereffect.h
@@ -144,6 +144,7 @@ private Q_SLOTS:
private:
void invokeEffect();
+ void resetGestureState();
bool m_gestureEnabled{false};
EffectTouchBorderState *m_effectState{nullptr};
@@ -154,8 +155,8 @@ private:
Status m_status = Status::Inactive;
bool m_gestureInProgress = false;
- int m_currentTaskIndex;
- int m_initialTaskIndex;
+ int m_currentTaskIndex = 0;
+ int m_initialTaskIndex = 0;
void clearVelocityFilter();
void calculateFilteredVelocity(qreal primaryPosition, qreal orthogonalPosition);
@@ -163,22 +164,24 @@ private:
// velocities in (logical) pixels/msec
QElapsedTimer m_frameTimer;
+ qreal m_previousPrimaryDelta = 0;
+ qreal m_previousOrthogonalDelta = 0;
qreal m_flickVelocityThreshold = 0.5 * 0.5; // squared because total velocity is kept as a square
qreal m_filterTimeConstant = 0.03; // time constant of velocity filter
- qreal m_touchXPosition;
- qreal m_touchYPosition;
+ qreal m_touchXPosition = 0;
+ qreal m_touchYPosition = 0;
qreal m_xVelocity = 0;
qreal m_yVelocity = 0;
// Using the square of velocity for the total (2-axis) because we just need it
// for one threshold comparison and we skip having to calculate the square root
- qreal m_totalSquaredVelocity;
+ qreal m_totalSquaredVelocity = 0;
// Positions of the task switcher effect itself
qreal m_xPosition = 0;
qreal m_yPosition = 0;
- bool m_wasInActiveTask;
+ bool m_wasInActiveTask = false;
QElapsedTimer *m_doubleClickTimer;
qint64 getDoubleClickInterval() const
diff --git a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp
index 59e1f48..6e04c86 100644
--- a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp
+++ b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.cpp
@@ -43,6 +43,12 @@ void TaskFilterModel::setWindowModel(TaskModel *taskModel)
sort(0);
}
+void TaskFilterModel::refresh()
+{
+ invalidateFilter();
+ sort(0);
+}
+
QString TaskFilterModel::screenName() const
{
return m_output ? m_output->name() : QString();
diff --git a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h
index 87244cc..ce3688a 100644
--- a/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h
+++ b/kwin/mobiletaskswitcher/plugin/taskfiltermodel.h
@@ -30,6 +30,8 @@ public:
TaskModel *windowModel() const;
void setWindowModel(KWin::TaskModel *taskModel);
+ Q_INVOKABLE void refresh();
+
QString screenName() const;
void setScreenName(const QString &screenName);
@@ -0,0 +1,60 @@
diff --git a/containments/homescreens/folio/applicationlistmodel.cpp b/containments/homescreens/folio/applicationlistmodel.cpp
--- a/containments/homescreens/folio/applicationlistmodel.cpp
+++ b/containments/homescreens/folio/applicationlistmodel.cpp
@@ -35,9 +35,9 @@ ApplicationListModel::ApplicationListModel(HomeScreen *parent)
// initialize wayland window checking
KWayland::Client::ConnectionThread *connection = KWayland::Client::ConnectionThread::fromApplication(this);
- if (!connection) {
- return;
- }
-
+ qDebug() << "Folio app model Wayland client connection:" << (connection != nullptr);
+ // Listing and launching desktop entries does not need a Wayland client connection. In an
+ // embedded compositor session KWayland may not expose its shared connection to this model;
+ // still populate the catalog so the drawer is usable.
load();
}
@@ -93,6 +93,7 @@ void ApplicationListModel::load()
}
const KService::List currentApps = queryApplications();
+ qDebug() << "Folio application service query returned" << currentApps.size() << "entries";
QList<KService::Ptr> toInsert;
for (const KService::Ptr &service : currentApps) {
diff --git a/containments/homescreens/folio/qml/AppDrawer.qml b/containments/homescreens/folio/qml/AppDrawer.qml
index a389b20..5b1840a 100644
--- a/containments/homescreens/folio/qml/AppDrawer.qml
+++ b/containments/homescreens/folio/qml/AppDrawer.qml
@@ -87,7 +87,7 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
- opacity: 0 // we display with the opacity gradient below
+ opacity: 1 // Render app tiles directly with Qt's software Quick backend.
headerHeight: root.headerHeight
// Keyboard navigation
@@ -104,10 +104,5 @@ Item {
}
}
- // Opacity gradient at grid edges
- MobileShell.FlickableOpacityGradient {
- anchors.fill: appDrawerGrid
- flickable: appDrawerGrid
- }
}
}
diff --git a/containments/homescreens/folio/qml/AppDrawerGrid.qml b/containments/homescreens/folio/qml/AppDrawerGrid.qml
--- a/containments/homescreens/folio/qml/AppDrawerGrid.qml
+++ b/containments/homescreens/folio/qml/AppDrawerGrid.qml
@@ -29,6 +29,5 @@ MobileShell.GridView {
cacheBuffer: cellHeight * 20
reuseItems: true
- layer.enabled: true
keyNavigationEnabled: true
highlightMoveDuration: 0
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: 2021-2023 Devin Lin <[email protected]>
// SPDX-FileCopyrightText: 2025 Luis Büchi <[email protected]>
// SPDX-License-Identifier: LGPL-2.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
Flickable {
// TODO flickable is busted, it refuses to actually do any flicks with touch input, only works with mouse
// we work around this somewhat by snapping to the nearest task in which direction it was moving when letting go
// no matter how far away we are (and no matter how fast we go)
id: root
required property var taskSwitcherState
required property var taskSwitcherHelpers
required property int tasksCount
// we use flickable solely for capturing flicks, not positioning elements
// the horizontal distance we can swipe in one flick
contentWidth: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount
contentHeight: height
contentX: startContentX
readonly property real startContentX: (taskSwitcherHelpers.taskWidth + taskSwitcherHelpers.taskSpacing) * tasksCount
property bool movingRight: false // TODO needed for flickable not flicking workaround
property bool resettingPosition: false
// update position from horizontal flickable movement
property real oldContentX
onContentXChanged: {
if (resettingPosition) {
oldContentX = contentX;
return;
}
// disable if animations are running to prevent bugs
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
if (moving) {
// TODO whenever flicking actually works this should probably be swapped with
// a minimum velocity after which it should snap to the nearest task
taskSwitcherState.xPosition += contentX - oldContentX;
}
movingRight = contentX < oldContentX;
oldContentX = contentX;
}
onMovementStarted: {
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
oldContentX = contentX;
movingRight = false;
taskSwitcherHelpers.cancelAnimations();
}
onMovementEnded: {
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight);
resetPosition();
}
onFlickStarted: {
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
root.cancelFlick();
}
onFlickEnded: {
// taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight);
// resetPosition();
}
onDraggingChanged: {
if (taskSwitcherHelpers.currentlyBeingClosed) {return}
if (dragging) {
oldContentX = contentX;
movingRight = false;
taskSwitcherHelpers.cancelAnimations();
} else {
resetPosition();
}
}
function resetPosition(): void {
resettingPosition = true;
cancelFlick();
oldContentX = startContentX;
contentX = startContentX;
movingRight = false;
Qt.callLater(() => {
oldContentX = contentX;
resettingPosition = false;
});
}
}
@@ -0,0 +1,837 @@
// SPDX-FileCopyrightText: 2015 Marco Martin <[email protected]>
// SPDX-FileCopyrightText: 2021-2024 Devin Lin <[email protected]>
// SPDX-FileCopyrightText: 2024-2025 Luis Büchi <[email protected]>
// SPDX-License-Identifier: GPL-2.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components 3.0 as PlasmaComponents
import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.plasma.private.mobileshell.taskswitcherplugin as TaskSwitcherPlugin
import org.kde.kwin 3.0 as KWinComponents
import org.kde.kwin.private.effects 1.0
import org.kde.kitemmodels
/**
* Component that provides a task switcher.
*/
FocusScope {
id: root
focus: true
property TaskSwitcherPlugin.MobileTaskSwitcherState state
readonly property QtObject effect: KWinComponents.SceneView.effect
readonly property QtObject targetScreen: KWinComponents.SceneView.screen
readonly property real navBottomMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? 0 : MobileShell.Constants.navigationPanelThickness
readonly property real navRightMargin: MobileShell.Constants.navigationPanelOnSide(width, height) ? MobileShell.Constants.navigationPanelThickness : 0
readonly property real topMargin: ShellSettings.Settings.autoHidePanelsEnabled ? 0 : MobileShell.Constants.topPanelHeight
readonly property real bottomMargin: ShellSettings.Settings.autoHidePanelsEnabled ? 0 : navBottomMargin
readonly property real leftMargin: 0
readonly property real rightMargin: ShellSettings.Settings.autoHidePanelsEnabled ? 0 : navRightMargin
property var taskSwitcherHelpers: TaskSwitcherHelpers {
taskSwitcher: root
taskList: taskList
}
MobileShell.HapticsEffect {
id: haptics
}
property TaskSwitcherPlugin.TaskFilterModel tasksModel: TaskSwitcherPlugin.TaskFilterModel {
screenName: root.targetScreen.name
windowModel: root.state.taskModel
}
readonly property int tasksCount: taskList.count
// keep track of task list events
property int oldTasksCount: tasksCount
onTasksCountChanged: {
// we need to subtract 1 from the current index when the closed task index is smaller
// this is because this part of the list has been shifted down by 1 when the closed task was removed.
if (taskSwitcherHelpers.lastClosedTask < state.currentTaskIndex) {
state.currentTaskIndex -= 1;
// animated at the same speed as the task x position in the TaskList so that the task appears not to move from the perspective of the user.
taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex, Kirigami.Units.longDuration, Easing.InOutQuad);
taskSwitcherHelpers.lastClosedTask = -1;
}
if (tasksCount === 0 && oldTasksCount !== 0) {
hide();
} else if (tasksCount < oldTasksCount) {
if (state.currentTaskIndex < 0) {
// if the user is on the first task, and it is closed, scroll right
taskSwitcherHelpers.animateGoToTaskIndex(0, Kirigami.Units.longDuration);
} else if (state.currentTaskIndex >= tasksCount) {
// if the user is on the last task, and it is closed, scroll left
taskSwitcherHelpers.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration);
}
}
oldTasksCount = tasksCount;
}
Keys.onEscapePressed: hide();
function initialSetup(): void {
closeAnim.stop();
container.opacity = 1;
closeAllButton.closeRequested = false;
oldTasksCount = tasksCount;
taskSwitcherHelpers.cancelAnimations();
state.updateWasInActiveTask(KWinComponents.Workspace.activeWindow);
// ensure the task drawer is not opened and reset values to defaults
taskSwitcherHelpers.reachedHeightThreshold = false;
taskSwitcherHelpers.gestureState = TaskSwitcherHelpers.GestureStates.Undecided;
taskSwitcherHelpers.isInTaskScrubMode = false;
taskSwitcherHelpers.inLastFrame = false;
taskSwitcherHelpers.hasVibrated = false;
taskSwitcherHelpers.closingFactor = 1;
taskSwitcherHelpers.closingScalingFactor = 1;
taskSwitcherHelpers.taskSwitchCanLaunch = false;
taskSwitchCanLaunchTimer.restart()
taskList.taskOffsetEasing = Easing.InOutQuart;
taskList.homeTouchPositionX = 0;
flickable.resetPosition();
backgroundColorOpacityAn.enabled = false;
backgroundColorOpacity = state.wasInActiveTask ? 1 : 0;
backgroundColorOpacityAn.enabled = true;
// reset the offset to have the task drawer off screen
taskList.setTaskOffsetValue(state.wasInActiveTask ? taskSwitcherHelpers.taskOffsetValue : taskSwitcherHelpers.homeOffsetValue, true);
// task index from the last time using the switcher
state.initialTaskIndex = Math.min(state.currentTaskIndex, tasksCount - 1);
if (state.wasInActiveTask) {
// if we were in an active task instead set initial task index to the position of that task
state.initialTaskIndex = taskSwitcherHelpers.getTaskIndexFromWindow(KWinComponents.Workspace.activeWindow);
} else {
// reset the task index to the start if on home screen
state.initialTaskIndex = 0
}
state.currentTaskIndex = state.initialTaskIndex
taskSwitcherHelpers.currentDisplayTask = state.currentTaskIndex;
taskSwitcherHelpers.goToTaskIndex(state.initialTaskIndex);
taskList.minimizeAll();
// fully open the switcher (if this is a button press, not gesture)
if (!state.gestureInProgress) {
taskSwitcherHelpers.fromButton = true;
if (state.wasInActiveTask) {
taskList.setTaskOffsetValue(0, true);
} else {
taskList.setTaskOffsetValue(0);
}
backgroundColorOpacity = 1;
taskSwitcherHelpers.open();
}
}
// called by c++ plugin
function hideAnimation(): void {
closeAnim.restart();
}
function instantHide(): void {
state.deactivate(true);
}
function hide(): void {
state.deactivate(false);
}
Connections {
target: root.state
function onActivated(): void {
root.tasksModel.refresh();
root.initialSetup();
}
// task scrub mode allows scrubbing through a number of tasks with a mostly horizontal motion
function taskScrubMode(): void {
taskList.setTaskOffsetValue(0, false, Easing.OutQuart);
if (!root.taskSwitcherHelpers.isInTaskScrubMode) {
root.backgroundColorOpacity = 1;
root.taskSwitcherHelpers.cancelAnimations();
root.taskSwitcherHelpers.open();
if (!root.taskSwitcherHelpers.hasVibrated) {
// Haptic feedback when the task scrub mode engages
haptics.buttonVibrate();
root.taskSwitcherHelpers.hasVibrated = true;
}
}
// TODO this makes sense, but makes scrub mode feel a bit weird
// improve trigger distance logic for task scrub mode to fix
let newTaskIndex = Math.max(0, Math.min(root.tasksCount - 1, Math.floor(root.state.touchXPosition / root.taskSwitcherHelpers.taskScrubDistance) + root.state.initialTaskIndex - (root.state.wasInActiveTask ? 0 : 1)));
if (newTaskIndex != root.state.currentTaskIndex || !root.taskSwitcherHelpers.isInTaskScrubMode) {
root.taskSwitcherHelpers.animateGoToTaskIndex(newTaskIndex);
root.taskSwitcherHelpers.isInTaskScrubMode = true;
}
}
function onTouchPositionChanged(): void {
let unmodifiedYposition = Math.abs(root.state.touchYPosition)
if (root.taskSwitcherHelpers.isInTaskScrubMode || // once in scrub mode, let's not allow to go out, that can result in inconsistent UX
(Math.abs(root.state.xVelocity) > Math.abs(root.state.yVelocity) * 3 && // gesture needs to be almost completely horizontal
Math.abs(root.state.xVelocity) < 2.5 && // and not with a fast flick TODO! evaluate whether to keep this, it's kinda awkward
Math.abs(root.state.touchXPosition) > root.taskSwitcherHelpers.taskScrubDistance * 0.95 && // and have moved far enough sideways
unmodifiedYposition < Kirigami.Units.largeSpacing * 2 && // and be close to the screen edge
root.tasksCount > 0 && // and there needs to be more than none task open
!root.taskSwitcherHelpers.taskDrawerOpened // and the task drawer must not be open
)) {
taskScrubMode();
} else {
if (root.taskSwitcherHelpers.currentlyBeingClosed) {
// if the task switch is still open but playing the close animation
// setup some values and return to the initial setup so that the user can always navigate with no down time
root.state.wasInActiveTask = root.taskSwitcherHelpers.openAppAnim.running ? true : false
taskList.setTaskOffsetValue(root.state.wasInActiveTask ? root.taskSwitcherHelpers.taskOffsetValue : root.taskSwitcherHelpers.homeOffsetValue, true);
root.state.status = !root.state.wasInActiveTask ? (root.taskSwitcherHelpers.openAppAnim.closeAnim && !root.taskSwitcherHelpers.taskDrawerWillOpen ? TaskSwitcherPlugin.MobileTaskSwitcherState.Active : TaskSwitcherPlugin.MobileTaskSwitcherState.Inactive) : TaskSwitcherPlugin.MobileTaskSwitcherState.Inactive
root.initialSetup();
} else if (root.taskSwitcherHelpers.openAnim.running) {
root.taskSwitcherHelpers.cancelAnimations();
root.state.status = root.taskSwitcherHelpers.stateClass.Active;
}
root.state.yPosition = unmodifiedYposition + (root.taskSwitcherHelpers.taskDrawerOpened || !root.state.wasInActiveTask ? root.taskSwitcherHelpers.openedYPosition : 0);
let newXPosition = root.taskSwitcherHelpers.xPositionFromTaskIndex(root.state.initialTaskIndex);
if (root.taskSwitcherHelpers.notHomeScreenState && !root.taskSwitcherHelpers.currentlyBeingClosed) {
newXPosition = newXPosition - (root.state.touchXPosition / root.taskSwitcherHelpers.currentScale);
}
root.state.xPosition = newXPosition;
// allows the user to move the task drawer left and right when on the home screen
taskList.homeTouchPositionX = root.taskSwitcherHelpers.notHomeScreenState ? 0 : (root.state.touchXPosition * 0.35);
// dynamically update the task switcher state based off of the touch position and velocity
updateTaskSwitcherState()
}
}
function updateTaskSwitcherState(): void {
let unmodifiedYposition = Math.abs(root.state.touchYPosition)
// if the touch is above heightThreshold, set reachedHeightThreshold to true
if (unmodifiedYposition > root.taskSwitcherHelpers.heightThreshold) {
// set reachedHeightThreshold when above or below two separate points to helps prevent flickering when the task switcher moves in and out of view
root.taskSwitcherHelpers.reachedHeightThreshold = true;
root.backgroundColorOpacity = root.taskSwitcherHelpers.notHomeScreenState ? 0 : 1;
} else if (unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) {
root.backgroundColorOpacity = 1;
} else {
root.backgroundColorOpacity = root.taskSwitcherHelpers.notHomeScreenState ? 1 : 0;
}
if (root.state.totalSquaredVelocity > root.state.flickVelocityThreshold) {
// flick
// ratio between y and x velocity as threshold between vertical and horizontal flick
let xyVelocityRatio = 1.7; // with 1.7 swipes up to ~60° from horizontal are counted as horizontal
if (root.state.yVelocity > Math.abs(root.state.xVelocity) * xyVelocityRatio) {
// downwards flick
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Undecided);
if (unmodifiedYposition < root.taskSwitcherHelpers.undoYThreshold) {
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.notHomeScreenState ? 0 : root.taskSwitcherHelpers.homeOffsetValue);
}
} else if (-root.state.yVelocity > Math.abs(root.state.xVelocity) * xyVelocityRatio || (root.taskSwitcherHelpers.reachedHeightThreshold && root.taskSwitcherHelpers.notHomeScreenState)) {
// upwards flick or if the touch is above heightThreshold
if (root.taskSwitcherHelpers.notHomeScreenState) {
// if in app or task switcher, go home
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home);
if (root.taskSwitcherHelpers.reachedHeightThreshold) {
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.taskOffsetValue);
}
} else if (unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) {
// else, keep the task switcher in view
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher);
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.peekOffsetValue);
}
} else if (!root.taskSwitcherHelpers.reachedHeightThreshold && !root.taskSwitcherHelpers.isInTaskScrubMode) {
// sideways flick
if (root.taskSwitcherHelpers.notHomeScreenState) {
taskList.setTaskOffsetValue(0, unmodifiedYposition < root.taskSwitcherHelpers.openedYPosition ? true : false);
}
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.HorizontalSwipe);
}
} else {
if (unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) {
// if just moveing out of undoYThreshold, set the state to home
if (root.taskSwitcherHelpers.gestureState < TaskSwitcherHelpers.GestureStates.TaskSwitcher) {
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home)
}
// if the touch is above heightThreshold, it will return home
if (unmodifiedYposition > root.taskSwitcherHelpers.heightThreshold) {
root.taskSwitcherHelpers.hasVibrated = true;
if (root.taskSwitcherHelpers.notHomeScreenState) {
// move the task switcher out of view
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home);
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.taskOffsetValue);
} else {
// keep the task switcher in view when above heightThreshold and from home
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher);
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.peekOffsetValue);
}
// minus largeSpacing from the heightThreshold to help prevent flickering when the task switcher moves in and out of view
} else if ((unmodifiedYposition < root.taskSwitcherHelpers.heightThreshold - Kirigami.Units.largeSpacing) || root.taskSwitcherHelpers.reachedHeightThreshold == false) {
// set reachedHeightThreshold when above or below two separate points to helps prevent flickering when the task switcher moves in and out of view
root.taskSwitcherHelpers.reachedHeightThreshold = false;
if (root.state.totalSquaredVelocity < root.state.flickVelocityThreshold && root.taskSwitcherHelpers.taskSwitchCanLaunch) {
// if velocity is small enough, move the task switcher into view
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher);
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.notHomeScreenState ? 0 : root.taskSwitcherHelpers.peekOffsetValue);
}
}
} else {
// if under the undo threshold, it will go back to the task switcher if it is open
if (root.taskSwitcherHelpers.taskDrawerOpened) {
root.taskSwitcherHelpers.reachedHeightThreshold = false;
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher)
taskList.setTaskOffsetValue(0);
} else {
root.taskSwitcherHelpers.reachedHeightThreshold = false;
root.setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Undecided)
taskList.setTaskOffsetValue(root.taskSwitcherHelpers.notHomeScreenState ? 0 : root.taskSwitcherHelpers.homeOffsetValue);
}
}
}
}
// returns to the currently centered app. usually used to "back out" of the switcher
// if accidentally invoked, but can also be used to switch to an adjacent app and then open it
function returnToApp(): void {
let newIndex = root.taskSwitcherHelpers.getNearestTaskIndex();
root.taskSwitcherHelpers.openApp(newIndex);
}
// diagonal quick switch gesture logic
function quickSwitch(): void {
// should "quick switch" to adjacent app in task switcher, but only if we were in an app before
let unmodifiedYposition = Math.abs(root.state.touchYPosition)
let newIndex = root.state.currentTaskIndex;
let shouldSwitch = false;
if (root.state.xVelocity > 0) {
if (root.taskSwitcherHelpers.notHomeScreenState) {
// flick to the right, go to the app on the left
newIndex = root.state.currentTaskIndex + 1;
}
if (newIndex < root.tasksCount) {
// switch only if flick doesn't go over end of list
shouldSwitch = true;
}
} else if (root.state.xVelocity < 0) {
if (root.taskSwitcherHelpers.notHomeScreenState) {
// flick to the left, go to app to the right
newIndex = root.state.currentTaskIndex - 1;
if (newIndex >= 0) {
// switch only if flick doesn't go over end of list
shouldSwitch = true;
}
} else {
// flick to the left on the home screen, dismiss the gesture
root.taskSwitcherHelpers.close();
return;
}
}
if (shouldSwitch) {
if (!root.taskSwitcherHelpers.taskDrawerOpened && unmodifiedYposition < root.taskSwitcherHelpers.openedYPosition) {
// if in a app, switch it to the new task when it is under the openedYPosition
taskList.setTaskOffsetValue(0, unmodifiedYposition < root.taskSwitcherHelpers.openedYPosition && root.taskSwitcherHelpers.notHomeScreenState);
root.taskSwitcherHelpers.openApp(newIndex, Kirigami.Units.longDuration * 4, Easing.OutExpo);
} else {
// if already in the task switcher or above the openedYPosition, only change the focus to the new task
root.taskSwitcherHelpers.animateGoToTaskIndex(newIndex);
root.taskSwitcherHelpers.open();
}
} else {
// if not switching, just open task switcher
root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex);
root.taskSwitcherHelpers.open();
}
}
// Logic for deciding how to handle the end of a gesture input
function onGestureInProgressChanged(): void {
root.taskSwitcherHelpers.fromButton = false;
if (root.state.gestureInProgress) {
root.taskSwitcherHelpers.currentDisplayTask = root.state.currentTaskIndex;
return;
}
if (taskList.count === 0) {
// dismiss the gesture if the task list is empty
root.taskSwitcherHelpers.close();
} if (root.taskSwitcherHelpers.isInTaskScrubMode) {
// TODO! do we want to handle upwards flick to dismiss in task scrub mode?
let unmodifiedYposition = Math.abs(root.state.touchYPosition)
root.backgroundColorOpacity = 1;
if (root.taskSwitcherHelpers.taskDrawerOpened || unmodifiedYposition > root.taskSwitcherHelpers.undoYThreshold) {
root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex);
root.taskSwitcherHelpers.open();
root.taskSwitcherHelpers.isInTaskScrubMode = false;
} else {
root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex);
}
} else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.Undecided) {
if (root.taskSwitcherHelpers.taskDrawerOpened) {
// if in the task switcher, return to it
root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex);
root.taskSwitcherHelpers.open();
} else if (root.state.wasInActiveTask) {
// if inside a app, return to it
returnToApp();
} else {
// else dismiss the gesture
root.taskSwitcherHelpers.close();
}
} else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.HorizontalSwipe) {
// sideways flick
root.backgroundColorOpacity = 1;
quickSwitch();
} else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.TaskSwitcher) {
// open the task drawer
root.backgroundColorOpacity = 1;
root.taskSwitcherHelpers.animateGoToTaskIndex(root.state.currentTaskIndex);
root.taskSwitcherHelpers.open();
} else if (root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.Home) {
root.taskSwitcherHelpers.close();
}
}
function onVelocityChanged(): void {
}
function onXPositionChanged(): void {
root.taskSwitcherHelpers.updateTaskIndex();
}
}
// kind of a hack, but this prevents the gesture from immediately activating the task switcher when it is not supposed to
Timer {
id: taskSwitchCanLaunchTimer
interval: 1; running: true; repeat: false
onTriggered: root.taskSwitcherHelpers.taskSwitchCanLaunch = true;
}
function setTaskDrawerState(value: int): void {
if (taskSwitcherHelpers.gestureState != TaskSwitcherHelpers.GestureStates.TaskSwitcher && value == TaskSwitcherHelpers.GestureStates.TaskSwitcher) {
// vibrate only if switching to task drawer
if (!taskSwitcherHelpers.hasVibrated) {
// Haptic feedback when the task scrub mode engages
haptics.buttonVibrate();
taskSwitcherHelpers.hasVibrated = true;
}
}
taskSwitcherHelpers.gestureState = value;
}
// view of the desktop background
KWinComponents.DesktopBackground {
id: backgroundItem
activity: KWinComponents.Workspace.currentActivity
desktop: KWinComponents.Workspace.currentDesktop
outputName: root.targetScreen.name
}
// background colour
Rectangle {
id: backgroundRect
anchors.fill: root
opacity: container.opacity
color: {
return Qt.rgba(0, 0, 0, 0.6 * root.taskSwitcherHelpers.closingFactor * root.backgroundColorOpacity);
}
}
// animate the background opacity based off of the state.
property real backgroundColorOpacity: 1
Behavior on backgroundColorOpacity {
id: backgroundColorOpacityAn
NumberAnimation {
duration: Kirigami.Units.longDuration
}
}
// status bar
// TODO: improve load times, it is quite slow
// MobileShell.StatusBar {
// id: statusBar
// z: 1
// colorGroup: Kirigami.Theme.ComplementaryColorGroup
// backgroundColor: "transparent"
//
// height: root.topMargin
// anchors.top: parent.top
// anchors.left: parent.left
// anchors.right: parent.right
// }
// Gesture panel
Component {
id: gesturePanelComponent
MobileShell.GesturePanel {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
opaqueBar: false
// Trigger home action on tap
onHandleClicked: {
if (taskList.count === 0) {
root.hide();
} else {
if (taskList.count > 1 &&
root.state.elapsedTimeSinceStart != -1 &&
root.state.elapsedTimeSinceStart < root.state.doubleClickInterval) {
root.taskSwitcherHelpers.openApp(1);
return;
}
const currentIndex = root.state.currentTaskIndex;
root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex);
}
}
}
}
// Navigation panel
Component {
id: navigationPanelComponent
MobileShell.NavigationPanel {
id: navigationPanel
backgroundColor: Qt.rgba(0, 0, 0, 0.1)
foregroundColorGroup: Kirigami.Theme.Complementary
shadow: false
isVertical: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
MobileShellState.PanelSettingsDBusClient {
id: panelSettings
screenName: Screen.name
}
leftPadding: panelSettings.navigationPanelLeftPadding
rightPadding: panelSettings.navigationPanelRightPadding
leftAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-task-switcher"
shrinkSize: 4
onTriggered: {
if (taskList.count === 0) {
root.hide();
} else {
if (taskList.count > 1 &&
root.state.elapsedTimeSinceStart != -1 &&
root.state.elapsedTimeSinceStart < root.state.doubleClickInterval) {
root.taskSwitcherHelpers.openApp(1);
return;
}
const currentIndex = root.state.currentTaskIndex;
root.taskSwitcherHelpers.openApp(root.state.currentTaskIndex);
}
}
}
// home button
middleAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "start-here-kde"
onTriggered: root.hide()
}
// close app/keyboard button
rightAction: MobileShell.NavigationPanelAction {
enabled: true
iconSource: "mobile-close-app"
shrinkSize: 4
onTriggered: {
taskList.getTaskAt(root.state.currentTaskIndex).closeApp();
}
}
rightCornerAction: MobileShell.NavigationPanelAction {
visible: false
}
}
}
Loader {
id: panelLoader
z: !root.taskSwitcherHelpers.currentlyBeingClosed ? 1 : 0
sourceComponent: {
if (ShellSettings.Settings.navigationPanelEnabled) {
return navigationPanelComponent;
} else if (ShellSettings.Settings.gesturePanelEnabled) {
return gesturePanelComponent;
} else {
return null;
}
}
}
states: [
State {
name: "landscape"
when: MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges {
target: panelLoader
anchors {
right: root.right
top: root.top
bottom: root.bottom
left: undefined
}
}
PropertyChanges {
target: panelLoader
width: navRightMargin
anchors.topMargin: root.topMargin
}
},
State {
name: "portrait"
when: !MobileShell.Constants.navigationPanelOnSide(root.width, root.height)
AnchorChanges {
target: panelLoader
anchors {
top: undefined
right: root.right
left: root.left
bottom: root.bottom
}
}
PropertyChanges {
target: panelLoader
height: navBottomMargin
}
}
]
// task list
Item {
id: container
// provide shell margins
anchors.fill: root
anchors.leftMargin: root.leftMargin
anchors.rightMargin: root.rightMargin
anchors.bottomMargin: root.bottomMargin
anchors.topMargin: root.topMargin
NumberAnimation on opacity {
id: closeAnim
running: false
to: 0
duration: Kirigami.Units.longDuration
easing.type: Easing.InOutQuad
onFinished: {
closeAllButton.closeRequested = false;
}
}
// placeholder message
ColumnLayout {
id: placeholder
spacing: Kirigami.Units.gridUnit
opacity: {
let baseOpacity = ((root.tasksCount === 0 && !root.taskSwitcherHelpers.currentlyBeingClosed) ? 0.9 : 0);
return root.taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.TaskSwitcher ? baseOpacity : 0;
}
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.veryLongDuration } }
anchors.centerIn: container
Kirigami.Icon {
id: icon
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.large
implicitHeight: Kirigami.Units.iconSizes.large
source: "edit-none-symbolic"
color: "white"
}
Kirigami.Heading {
Layout.fillWidth: true
Layout.maximumWidth: root.width * 0.75
Layout.alignment: Qt.AlignHCenter
color: "white"
level: 3
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
text: i18n("No applications are running.")
}
}
RowLayout {
id: scrubIconList
opacity: root.taskSwitcherHelpers.isInTaskScrubMode ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration } }
anchors.bottom: container.bottom
anchors.right: container.horizontalCenter
anchors.bottomMargin: root.taskSwitcherHelpers.scrubModeBottomMargin
anchors.rightMargin: {
let size = Kirigami.Units.iconSizes.large + Kirigami.Units.largeSpacing * 2;
let offset = (root.state.currentTaskIndex + 0.5) * size;
return -offset;
}
Behavior on anchors.rightMargin {
NumberAnimation {
duration: root.taskSwitcherHelpers.xAnimDuration;
easing.type: root.taskSwitcherHelpers.xAnimEasingType;
}
}
spacing: Kirigami.Units.largeSpacing * 2
layoutDirection: Qt.RightToLeft
Repeater {
model: root.tasksModel
delegate: Kirigami.Icon {
id: iconDelegate
required property QtObject window
required property int index
readonly property bool isCenteredIcon: iconDelegate.index === root.state.currentTaskIndex;
Layout.preferredHeight: isCenteredIcon ? Kirigami.Units.iconSizes.huge : Kirigami.Units.iconSizes.large
Layout.preferredWidth: isCenteredIcon ? Kirigami.Units.iconSizes.huge : Kirigami.Units.iconSizes.large
Layout.alignment: Qt.AlignVCenter
source: iconDelegate.window.icon
}
}
}
RowLayout {
id: scrubIndicator
opacity: root.taskSwitcherHelpers.isInTaskScrubMode ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
anchors.bottom: container.bottom
anchors.horizontalCenter: container.horizontalCenter
anchors.bottomMargin: root.taskSwitcherHelpers.openedYPosition * 1 / 4
Kirigami.Icon {
id: iconScrubBack
opacity: root.state.currentTaskIndex == 0 ? 0.3 : 1
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration * 2; easing.type: Easing.OutExpo } }
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium
source: "draw-arrow-back"
color: "white"
}
Item {
width: root.taskSwitcherHelpers.windowWidth / 4
}
Kirigami.Icon {
id: iconScrubFront
opacity: root.state.currentTaskIndex == root.tasksCount - 1 ? 0.3 : 1
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration * 2; easing.type: Easing.OutExpo } }
Layout.alignment: Qt.AlignHCenter
implicitWidth: Kirigami.Units.iconSizes.medium
implicitHeight: Kirigami.Units.iconSizes.medium
source: "draw-arrow-forward"
color: "white"
}
}
// flicking area for task switcher
FlickContainer {
id: flickable
anchors.fill: container
taskSwitcherState: root.state
taskSwitcherHelpers: root.taskSwitcherHelpers
tasksCount: root.tasksCount
// don't allow FlickContainer to steal from swiping on tasks
interactive: taskList.taskInteractingCount === 0
// the item is effectively anchored to the flickable bounds
TaskList {
id: taskList
taskSwitcher: root
shellTopMargin: root.topMargin
shellBottomMargin: root.bottomMargin
x: flickable.contentX
width: flickable.width
height: flickable.height
}
PlasmaComponents.ToolButton {
id: closeAllButton
property bool closeRequested: false
visible: root.tasksCount !== 0 && !root.taskSwitcherHelpers.isInTaskScrubMode
enabled: !root.taskSwitcherHelpers.currentlyBeingClosed && !root.state.gestureInProgress
Kirigami.Theme.inherit: false
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
anchors {
bottom: taskList.bottom
bottomMargin: (taskList.taskYBase) * 0.75
horizontalCenter: taskList.horizontalCenter
}
opacity: (root.taskSwitcherHelpers.currentlyBeingClosed || root.state.gestureInProgress || !root.taskSwitcherHelpers.taskDrawerOpened) ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } }
icon.name: "edit-clear-history"
font.bold: true
text: closeRequested ? i18n("Confirm Close All") : i18n("Close All")
onClicked: {
if (closeRequested) {
taskList.closeAll();
} else {
closeRequested = true;
}
}
}
}
}
}
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
image_name=${DROIDDECK_PLASMA_BUILD_IMAGE:-droiddeck-plasma-mobile-builder:6.7.5}
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
docker build --platform linux/arm64 -t "${image_name}" \
-f "${repo_root}/tools/plasma-mobile/Dockerfile" "${repo_root}/tools/plasma-mobile"
fi
docker run --rm --platform linux/arm64 \
--user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "${repo_root}:/src" -w /src "${image_name}" \
bash /src/tools/plasma-mobile/build-in-container.sh
prebuilt="${repo_root}/tools/plasma-mobile/prebuilt"
asset_root="${repo_root}/app/src/main/assets/linuxfs"
cp "${asset_root}/usr/lib/qt6/plugins/plasma/applets/org.kde.plasma.mobile.homescreen.folio.so" \
"${prebuilt}/org.kde.plasma.mobile.homescreen.folio.so"
cp "${asset_root}/usr/lib/qt6/qml/org/kde/plasma/private/mobileshell/taskswitcherplugin/libmobiletaskswitcherplugin.so" \
"${prebuilt}/libmobiletaskswitcherplugin.so"
cp "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/FlickContainer.qml" \
"${prebuilt}/FlickContainer.qml"
cp "${asset_root}/usr/share/kwin/effects/mobiletaskswitcher/contents/ui/TaskSwitcher.qml" \
"${prebuilt}/TaskSwitcher.qml"
echo "Refreshed the Plasma Mobile backports from ${image_name##*:}."