mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-07 19:59:14 -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:
@@ -541,5 +541,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
blurCmd,
|
blurCmd,
|
||||||
trashCmd,
|
trashCmd,
|
||||||
systemCmd,
|
systemCmd,
|
||||||
|
switchUserCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var switchUserCmd = &cobra.Command{
|
||||||
|
Use: "switch-user [target]",
|
||||||
|
Short: "Switch to another active session on this seat",
|
||||||
|
Long: `Switch the active VT to another running session.
|
||||||
|
|
||||||
|
With no target, prints the list of switchable sessions. Pass a username or a
|
||||||
|
numeric session ID to switch directly. Requires the target to already be a
|
||||||
|
running session on the same seat (use the greeter for a fresh login).`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
Run: runSwitchUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionInfo struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Seat string
|
||||||
|
TTY string
|
||||||
|
Type string
|
||||||
|
Class string
|
||||||
|
Active bool
|
||||||
|
State string
|
||||||
|
Current bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSwitchUser(cmd *cobra.Command, args []string) {
|
||||||
|
currentID := os.Getenv("XDG_SESSION_ID")
|
||||||
|
sessions, err := listSessions(currentID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switchable := make([]sessionInfo, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Class != "user" || s.State == "closing" || s.Current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switchable = append(switchable, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
if len(switchable) == 0 {
|
||||||
|
fmt.Println("No other active sessions on this seat.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
printSessions(switchable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := args[0]
|
||||||
|
picked, err := pickSession(switchable, target)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
if len(switchable) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
|
||||||
|
printSessions(switchable)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := activateSession(picked.ID); err != nil {
|
||||||
|
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessions(currentID string) ([]sessionInfo, error) {
|
||||||
|
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, fields[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sessionInfo, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
s, err := showSession(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Current = currentID != "" && s.ID == currentID
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Name != out[j].Name {
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSession(id string) (sessionInfo, error) {
|
||||||
|
out, err := exec.Command("loginctl", "show-session", id,
|
||||||
|
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
|
||||||
|
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
|
||||||
|
if err != nil {
|
||||||
|
return sessionInfo{}, err
|
||||||
|
}
|
||||||
|
fields := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
idx := strings.IndexByte(line, '=')
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[line[:idx]] = line[idx+1:]
|
||||||
|
}
|
||||||
|
if fields["Id"] == "" {
|
||||||
|
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
|
||||||
|
}
|
||||||
|
return sessionInfo{
|
||||||
|
ID: fields["Id"],
|
||||||
|
Name: fields["Name"],
|
||||||
|
Seat: fields["Seat"],
|
||||||
|
TTY: fields["TTY"],
|
||||||
|
Type: fields["Type"],
|
||||||
|
Class: fields["Class"],
|
||||||
|
Active: fields["Active"] == "yes",
|
||||||
|
State: fields["State"],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.ID == target {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := make([]sessionInfo, 0, 2)
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.Name == target {
|
||||||
|
matches = append(matches, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
ids := make([]string, len(matches))
|
||||||
|
for i, m := range matches {
|
||||||
|
ids[i] = m.ID
|
||||||
|
}
|
||||||
|
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateSession(id string) error {
|
||||||
|
return exec.Command("loginctl", "activate", id).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSessions(sessions []sessionInfo) {
|
||||||
|
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
|
||||||
|
for _, s := range sessions {
|
||||||
|
tty := s.TTY
|
||||||
|
if tty == "" {
|
||||||
|
tty = "-"
|
||||||
|
}
|
||||||
|
seat := s.Seat
|
||||||
|
if seat == "" {
|
||||||
|
seat = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -212,6 +212,52 @@ dms ipc call lock lock
|
|||||||
dms ipc call lock isLocked
|
dms ipc call lock isLocked
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Target: `sessions`
|
||||||
|
|
||||||
|
Logind session enumeration and seat-local session switching. Wraps `loginctl list-sessions` and `loginctl activate`. Only switches between sessions that are *already running* on the current seat — creating a fresh login as another user requires a multi-session greeter setup (greetd-flexiserver / GDM / LightDM) and is out of scope.
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
**`list`**
|
||||||
|
- Print every session DMS knows about as tab-separated columns: `sessionId\tusername\tseat\ttty\ttype\tcurrent-marker`
|
||||||
|
- Returns: Multi-line string. The current session is marked with `*current*`.
|
||||||
|
|
||||||
|
**`refresh`**
|
||||||
|
- Re-enumerate sessions in the background (the picker also refreshes itself on open)
|
||||||
|
- Returns: `"ok"`
|
||||||
|
|
||||||
|
**`open`**
|
||||||
|
- Refresh and open the Switch User picker on the focused screen
|
||||||
|
- Returns: `"ok"`
|
||||||
|
|
||||||
|
**`activate <sessionId>`**
|
||||||
|
- Activate a session by its numeric logind ID (the `Id=` field from `loginctl show-session`). Performs a VT switch
|
||||||
|
- Parameters: `sessionId` - Numeric session ID
|
||||||
|
- Returns: `"ok"` on dispatch, `"ERROR: missing session id"` if blank
|
||||||
|
- Note: Failures from `loginctl activate` surface through the `switchFailed` QML signal and a Log warning — the IPC call returns success once the spawn is queued, not after activation completes
|
||||||
|
|
||||||
|
**`switchTo <target>`**
|
||||||
|
- Switch to another session by username *or* session ID. The first non-current session matching the username wins; if there's no match, the call fails through the same logging path as `activate`
|
||||||
|
- Parameters: `target` - Username (e.g. `testuser2`) or numeric session ID
|
||||||
|
- Returns: `"ok"` on dispatch, `"ERROR: missing target (username or session id)"` if blank
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```bash
|
||||||
|
# Inspect what's switchable
|
||||||
|
dms ipc call sessions list
|
||||||
|
|
||||||
|
# Open the picker (useful for a keybind)
|
||||||
|
dms ipc call sessions open
|
||||||
|
|
||||||
|
# Jump straight to another logged-in user without the picker
|
||||||
|
dms ipc call sessions switchTo testuser2
|
||||||
|
|
||||||
|
# Or by session ID, when the user has multiple sessions
|
||||||
|
dms ipc call sessions activate 4
|
||||||
|
```
|
||||||
|
|
||||||
|
The dedicated `dms switch-user [target]` CLI command wraps the same behavior with a friendlier error path (it prints the switchable list when no target matches).
|
||||||
|
|
||||||
## Target: `inhibit`
|
## Target: `inhibit`
|
||||||
|
|
||||||
Idle inhibitor control to prevent automatic sleep/lock.
|
Idle inhibitor control to prevent automatic sleep/lock.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import qs.Services
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
readonly property var log: Log.scoped("DMSShell")
|
readonly property var log: Log.scoped("DMSShell")
|
||||||
|
readonly property var _sessionsServiceRef: SessionsService
|
||||||
|
|
||||||
property bool osdSurfacesLoaded: true
|
property bool osdSurfacesLoaded: true
|
||||||
property int pendingOsdResumeReloads: 0
|
property int pendingOsdResumeReloads: 0
|
||||||
@@ -1146,12 +1147,30 @@ Item {
|
|||||||
lock.activate();
|
lock.activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSwitchUserRequested: {
|
||||||
|
switchUserModalLoader.active = true;
|
||||||
|
Qt.callLater(() => {
|
||||||
|
if (switchUserModalLoader.item)
|
||||||
|
switchUserModalLoader.item.showFromPowerMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
PopoutService.powerMenuModal = powerMenuModal;
|
PopoutService.powerMenuModal = powerMenuModal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LazyLoader {
|
||||||
|
id: switchUserModalLoader
|
||||||
|
|
||||||
|
active: false
|
||||||
|
|
||||||
|
SwitchUserModal {
|
||||||
|
id: switchUserModal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyLoader {
|
LazyLoader {
|
||||||
id: hyprKeybindsModalLoader
|
id: hyprKeybindsModalLoader
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ DankModal {
|
|||||||
executeAction(action);
|
executeAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signal switchUserRequested
|
||||||
|
|
||||||
function executeAction(action) {
|
function executeAction(action) {
|
||||||
if (action === "lock") {
|
if (action === "lock") {
|
||||||
close();
|
close();
|
||||||
@@ -92,6 +94,11 @@ DankModal {
|
|||||||
Quickshell.execDetached(["dms", "restart"]);
|
Quickshell.execDetached(["dms", "restart"]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "switchuser") {
|
||||||
|
close();
|
||||||
|
switchUserRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
root.powerActionRequested(action, "", "");
|
root.powerActionRequested(action, "", "");
|
||||||
}
|
}
|
||||||
@@ -216,6 +223,12 @@ DankModal {
|
|||||||
"label": I18n.tr("Restart DMS"),
|
"label": I18n.tr("Restart DMS"),
|
||||||
"key": "D"
|
"key": "D"
|
||||||
};
|
};
|
||||||
|
case "switchuser":
|
||||||
|
return {
|
||||||
|
"icon": "switch_account",
|
||||||
|
"label": I18n.tr("Switch User"),
|
||||||
|
"key": "U"
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
"icon": "help",
|
"icon": "help",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ Rectangle {
|
|||||||
|
|
||||||
signal closed
|
signal closed
|
||||||
|
|
||||||
|
signal switchUserRequested
|
||||||
|
|
||||||
function updateVisibleActions() {
|
function updateVisibleActions() {
|
||||||
const allActions = powerMenuActionsOverride !== undefined ? powerMenuActionsOverride : ((typeof SettingsData !== "undefined" && SettingsData.powerMenuActions) ? SettingsData.powerMenuActions : ["logout", "suspend", "hibernate", "reboot", "poweroff"]);
|
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;
|
const hibernateSupported = (typeof SessionService !== "undefined" && SessionService.hibernateSupported) || false;
|
||||||
@@ -128,6 +130,12 @@ Rectangle {
|
|||||||
"label": I18n.tr("Hibernate"),
|
"label": I18n.tr("Hibernate"),
|
||||||
"key": "H"
|
"key": "H"
|
||||||
};
|
};
|
||||||
|
case "switchuser":
|
||||||
|
return {
|
||||||
|
"icon": "switch_account",
|
||||||
|
"label": I18n.tr("Switch User"),
|
||||||
|
"key": "U"
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
"icon": "help",
|
"icon": "help",
|
||||||
@@ -183,6 +191,11 @@ Rectangle {
|
|||||||
function executeAction(action) {
|
function executeAction(action) {
|
||||||
if (!action)
|
if (!action)
|
||||||
return;
|
return;
|
||||||
|
if (action === "switchuser") {
|
||||||
|
hide();
|
||||||
|
switchUserRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof SessionService === "undefined")
|
if (typeof SessionService === "undefined")
|
||||||
return;
|
return;
|
||||||
hide();
|
hide();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Quickshell.Hyprland
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Services.Mpris
|
import Quickshell.Services.Mpris
|
||||||
import qs.Common
|
import qs.Common
|
||||||
|
import qs.Modals
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
@@ -1728,5 +1729,12 @@ Item {
|
|||||||
Qt.callLater(() => passwordField.forceActiveFocus());
|
Qt.callLater(() => passwordField.forceActiveFocus());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onSwitchUserRequested: {
|
||||||
|
switchUserPicker.showFromLockScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwitchUserModal {
|
||||||
|
id: switchUserPicker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,6 +455,11 @@ Item {
|
|||||||
label: I18n.tr("Show Restart DMS"),
|
label: I18n.tr("Show Restart DMS"),
|
||||||
desc: I18n.tr("Restart the DankMaterialShell")
|
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",
|
key: "hibernate",
|
||||||
label: I18n.tr("Show Hibernate"),
|
label: I18n.tr("Show Hibernate"),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user