1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-13 14:36:32 -04:00

feat(sessions): implement local user session switching functionality

- Core user is logged in tty1 while user two is in tty3, you can now seamlessly switch bewteen them
New IPC options:
- `dms ipc call sessions list`
- `dms switch-user [target]`
- New Powermenu switch users option
This commit is contained in:
purian23
2026-05-24 18:33:38 -04:00
parent 4d4af8f549
commit bb05cbb6c5
10 changed files with 800 additions and 0 deletions
+19
View File
@@ -30,6 +30,7 @@ import qs.Services
Item {
id: root
readonly property var log: Log.scoped("DMSShell")
readonly property var _sessionsServiceRef: SessionsService
property bool osdSurfacesLoaded: true
property int pendingOsdResumeReloads: 0
@@ -1146,12 +1147,30 @@ Item {
lock.activate();
}
onSwitchUserRequested: {
switchUserModalLoader.active = true;
Qt.callLater(() => {
if (switchUserModalLoader.item)
switchUserModalLoader.item.showFromPowerMenu();
});
}
Component.onCompleted: {
PopoutService.powerMenuModal = powerMenuModal;
}
}
}
LazyLoader {
id: switchUserModalLoader
active: false
SwitchUserModal {
id: switchUserModal
}
}
LazyLoader {
id: hyprKeybindsModalLoader
+13
View File
@@ -81,6 +81,8 @@ DankModal {
executeAction(action);
}
signal switchUserRequested
function executeAction(action) {
if (action === "lock") {
close();
@@ -92,6 +94,11 @@ DankModal {
Quickshell.execDetached(["dms", "restart"]);
return;
}
if (action === "switchuser") {
close();
switchUserRequested();
return;
}
close();
root.powerActionRequested(action, "", "");
}
@@ -216,6 +223,12 @@ DankModal {
"label": I18n.tr("Restart DMS"),
"key": "D"
};
case "switchuser":
return {
"icon": "switch_account",
"label": I18n.tr("Switch User"),
"key": "U"
};
default:
return {
"icon": "help",
+272
View File
@@ -0,0 +1,272 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
property bool lockOnSwitch: false
function showFromPowerMenu() {
root.lockOnSwitch = false;
SessionsService.refresh();
open();
}
function showFromLockScreen() {
root.lockOnSwitch = true;
SessionsService.refresh();
open();
}
function _formatTty(s) {
if (s.tty && s.tty.length > 0)
return s.tty;
if (s.seat && s.seat.length > 0)
return s.seat;
return I18n.tr("remote");
}
function _formatType(s) {
if (!s.type || s.type.length === 0)
return "";
switch (s.type) {
case "wayland":
return "Wayland";
case "x11":
return "X11";
case "tty":
return "TTY";
default:
return s.type.charAt(0).toUpperCase() + s.type.substring(1);
}
}
function _doSwitch(sessionId, username) {
if (root.lockOnSwitch && typeof SessionService !== "undefined" && SessionService.loginctlAvailable)
SessionService.lock();
SessionsService.activate(sessionId, null);
close();
}
layerNamespace: "dms:switch-user-modal"
shouldBeVisible: false
allowStacking: true
modalWidth: 420
modalHeight: contentLoader.item ? Math.min(540, contentLoader.item.implicitHeight + Theme.spacingM * 2) : 320
enableShadow: true
shouldHaveFocus: true
onBackgroundClicked: close()
Connections {
target: SessionsService
function onSwitchRequested() {
root.showFromPowerMenu();
}
}
content: Component {
Item {
anchors.fill: parent
implicitHeight: mainColumn.implicitHeight
Column {
id: mainColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.topMargin: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "switch_account"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Switch User")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
width: parent.width
text: I18n.tr("Select an active session to switch to. The current session stays running in the background.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
visible: SessionsService.otherSessions().length > 0
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: SessionsService.otherSessions().length > 0
Repeater {
model: SessionsService.otherSessions()
Rectangle {
id: sessionRow
required property var modelData
width: parent.width
height: 64
radius: Theme.cornerRadius
color: sessionMouse.containsMouse ? Theme.surfacePressed : Theme.surfaceContainerHighest
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "account_circle"
size: Theme.iconSize + 4
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - 4 - chevron.width - Theme.spacingM * 2
spacing: 2
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: sessionRow.modelData.username
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: {
const tty = root._formatTty(sessionRow.modelData);
const type = root._formatType(sessionRow.modelData);
const parts = [];
if (type)
parts.push(type);
parts.push(I18n.tr("session %1").arg(sessionRow.modelData.sessionId));
if (tty)
parts.push(tty);
return parts.join(" · ");
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
DankIcon {
id: chevron
name: "chevron_right"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: sessionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._doSwitch(sessionRow.modelData.sessionId, sessionRow.modelData.username)
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: SessionsService.otherSessions().length === 0
Rectangle {
width: parent.width
height: bodyCol.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "info"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.top: parent.top
anchors.topMargin: 2
}
Column {
id: bodyCol
width: parent.width - Theme.iconSize - Theme.spacingM
spacing: 4
StyledText {
text: I18n.tr("No other active sessions on this seat")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("To sign in as a different user, log out and pick the account from the greeter. Creating a fresh session in parallel needs a multi-session greeter (greetd-flexiserver / GDM / LightDM).")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
}
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
layoutDirection: Qt.RightToLeft
DankButton {
text: I18n.tr("Close")
backgroundColor: Theme.surfaceVariantAlpha
textColor: Theme.surfaceText
onClicked: root.close()
}
DankButton {
visible: SessionsService.otherSessions().length === 0 && !root.lockOnSwitch
text: I18n.tr("Log out")
iconName: "logout"
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: {
if (typeof SessionService !== "undefined")
SessionService.logout();
root.close();
}
}
}
Item {
width: 1
height: Theme.spacingS
}
}
}
}
}
+13
View File
@@ -36,6 +36,8 @@ Rectangle {
signal closed
signal switchUserRequested
function updateVisibleActions() {
const allActions = powerMenuActionsOverride !== undefined ? powerMenuActionsOverride : ((typeof SettingsData !== "undefined" && SettingsData.powerMenuActions) ? SettingsData.powerMenuActions : ["logout", "suspend", "hibernate", "reboot", "poweroff"]);
const hibernateSupported = (typeof SessionService !== "undefined" && SessionService.hibernateSupported) || false;
@@ -128,6 +130,12 @@ Rectangle {
"label": I18n.tr("Hibernate"),
"key": "H"
};
case "switchuser":
return {
"icon": "switch_account",
"label": I18n.tr("Switch User"),
"key": "U"
};
default:
return {
"icon": "help",
@@ -183,6 +191,11 @@ Rectangle {
function executeAction(action) {
if (!action)
return;
if (action === "switchuser") {
hide();
switchUserRequested();
return;
}
if (typeof SessionService === "undefined")
return;
hide();
@@ -9,6 +9,7 @@ import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Services.Mpris
import qs.Common
import qs.Modals
import qs.Services
import qs.Widgets
@@ -1728,5 +1729,12 @@ Item {
Qt.callLater(() => passwordField.forceActiveFocus());
}
}
onSwitchUserRequested: {
switchUserPicker.showFromLockScreen();
}
}
SwitchUserModal {
id: switchUserPicker
}
}
@@ -455,6 +455,11 @@ Item {
label: I18n.tr("Show Restart DMS"),
desc: I18n.tr("Restart the DankMaterialShell")
},
{
key: "switchuser",
label: I18n.tr("Show Switch User"),
desc: I18n.tr("Opens a picker of other active sessions on this seat")
},
{
key: "hibernate",
label: I18n.tr("Show Hibernate"),
+236
View File
@@ -0,0 +1,236 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
readonly property var log: Log.scoped("SessionsService")
property var sessions: []
property string currentSessionId: ""
property string currentSeat: ""
property bool refreshing: false
signal switchFailed(string sessionId, string username, string message)
signal switchRequested
function isCurrent(sessionId) {
return sessionId === currentSessionId;
}
function findByUsername(username) {
for (let i = 0; i < sessions.length; i++) {
const s = sessions[i];
if (s.username === username && !s.current)
return s;
}
return null;
}
function findById(sessionId) {
for (let i = 0; i < sessions.length; i++) {
if (sessions[i].sessionId === sessionId)
return sessions[i];
}
return null;
}
function otherSessions() {
return sessions.filter(s => !s.current);
}
function refresh() {
if (refreshing)
return;
refreshing = true;
Proc.runCommand("sessionsService-current", ["sh", "-c", "echo \"${XDG_SESSION_ID}:$(loginctl show-session \"${XDG_SESSION_ID}\" -p Seat --value 2>/dev/null)\""], (output, exitCode) => {
const trimmed = (output || "").trim();
const parts = trimmed.split(":");
root.currentSessionId = parts[0] || "";
root.currentSeat = parts[1] || "";
_loadSessions();
}, 0);
}
function _loadSessions() {
const script = "loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}' | while read id; do loginctl show-session \"$id\" -p Id -p User -p Name -p Seat -p TTY -p Type -p Class -p Active -p State -p Remote 2>/dev/null | tr '\\n' '|'; echo; done";
Proc.runCommand("sessionsService-list", ["sh", "-c", script], (output, exitCode) => {
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
const list = [];
for (let i = 0; i < lines.length; i++) {
const fields = {};
const pairs = lines[i].split("|");
for (let j = 0; j < pairs.length; j++) {
const eq = pairs[j].indexOf("=");
if (eq <= 0)
continue;
fields[pairs[j].substring(0, eq)] = pairs[j].substring(eq + 1);
}
if (!fields.Id)
continue;
if (fields.Class !== "user")
continue;
if (fields.State === "closing")
continue;
const sessionId = fields.Id;
list.push({
sessionId: sessionId,
uid: parseInt(fields.User || "0", 10),
username: fields.Name || "",
seat: fields.Seat || "",
tty: fields.TTY || "",
type: fields.Type || "",
sessionClass: fields.Class || "",
active: fields.Active === "yes",
state: fields.State || "",
remote: fields.Remote === "yes",
current: sessionId === root.currentSessionId
});
}
list.sort((a, b) => {
if (a.current !== b.current)
return a.current ? -1 : 1;
if (a.username !== b.username)
return a.username.localeCompare(b.username);
return parseInt(a.sessionId, 10) - parseInt(b.sessionId, 10);
});
root.sessions = list;
root.refreshing = false;
}, 0);
}
function activate(sessionId, callback) {
if (!sessionId) {
_fail("", "", I18n.tr("No session selected"), callback);
return;
}
if (sessionId === root.currentSessionId) {
_fail(sessionId, "", I18n.tr("Already on that session"), callback);
return;
}
const session = findById(sessionId);
const username = session ? session.username : "";
_spawnActivate(sessionId, username, callback);
}
function switchToUser(target, callback) {
if (!target) {
_fail("", "", I18n.tr("No user specified"), callback);
return;
}
let session = findById(target);
if (!session)
session = findByUsername(target);
if (!session) {
_fail("", target, I18n.tr("No active session found for %1").arg(target), callback);
return;
}
if (session.current) {
_fail(session.sessionId, session.username, I18n.tr("Already on that session"), callback);
return;
}
_spawnActivate(session.sessionId, session.username, callback);
}
function _fail(sessionId, username, message, callback) {
log.warn("switch failed:", sessionId, username, message);
root.switchFailed(sessionId, username, message);
if (typeof callback === "function") {
try {
callback(false, message);
} catch (e) {
log.warn("SessionsService callback error:", e);
}
}
}
Component {
id: activateComp
Process {
id: activateProc
property string targetSession: ""
property string targetUsername: ""
property var cb: null
property string capturedErr: ""
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: activateProc.capturedErr = text || ""
}
onExited: exitCode => {
const svc = root;
const sessionId = activateProc.targetSession;
const username = activateProc.targetUsername;
const cb = activateProc.cb;
const err = (activateProc.capturedErr || "").trim();
Qt.callLater(() => activateProc.destroy());
if (exitCode !== 0) {
svc._fail(sessionId, username, err || I18n.tr("loginctl activate failed (exit %1)").arg(exitCode), cb);
return;
}
if (typeof cb === "function") {
try {
cb(true, "");
} catch (e) {
svc.log.warn("activate cb error:", e);
}
}
}
}
}
function _spawnActivate(sessionId, username, callback) {
const proc = activateComp.createObject(root, {
command: ["loginctl", "activate", sessionId],
targetSession: sessionId,
targetUsername: username,
cb: callback
});
proc.running = true;
}
IpcHandler {
target: "sessions"
function list(): string {
const lines = [];
for (let i = 0; i < root.sessions.length; i++) {
const s = root.sessions[i];
lines.push([s.sessionId, s.username, s.seat || "-", s.tty || "-", s.type || "-", s.current ? "*current*" : ""].join("\t"));
}
return lines.join("\n");
}
function refresh(): string {
root.refresh();
return "ok";
}
function open(): string {
root.refresh();
root.switchRequested();
return "ok";
}
function activate(sessionId: string): string {
if (!sessionId)
return "ERROR: missing session id";
root.activate(sessionId, null);
return "ok";
}
function switchTo(target: string): string {
if (!target)
return "ERROR: missing target (username or session id)";
root.switchToUser(target, null);
return "ok";
}
}
Component.onCompleted: refresh()
}