diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 05c29c11..9a9714d0 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -541,5 +541,6 @@ func getCommonCommands() []*cobra.Command { blurCmd, trashCmd, systemCmd, + switchUserCmd, } } diff --git a/core/cmd/dms/commands_session.go b/core/cmd/dms/commands_session.go new file mode 100644 index 00000000..124d3c65 --- /dev/null +++ b/core/cmd/dms/commands_session.go @@ -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) + } +} diff --git a/docs/IPC.md b/docs/IPC.md index c1652d86..29babecc 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -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 `** +- 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 `** +- 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. diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 16333494..e37e1c4f 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -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 diff --git a/quickshell/Modals/PowerMenuModal.qml b/quickshell/Modals/PowerMenuModal.qml index 62f64f68..781062aa 100644 --- a/quickshell/Modals/PowerMenuModal.qml +++ b/quickshell/Modals/PowerMenuModal.qml @@ -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", diff --git a/quickshell/Modals/SwitchUserModal.qml b/quickshell/Modals/SwitchUserModal.qml new file mode 100644 index 00000000..61eac520 --- /dev/null +++ b/quickshell/Modals/SwitchUserModal.qml @@ -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 + } + } + } + } +} diff --git a/quickshell/Modules/Lock/LockPowerMenu.qml b/quickshell/Modules/Lock/LockPowerMenu.qml index 0a0ee849..4afa56d5 100644 --- a/quickshell/Modules/Lock/LockPowerMenu.qml +++ b/quickshell/Modules/Lock/LockPowerMenu.qml @@ -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(); diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index d83d7075..c8e5054f 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -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 } } diff --git a/quickshell/Modules/Settings/PowerSleepTab.qml b/quickshell/Modules/Settings/PowerSleepTab.qml index b56569f1..ef860c63 100644 --- a/quickshell/Modules/Settings/PowerSleepTab.qml +++ b/quickshell/Modules/Settings/PowerSleepTab.qml @@ -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"), diff --git a/quickshell/Services/SessionsService.qml b/quickshell/Services/SessionsService.qml new file mode 100644 index 00000000..13d79a6c --- /dev/null +++ b/quickshell/Services/SessionsService.qml @@ -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() +}