mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-22 19:15:24 -04:00
Compare commits
3 Commits
0b55fbcb15
...
6093c37b41
| Author | SHA1 | Date | |
|---|---|---|---|
| 6093c37b41 | |||
| bb05cbb6c5 | |||
| 4d4af8f549 |
@@ -541,5 +541,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
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
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
Idle inhibitor control to prevent automatic sleep/lock.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -555,5 +555,20 @@ FocusScope {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: usersLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 35
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: UsersTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@ Rectangle {
|
||||
"tabIndex": 20,
|
||||
"updaterOnly": true
|
||||
},
|
||||
{
|
||||
"id": "users",
|
||||
"text": I18n.tr("Users"),
|
||||
"icon": "manage_accounts",
|
||||
"tabIndex": 35
|
||||
},
|
||||
{
|
||||
"id": "window_rules",
|
||||
"text": I18n.tr("Window Rules"),
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +597,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Auto-hide")
|
||||
description: I18n.tr("Automatically hide the bar when the pointer moves away")
|
||||
checked: selectedBarConfig?.autoHide ?? false
|
||||
onToggled: toggled => {
|
||||
SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -623,6 +624,7 @@ Item {
|
||||
id: hideDelaySlider
|
||||
width: parent.width - parent.parent.leftPadding
|
||||
text: I18n.tr("Hide Delay")
|
||||
description: I18n.tr("Time to wait before hiding after the pointer leaves")
|
||||
value: selectedBarConfig?.autoHideDelay ?? 250
|
||||
minimum: 0
|
||||
maximum: 2000
|
||||
@@ -645,6 +647,7 @@ Item {
|
||||
SettingsToggleRow {
|
||||
width: parent.width - parent.leftPadding
|
||||
text: I18n.tr("Strict auto-hide", "Dank bar setting: hide the bar when the pointer leaves even if a menu or bar popover is still open")
|
||||
description: I18n.tr("Hide the bar when the pointer leaves even if a popout is still open")
|
||||
checked: selectedBarConfig?.autoHideStrict ?? false
|
||||
onToggled: toggled => {
|
||||
SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -658,6 +661,7 @@ Item {
|
||||
width: parent.width - parent.leftPadding
|
||||
visible: CompositorService.isNiri || CompositorService.isHyprland
|
||||
text: I18n.tr("Hide When Windows Open")
|
||||
description: I18n.tr("Show the bar only when no windows are open")
|
||||
checked: selectedBarConfig?.showOnWindowsOpen ?? false
|
||||
onToggled: toggled => {
|
||||
SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -676,6 +680,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Manual Show/Hide")
|
||||
description: I18n.tr("Toggle bar visibility manually via IPC")
|
||||
checked: selectedBarConfig?.visible ?? true
|
||||
onToggled: toggled => {
|
||||
SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -694,6 +699,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Click Through")
|
||||
description: I18n.tr("Mouse clicks pass through the bar to windows behind it")
|
||||
checked: selectedBarConfig?.clickThrough ?? false
|
||||
onToggled: toggled => SettingsData.updateBarConfig(selectedBarId, {
|
||||
clickThrough: toggled
|
||||
@@ -713,6 +719,7 @@ Item {
|
||||
enabled: !SettingsData.frameEnabled
|
||||
opacity: SettingsData.frameEnabled ? 0.5 : 1.0
|
||||
text: I18n.tr("Show on Overview")
|
||||
description: I18n.tr("Show the bar when niri overview is active")
|
||||
checked: selectedBarConfig?.openOnOverview ?? false
|
||||
onToggled: toggled => {
|
||||
SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -759,6 +766,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: edgeSpacingSlider
|
||||
text: I18n.tr("Edge Spacing")
|
||||
description: I18n.tr("Space between the bar and screen edges")
|
||||
value: selectedBarConfig?.spacing ?? 4
|
||||
minimum: 0
|
||||
maximum: 32
|
||||
@@ -780,6 +788,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: exclusiveZoneSlider
|
||||
text: I18n.tr("Exclusive Zone Offset")
|
||||
description: I18n.tr("Fine-tune the space reserved for the bar from the screen edge")
|
||||
value: selectedBarConfig?.bottomGap ?? 0
|
||||
minimum: -50
|
||||
maximum: 50
|
||||
@@ -801,6 +810,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: sizeSlider
|
||||
text: I18n.tr("Size")
|
||||
description: I18n.tr("Adjust the bar height via inner padding")
|
||||
value: selectedBarConfig?.innerPadding ?? 4
|
||||
minimum: -8
|
||||
maximum: 24
|
||||
@@ -822,6 +832,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: widgetPaddingSlider
|
||||
text: I18n.tr("Padding")
|
||||
description: I18n.tr("Inner padding applied to each widget")
|
||||
value: selectedBarConfig?.widgetPadding ?? 8
|
||||
minimum: 0
|
||||
maximum: 32
|
||||
@@ -852,6 +863,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Auto Popup Gaps")
|
||||
description: I18n.tr("Automatically calculate popup gap based on bar spacing")
|
||||
checked: selectedBarConfig?.popupGapsAuto ?? true
|
||||
onToggled: checked => {
|
||||
SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -877,6 +889,7 @@ Item {
|
||||
id: popupGapsManualSlider
|
||||
width: parent.width - parent.parent.leftPadding
|
||||
text: I18n.tr("Manual Gap Size")
|
||||
description: I18n.tr("Override the popup gap size when auto is disabled")
|
||||
value: selectedBarConfig?.popupGapsManual ?? 4
|
||||
minimum: 0
|
||||
maximum: 50
|
||||
@@ -907,6 +920,7 @@ Item {
|
||||
id: barTransparencySlider
|
||||
visible: !SettingsData.frameEnabled
|
||||
text: I18n.tr("Bar Transparency")
|
||||
description: I18n.tr("Opacity of the bar background")
|
||||
value: (selectedBarConfig?.transparency ?? 1.0) * 100
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
@@ -929,6 +943,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: widgetTransparencySlider
|
||||
text: I18n.tr("Widget Transparency")
|
||||
description: I18n.tr("Opacity of widget backgrounds")
|
||||
value: (selectedBarConfig?.widgetTransparency ?? 1.0) * 100
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
@@ -1023,6 +1038,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Square Corners")
|
||||
description: I18n.tr("Remove corner rounding from the bar")
|
||||
visible: !SettingsData.frameEnabled
|
||||
checked: selectedBarConfig?.squareCorners ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -1032,6 +1048,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("No Background")
|
||||
description: I18n.tr("Make the bar background fully transparent")
|
||||
visible: !SettingsData.frameEnabled
|
||||
checked: selectedBarConfig?.noBackground ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -1041,6 +1058,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Maximize Widget Icons")
|
||||
description: I18n.tr("Stretch widget icons to fill the available bar height")
|
||||
checked: selectedBarConfig?.maximizeWidgetIcons ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
maximizeWidgetIcons: checked
|
||||
@@ -1049,6 +1067,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Maximize Widget Text")
|
||||
description: I18n.tr("Stretch widget text to fill the available bar height")
|
||||
checked: selectedBarConfig?.maximizeWidgetText ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
maximizeWidgetText: checked
|
||||
@@ -1057,6 +1076,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Remove Widget Padding")
|
||||
description: I18n.tr("Remove inner padding from all widgets")
|
||||
checked: selectedBarConfig?.removeWidgetPadding ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
removeWidgetPadding: checked
|
||||
@@ -1072,6 +1092,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Goth Corners")
|
||||
description: I18n.tr("Apply inverse concave corner cutouts to the bar")
|
||||
visible: !SettingsData.frameEnabled
|
||||
checked: selectedBarConfig?.gothCornersEnabled ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -1081,6 +1102,7 @@ Item {
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Corner Radius Override")
|
||||
description: I18n.tr("Use a custom radius for goth corner cutouts")
|
||||
checked: selectedBarConfig?.gothCornerRadiusOverride ?? false
|
||||
visible: selectedBarConfig?.gothCornersEnabled ?? false
|
||||
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
|
||||
@@ -1239,6 +1261,7 @@ Item {
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
text: I18n.tr("Color")
|
||||
description: I18n.tr("Theme color used for the border")
|
||||
model: ["Surface", "Secondary", "Primary"]
|
||||
currentIndex: {
|
||||
switch (selectedBarConfig?.borderColor || "surfaceText") {
|
||||
@@ -1276,6 +1299,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: borderOpacitySlider
|
||||
text: I18n.tr("Opacity")
|
||||
description: I18n.tr("Transparency of the border")
|
||||
value: (selectedBarConfig?.borderOpacity ?? 1.0) * 100
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
@@ -1298,6 +1322,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: borderThicknessSlider
|
||||
text: I18n.tr("Thickness")
|
||||
description: I18n.tr("Width of the border in pixels")
|
||||
value: selectedBarConfig?.borderThickness ?? 1
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
@@ -1329,6 +1354,7 @@ Item {
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
text: I18n.tr("Color")
|
||||
description: I18n.tr("Theme color used for the widget outline")
|
||||
model: ["Surface", "Secondary", "Primary"]
|
||||
currentIndex: {
|
||||
switch (selectedBarConfig?.widgetOutlineColor || "primary") {
|
||||
@@ -1366,6 +1392,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: widgetOutlineOpacitySlider
|
||||
text: I18n.tr("Opacity")
|
||||
description: I18n.tr("Transparency of the widget outline")
|
||||
value: (selectedBarConfig?.widgetOutlineOpacity ?? 1.0) * 100
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
@@ -1388,6 +1415,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
id: widgetOutlineThicknessSlider
|
||||
text: I18n.tr("Thickness")
|
||||
description: I18n.tr("Width of the widget outline in pixels")
|
||||
value: selectedBarConfig?.widgetOutlineThickness ?? 1
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
@@ -1458,6 +1486,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
visible: shadowCard.shadowActive
|
||||
text: I18n.tr("Intensity", "shadow intensity slider")
|
||||
description: I18n.tr("Shadow blur radius in pixels")
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
unit: "px"
|
||||
@@ -1471,6 +1500,7 @@ Item {
|
||||
SettingsSliderRow {
|
||||
visible: shadowCard.shadowActive
|
||||
text: I18n.tr("Opacity")
|
||||
description: I18n.tr("Transparency of the shadow layer")
|
||||
minimum: 10
|
||||
maximum: 100
|
||||
unit: "%"
|
||||
@@ -1658,6 +1688,7 @@ Item {
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
text: I18n.tr("Y Axis")
|
||||
description: I18n.tr("Action performed when scrolling vertically on the bar")
|
||||
model: CompositorService.isNiri ? [I18n.tr("None"), I18n.tr("Workspace"), I18n.tr("Column")] : [I18n.tr("None"), I18n.tr("Workspace")]
|
||||
currentIndex: {
|
||||
switch (selectedBarConfig?.scrollYBehavior || "workspace") {
|
||||
@@ -1694,6 +1725,7 @@ Item {
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
text: I18n.tr("X Axis")
|
||||
description: I18n.tr("Action performed when scrolling horizontally on the bar")
|
||||
visible: CompositorService.isNiri
|
||||
model: [I18n.tr("None"), I18n.tr("Workspace"), I18n.tr("Column")]
|
||||
currentIndex: {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string statusText: ""
|
||||
property bool statusIsError: false
|
||||
property bool operationPending: false
|
||||
property string pendingUsername: ""
|
||||
property string pendingPassword: ""
|
||||
property string pendingConfirm: ""
|
||||
property bool pendingAdmin: false
|
||||
|
||||
function _resetForm() {
|
||||
pendingUsername = "";
|
||||
pendingPassword = "";
|
||||
pendingConfirm = "";
|
||||
pendingAdmin = false;
|
||||
usernameField.text = "";
|
||||
passwordField.text = "";
|
||||
confirmField.text = "";
|
||||
}
|
||||
|
||||
function _passwordsMatch() {
|
||||
return pendingPassword.length > 0 && pendingPassword === pendingConfirm;
|
||||
}
|
||||
|
||||
function _createCanProceed() {
|
||||
return !operationPending && UsersService.isValidUsername(pendingUsername) && !UsersService.userExists(pendingUsername) && _passwordsMatch();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: UsersService
|
||||
function onOperationCompleted(op, username, success, message) {
|
||||
root.operationPending = false;
|
||||
root.statusIsError = !success;
|
||||
if (success) {
|
||||
root.statusText = message + (username ? (" — " + username) : "");
|
||||
if (op === "create")
|
||||
root._resetForm();
|
||||
} else {
|
||||
root.statusText = (username ? (username + ": ") : "") + message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
id: deleteUserConfirm
|
||||
}
|
||||
|
||||
ConfirmModal {
|
||||
id: adminToggleConfirm
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentHeight: mainColumn.height + Theme.spacingXL
|
||||
contentWidth: width
|
||||
|
||||
Column {
|
||||
id: mainColumn
|
||||
topPadding: 4
|
||||
width: Math.min(600, parent.width - Theme.spacingL * 2)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: !PolkitService.polkitAvailable
|
||||
text: I18n.tr("Polkit integration is disabled. User management requires Polkit to elevate privileges.")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "group"
|
||||
title: I18n.tr("Existing Users")
|
||||
settingKey: "usersList"
|
||||
visible: PolkitService.polkitAvailable
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Administrator group:")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: UsersService.adminGroup
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.spacingM
|
||||
height: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: UsersService.refreshing ? I18n.tr("Refreshing…") : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: UsersService.users
|
||||
|
||||
Rectangle {
|
||||
id: userRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
height: Math.max(48, rowContent.implicitHeight + Theme.spacingS * 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHighest
|
||||
|
||||
readonly property bool isLastAdmin: modelData.isAdmin && UsersService.adminMembers.length <= 1
|
||||
|
||||
Row {
|
||||
id: rowContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: "account_circle"
|
||||
size: Theme.iconSize
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Theme.iconSize - actionButtons.width - Theme.spacingM * 3
|
||||
spacing: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
text: userRow.modelData.username
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: userRow.modelData.isAdmin
|
||||
width: adminChipText.implicitWidth + Theme.spacingS * 2
|
||||
height: adminChipText.implicitHeight + Theme.spacingXS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.primary, 0.15)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
id: adminChipText
|
||||
anchors.centerIn: parent
|
||||
text: I18n.tr("admin")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: userRow.modelData.gecos && userRow.modelData.gecos.length > 0 ? userRow.modelData.gecos + " · UID " + userRow.modelData.uid : "UID " + userRow.modelData.uid
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actionButtons
|
||||
spacing: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankActionButton {
|
||||
id: adminToggleBtn
|
||||
readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin)
|
||||
buttonSize: 36
|
||||
iconSize: 20
|
||||
iconName: userRow.modelData.isAdmin ? "shield_person" : "shield"
|
||||
iconColor: userRow.modelData.isAdmin ? Theme.primary : Theme.surfaceVariantText
|
||||
opacity: actionBlocked ? 0.4 : 1.0
|
||||
tooltipText: (userRow.isLastAdmin && userRow.modelData.isAdmin) ? I18n.tr("Cannot remove the only administrator") : (userRow.modelData.isAdmin ? I18n.tr("Remove admin") : I18n.tr("Make admin"))
|
||||
tooltipSide: "left"
|
||||
onClicked: {
|
||||
if (actionBlocked)
|
||||
return;
|
||||
const makeAdmin = !userRow.modelData.isAdmin;
|
||||
adminToggleConfirm.showWithOptions({
|
||||
title: makeAdmin ? I18n.tr("Grant admin?") : I18n.tr("Remove admin?"),
|
||||
message: makeAdmin ? I18n.tr("Add \"%1\" to the %2 group?").arg(userRow.modelData.username).arg(UsersService.adminGroup) : I18n.tr("Remove \"%1\" from the %2 group?").arg(userRow.modelData.username).arg(UsersService.adminGroup),
|
||||
confirmText: makeAdmin ? I18n.tr("Grant") : I18n.tr("Remove"),
|
||||
confirmColor: Theme.primary,
|
||||
onConfirm: () => {
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.setAdmin(userRow.modelData.username, makeAdmin, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: deleteBtn
|
||||
readonly property bool actionBlocked: root.operationPending || !UsersService.canDelete(userRow.modelData.username)
|
||||
buttonSize: 36
|
||||
iconSize: 20
|
||||
iconName: "delete"
|
||||
iconColor: Theme.error
|
||||
opacity: actionBlocked ? 0.4 : 1.0
|
||||
tooltipText: userRow.isLastAdmin ? I18n.tr("Cannot delete the only administrator") : I18n.tr("Delete user")
|
||||
tooltipSide: "left"
|
||||
onClicked: {
|
||||
if (actionBlocked)
|
||||
return;
|
||||
deleteUserConfirm.showWithOptions({
|
||||
title: I18n.tr("Delete user?"),
|
||||
message: I18n.tr("Delete \"%1\" and remove the home directory? This cannot be undone.").arg(userRow.modelData.username),
|
||||
confirmText: I18n.tr("Delete"),
|
||||
confirmColor: Theme.primary,
|
||||
onConfirm: () => {
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.deleteUser(userRow.modelData.username, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: UsersService.users.length === 0 && !UsersService.refreshing
|
||||
text: I18n.tr("No human user accounts found.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "person_add"
|
||||
title: I18n.tr("Create User")
|
||||
settingKey: "createUser"
|
||||
visible: PolkitService.polkitAvailable
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Username")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: usernameField
|
||||
width: parent.width
|
||||
placeholderText: I18n.tr("e.g. alice")
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
normalBorderColor: usernameInvalid ? Theme.error : Theme.outlineMedium
|
||||
focusedBorderColor: usernameInvalid ? Theme.error : Theme.primary
|
||||
|
||||
readonly property bool usernameInvalid: text.length > 0 && (!UsersService.isValidUsername(text) || UsersService.userExists(text))
|
||||
|
||||
onTextEdited: {
|
||||
root.pendingUsername = text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: usernameField.text.length > 0 && !UsersService.isValidUsername(usernameField.text)
|
||||
text: I18n.tr("Username must start with a lowercase letter or underscore and contain only lowercase letters, digits, hyphens, or underscores.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: usernameField.text.length > 0 && UsersService.isValidUsername(usernameField.text) && UsersService.userExists(usernameField.text)
|
||||
text: I18n.tr("A user with that name already exists.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Password")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: passwordField
|
||||
width: parent.width
|
||||
placeholderText: I18n.tr("Set initial password")
|
||||
echoMode: TextInput.Password
|
||||
showPasswordToggle: true
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
onTextEdited: root.pendingPassword = text
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Confirm password")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: confirmField
|
||||
width: parent.width
|
||||
placeholderText: I18n.tr("Re-enter password")
|
||||
echoMode: TextInput.Password
|
||||
showPasswordToggle: true
|
||||
backgroundColor: Theme.surfaceContainerHighest
|
||||
normalBorderColor: confirmMismatch ? Theme.error : Theme.outlineMedium
|
||||
focusedBorderColor: confirmMismatch ? Theme.error : Theme.primary
|
||||
|
||||
readonly property bool confirmMismatch: text.length > 0 && text !== passwordField.text
|
||||
|
||||
onTextEdited: root.pendingConfirm = text
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: confirmField.text.length > 0 && confirmField.text !== passwordField.text
|
||||
text: I18n.tr("Passwords do not match.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.error
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "createUserAdmin"
|
||||
tags: ["user", "admin", "sudo", "wheel"]
|
||||
text: I18n.tr("Grant administrator privileges")
|
||||
description: I18n.tr("Add the new user to the %1 group so they can use sudo.").arg(UsersService.adminGroup)
|
||||
checked: root.pendingAdmin
|
||||
onToggled: checked => root.pendingAdmin = checked
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankButton {
|
||||
text: root.operationPending ? I18n.tr("Working…") : I18n.tr("Create User")
|
||||
iconName: "person_add"
|
||||
backgroundColor: Theme.primary
|
||||
textColor: Theme.primaryText
|
||||
enabled: root._createCanProceed()
|
||||
onClicked: {
|
||||
if (!root._createCanProceed())
|
||||
return;
|
||||
root.operationPending = true;
|
||||
root.statusText = "";
|
||||
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, null);
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.statusText
|
||||
color: root.statusIsError ? Theme.error : Theme.primary
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width - parent.children[0].width - Theme.spacingM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("UsersService")
|
||||
|
||||
property var users: []
|
||||
property string adminGroup: "wheel"
|
||||
property var adminMembers: []
|
||||
property bool refreshing: false
|
||||
|
||||
signal operationCompleted(string op, string username, bool success, string message)
|
||||
|
||||
readonly property var _usernameRegex: /^[a-z_][a-z0-9_-]{0,30}\$?$/
|
||||
|
||||
function isValidUsername(name) {
|
||||
if (typeof name !== "string")
|
||||
return false;
|
||||
return _usernameRegex.test(name);
|
||||
}
|
||||
|
||||
function userExists(name) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (users[i].username === name)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _findUser(name) {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
if (users[i].username === name)
|
||||
return users[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canDelete(name) {
|
||||
const u = _findUser(name);
|
||||
if (!u)
|
||||
return false;
|
||||
if (u.isAdmin && adminMembers.length <= 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (refreshing)
|
||||
return;
|
||||
refreshing = true;
|
||||
_detectAdminGroup();
|
||||
}
|
||||
|
||||
function _detectAdminGroup() {
|
||||
Proc.runCommand("usersService-detectGroup", ["sh", "-c", "getent group wheel >/dev/null && echo wheel || (getent group sudo >/dev/null && echo sudo || echo wheel)"], (output, exitCode) => {
|
||||
const detected = (output || "").trim() || "wheel";
|
||||
root.adminGroup = detected;
|
||||
_loadAdminMembers();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _loadAdminMembers() {
|
||||
Proc.runCommand("usersService-adminMembers", ["sh", "-c", "getent group " + root.adminGroup + " | awk -F: '{print $4}'"], (output, exitCode) => {
|
||||
const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
|
||||
root.adminMembers = members;
|
||||
_loadUsers();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _loadUsers() {
|
||||
Proc.runCommand("usersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => {
|
||||
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
|
||||
const list = [];
|
||||
const adminSet = {};
|
||||
for (let i = 0; i < root.adminMembers.length; i++)
|
||||
adminSet[root.adminMembers[i]] = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parts = lines[i].split(":");
|
||||
if (parts.length < 5)
|
||||
continue;
|
||||
const username = parts[0];
|
||||
list.push({
|
||||
username,
|
||||
uid: parseInt(parts[1], 10),
|
||||
gecos: (parts[2] || "").split(",")[0],
|
||||
home: parts[3] || "",
|
||||
shell: parts[4] || "",
|
||||
isAdmin: adminSet[username] === true
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => a.username.localeCompare(b.username));
|
||||
root.users = list;
|
||||
root.refreshing = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function createUser(username, password, addToAdmin, callback) {
|
||||
if (!isValidUsername(username)) {
|
||||
_emit("create", username, false, I18n.tr("Invalid username"), callback);
|
||||
return;
|
||||
}
|
||||
if (!password || password.length < 1) {
|
||||
_emit("create", username, false, I18n.tr("Password cannot be empty"), callback);
|
||||
return;
|
||||
}
|
||||
if (userExists(username)) {
|
||||
_emit("create", username, false, I18n.tr("User already exists"), callback);
|
||||
return;
|
||||
}
|
||||
_runUseradd(username, password, addToAdmin === true, callback);
|
||||
}
|
||||
|
||||
function setPassword(username, newPassword, callback) {
|
||||
if (!isValidUsername(username) || !userExists(username)) {
|
||||
_emit("passwd", username, false, I18n.tr("User not found"), callback);
|
||||
return;
|
||||
}
|
||||
if (!newPassword || newPassword.length < 1) {
|
||||
_emit("passwd", username, false, I18n.tr("Password cannot be empty"), callback);
|
||||
return;
|
||||
}
|
||||
_runChpasswd(username, newPassword, "passwd", callback);
|
||||
}
|
||||
|
||||
function deleteUser(username, callback) {
|
||||
if (!userExists(username)) {
|
||||
_emit("delete", username, false, I18n.tr("User not found"), callback);
|
||||
return;
|
||||
}
|
||||
if (!canDelete(username)) {
|
||||
_emit("delete", username, false, I18n.tr("Cannot delete the only administrator"), callback);
|
||||
return;
|
||||
}
|
||||
_runUserdel(username, callback);
|
||||
}
|
||||
|
||||
function setAdmin(username, makeAdmin, callback) {
|
||||
if (!userExists(username)) {
|
||||
_emit("admin", username, false, I18n.tr("User not found"), callback);
|
||||
return;
|
||||
}
|
||||
if (!makeAdmin) {
|
||||
const u = _findUser(username);
|
||||
if (u && u.isAdmin && root.adminMembers.length <= 1) {
|
||||
_emit("admin", username, false, I18n.tr("Cannot remove the only administrator"), callback);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_runAdminToggle(username, makeAdmin === true, callback);
|
||||
}
|
||||
|
||||
function _emit(op, username, success, message, callback) {
|
||||
root.operationCompleted(op, username, success, message);
|
||||
if (typeof callback === "function") {
|
||||
try {
|
||||
callback(success, message);
|
||||
} catch (e) {
|
||||
log.warn("UsersService callback error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: useraddComp
|
||||
Process {
|
||||
id: useraddProc
|
||||
property string targetUser: ""
|
||||
property string targetPassword: ""
|
||||
property bool addAdmin: false
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: useraddProc.capturedErr = text || ""
|
||||
}
|
||||
onExited: exitCode => {
|
||||
const svc = root;
|
||||
if (exitCode !== 0) {
|
||||
svc._emit("create", useraddProc.targetUser, false, (useraddProc.capturedErr || "").trim() || I18n.tr("useradd failed (exit %1)").arg(exitCode), useraddProc.cb);
|
||||
Qt.callLater(() => useraddProc.destroy());
|
||||
return;
|
||||
}
|
||||
const targetUser = useraddProc.targetUser;
|
||||
const targetPassword = useraddProc.targetPassword;
|
||||
const addAdmin = useraddProc.addAdmin;
|
||||
const outerCb = useraddProc.cb;
|
||||
Qt.callLater(() => useraddProc.destroy());
|
||||
|
||||
svc._runChpasswd(targetUser, targetPassword, "create", (pwOk, pwMsg) => {
|
||||
if (!pwOk) {
|
||||
svc._emit("create", targetUser, false, pwMsg, outerCb);
|
||||
return;
|
||||
}
|
||||
if (addAdmin) {
|
||||
svc._runAdminToggle(targetUser, true, (adminOk, adminMsg) => {
|
||||
if (adminOk) {
|
||||
svc._emit("create", targetUser, true, I18n.tr("User created with administrator privileges"), outerCb);
|
||||
} else {
|
||||
svc._emit("create", targetUser, false, adminMsg, outerCb);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
svc._emit("create", targetUser, true, I18n.tr("User created"), outerCb);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: chpasswdComp
|
||||
Process {
|
||||
id: chpasswdProc
|
||||
property string targetUser: ""
|
||||
property string targetPassword: ""
|
||||
property string op: "passwd"
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
command: ["pkexec", "sh", "-c", "head -n1 | chpasswd"]
|
||||
stdinEnabled: true
|
||||
running: false
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: chpasswdProc.capturedErr = text || ""
|
||||
}
|
||||
onStarted: {
|
||||
chpasswdProc.write(chpasswdProc.targetUser + ":" + chpasswdProc.targetPassword + "\n");
|
||||
}
|
||||
onExited: exitCode => {
|
||||
const op = chpasswdProc.op;
|
||||
const targetUser = chpasswdProc.targetUser;
|
||||
const cb = chpasswdProc.cb;
|
||||
const err = (chpasswdProc.capturedErr || "").trim();
|
||||
Qt.callLater(() => chpasswdProc.destroy());
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const msg = err || I18n.tr("Password change failed (exit %1)").arg(exitCode);
|
||||
if (op === "create") {
|
||||
if (typeof cb === "function")
|
||||
cb(false, msg);
|
||||
} else {
|
||||
root._emit("passwd", targetUser, false, msg, cb);
|
||||
}
|
||||
} else {
|
||||
root.refresh();
|
||||
if (op === "create") {
|
||||
if (typeof cb === "function")
|
||||
cb(true, I18n.tr("Password set"));
|
||||
} else {
|
||||
root._emit("passwd", targetUser, true, I18n.tr("Password updated"), cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: userdelComp
|
||||
Process {
|
||||
id: userdelProc
|
||||
property string targetUser: ""
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: userdelProc.capturedErr = text || ""
|
||||
}
|
||||
onExited: exitCode => {
|
||||
const targetUser = userdelProc.targetUser;
|
||||
const cb = userdelProc.cb;
|
||||
const err = (userdelProc.capturedErr || "").trim();
|
||||
Qt.callLater(() => userdelProc.destroy());
|
||||
|
||||
if (exitCode !== 0) {
|
||||
root._emit("delete", targetUser, false, err || I18n.tr("userdel failed (exit %1)").arg(exitCode), cb);
|
||||
} else {
|
||||
root.refresh();
|
||||
root._emit("delete", targetUser, true, I18n.tr("User deleted"), cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: adminToggleComp
|
||||
Process {
|
||||
id: adminToggleProc
|
||||
property string targetUser: ""
|
||||
property bool makeAdmin: false
|
||||
property var cb: null
|
||||
property string capturedErr: ""
|
||||
running: false
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: adminToggleProc.capturedErr = text || ""
|
||||
}
|
||||
onExited: exitCode => {
|
||||
const targetUser = adminToggleProc.targetUser;
|
||||
const makeAdmin = adminToggleProc.makeAdmin;
|
||||
const cb = adminToggleProc.cb;
|
||||
const err = (adminToggleProc.capturedErr || "").trim();
|
||||
Qt.callLater(() => adminToggleProc.destroy());
|
||||
|
||||
if (exitCode !== 0) {
|
||||
root._emit("admin", targetUser, false, err || I18n.tr("usermod failed (exit %1)").arg(exitCode), cb);
|
||||
} else {
|
||||
root.refresh();
|
||||
root._emit("admin", targetUser, true, makeAdmin ? I18n.tr("Granted administrator privileges") : I18n.tr("Removed administrator privileges"), cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _runUseradd(username, password, addToAdmin, callback) {
|
||||
const proc = useraddComp.createObject(root, {
|
||||
command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
|
||||
targetUser: username,
|
||||
targetPassword: password,
|
||||
addAdmin: addToAdmin,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
function _runChpasswd(username, password, op, callback) {
|
||||
const proc = chpasswdComp.createObject(root, {
|
||||
targetUser: username,
|
||||
targetPassword: password,
|
||||
op: op,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
function _runUserdel(username, callback) {
|
||||
const proc = userdelComp.createObject(root, {
|
||||
command: ["pkexec", "userdel", "-r", username],
|
||||
targetUser: username,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
function _runAdminToggle(username, makeAdmin, callback) {
|
||||
const cmd = makeAdmin ? ["pkexec", "usermod", "-aG", root.adminGroup, username] : ["pkexec", "gpasswd", "-d", username, root.adminGroup];
|
||||
const proc = adminToggleComp.createObject(root, {
|
||||
command: cmd,
|
||||
targetUser: username,
|
||||
makeAdmin: makeAdmin,
|
||||
cb: callback
|
||||
});
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
}
|
||||
Reference in New Issue
Block a user