mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec08e2134 | ||
|
|
a67c5cd3b2 | ||
|
|
5771cb89a2 | ||
|
|
b15bdd5739 | ||
|
|
4dfb9a5ae9 | ||
|
|
6c25bb1cd5 | ||
|
|
7d3fe09b01 | ||
|
|
f1d5dcd505 | ||
|
|
3f6fd9b40f | ||
|
|
70d3d1f6a4 |
No files matched your search
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+46
@@ -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."
|
||||
Executable
+17
@@ -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.
Binary file not shown.
Executable
+27
@@ -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##*:}."
|
||||
Reference in new issue
Block a user