287 lines
7.0 KiB
JavaScript
287 lines
7.0 KiB
JavaScript
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
import St from 'gi://St';
|
|
import Clutter from 'gi://Clutter';
|
|
|
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
|
|
|
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
|
|
|
|
const MIN_NORMAL_UID = 1000;
|
|
const MAX_NORMAL_UID = 60000;
|
|
|
|
const UserSwitcherIndicator = GObject.registerClass(
|
|
class UserSwitcherIndicator extends PanelMenu.Button {
|
|
_init(extension) {
|
|
super._init(0.0, 'User Switcher');
|
|
|
|
this._extension = extension;
|
|
this._ = extension.gettext.bind(extension);
|
|
this._currentUser = GLib.get_user_name();
|
|
|
|
const panelBox = new St.BoxLayout({
|
|
style_class: 'panel-status-menu-box',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
|
|
const icon = new St.Icon({
|
|
icon_name: 'avatar-default-symbolic',
|
|
style_class: 'system-status-icon',
|
|
});
|
|
panelBox.add_child(icon);
|
|
|
|
const label = new St.Label({
|
|
text: this._currentUser,
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
});
|
|
panelBox.add_child(label);
|
|
|
|
this.add_child(panelBox);
|
|
this._buildMenu();
|
|
}
|
|
|
|
_buildMenu() {
|
|
this.menu.removeAll();
|
|
|
|
const title = new PopupMenu.PopupMenuItem(
|
|
this._('Switch User'),
|
|
{reactive: false, can_focus: false}
|
|
);
|
|
title.label.add_style_class_name('popup-menu-item-title');
|
|
this.menu.addMenuItem(title);
|
|
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
|
|
|
for (const user of this._getLocalUsers()) {
|
|
const item = new PopupMenu.PopupMenuItem(user.displayName);
|
|
if (user.username === this._currentUser)
|
|
item.setOrnament(PopupMenu.Ornament.DOT);
|
|
|
|
item.connect('activate', () => {
|
|
void this._switchToUser(user.username);
|
|
});
|
|
this.menu.addMenuItem(item);
|
|
}
|
|
|
|
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
|
const loginScreenItem = new PopupMenu.PopupMenuItem(this._('Open Login Screen'));
|
|
loginScreenItem.connect('activate', () => this._openLoginScreen());
|
|
this.menu.addMenuItem(loginScreenItem);
|
|
|
|
const logoutItem = new PopupMenu.PopupMenuItem(this._('Log Out'));
|
|
logoutItem.connect('activate', () => this._logOutCurrentUser());
|
|
this.menu.addMenuItem(logoutItem);
|
|
}
|
|
|
|
_getLocalUsers() {
|
|
const users = [];
|
|
let contents;
|
|
|
|
try {
|
|
[, contents] = GLib.file_get_contents('/etc/passwd');
|
|
} catch (error) {
|
|
log(`User Switcher: failed to read /etc/passwd: ${error}`);
|
|
return users;
|
|
}
|
|
|
|
const lines = new TextDecoder().decode(contents).split('\n');
|
|
for (const line of lines) {
|
|
if (!line || line.startsWith('#'))
|
|
continue;
|
|
|
|
const fields = line.split(':');
|
|
if (fields.length < 7)
|
|
continue;
|
|
|
|
const username = fields[0];
|
|
const uid = Number.parseInt(fields[2], 10);
|
|
const gecos = fields[4] ?? '';
|
|
const shell = fields[6] ?? '';
|
|
|
|
if (Number.isNaN(uid))
|
|
continue;
|
|
if (uid < MIN_NORMAL_UID || uid > MAX_NORMAL_UID)
|
|
continue;
|
|
if (shell.endsWith('/nologin') || shell.endsWith('/false'))
|
|
continue;
|
|
|
|
const fullName = gecos.split(',')[0].trim();
|
|
const displayName = fullName ? `${fullName} (${username})` : username;
|
|
users.push({username, displayName});
|
|
}
|
|
|
|
users.sort((a, b) => a.username.localeCompare(b.username));
|
|
return users;
|
|
}
|
|
|
|
async _switchToUser(username) {
|
|
if (username === this._currentUser)
|
|
return;
|
|
|
|
if (await this._activateUserSession(username))
|
|
return;
|
|
|
|
if (await this._runSuccessfulCommand(['dm-tool', 'switch-to-user', username]))
|
|
return;
|
|
|
|
Main.notify(
|
|
this._('User Switcher'),
|
|
this._('Direct switch is unavailable. Opening login screen instead.')
|
|
);
|
|
this._openLoginScreen();
|
|
}
|
|
|
|
_openLoginScreen() {
|
|
const commands = [
|
|
['gdmflexiserver'],
|
|
['gnome-session-quit', '--switch-user', '--no-prompt'],
|
|
];
|
|
|
|
for (const argv of commands) {
|
|
if (this._spawnIfAvailable(argv))
|
|
return;
|
|
}
|
|
|
|
Main.notify(
|
|
this._('User Switcher'),
|
|
this._('No supported switch command was found on this system.')
|
|
);
|
|
}
|
|
|
|
_logOutCurrentUser() {
|
|
if (this._spawnIfAvailable(['gnome-session-quit', '--logout', '--no-prompt']))
|
|
return;
|
|
|
|
Main.notify(
|
|
this._('User Switcher'),
|
|
this._('No supported logout command was found on this system.')
|
|
);
|
|
}
|
|
|
|
async _runSuccessfulCommand(argv) {
|
|
if (!argv.length)
|
|
return false;
|
|
|
|
if (!GLib.find_program_in_path(argv[0]))
|
|
return false;
|
|
|
|
try {
|
|
const process = Gio.Subprocess.new(
|
|
argv,
|
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
|
|
);
|
|
const result = await this._communicateUtf8(process);
|
|
return result.ok && process.get_successful();
|
|
} catch (error) {
|
|
log(`User Switcher: failed to start ${argv[0]}: ${error}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async _activateUserSession(username) {
|
|
const sessionId = await this._getUserSessionId(username);
|
|
if (!sessionId)
|
|
return false;
|
|
|
|
return this._runSuccessfulCommand(['loginctl', 'activate', sessionId]);
|
|
}
|
|
|
|
async _getUserSessionId(username) {
|
|
const sessionsOutput = await this._runCommandGetStdout([
|
|
'loginctl',
|
|
'list-sessions',
|
|
'--no-legend',
|
|
]);
|
|
if (!sessionsOutput)
|
|
return null;
|
|
|
|
for (const line of sessionsOutput.split('\n')) {
|
|
const fields = line.trim().split(/\s+/);
|
|
if (fields.length < 3)
|
|
continue;
|
|
|
|
const sessionId = fields[0];
|
|
const sessionUser = fields[2];
|
|
if (sessionUser === username)
|
|
return sessionId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async _runCommandGetStdout(argv) {
|
|
if (!argv.length)
|
|
return null;
|
|
|
|
if (!GLib.find_program_in_path(argv[0]))
|
|
return null;
|
|
|
|
try {
|
|
const process = Gio.Subprocess.new(
|
|
argv,
|
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
|
|
);
|
|
const result = await this._communicateUtf8(process);
|
|
if (!result.ok || !process.get_successful())
|
|
return null;
|
|
|
|
const value = result.stdout.trim();
|
|
return value.length > 0 ? value : null;
|
|
} catch (error) {
|
|
log(`User Switcher: failed to run ${argv[0]}: ${error}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
_communicateUtf8(process) {
|
|
return new Promise(resolve => {
|
|
process.communicate_utf8_async(null, null, (proc, res) => {
|
|
try {
|
|
const [ok, stdout] = proc.communicate_utf8_finish(res);
|
|
resolve({
|
|
ok,
|
|
stdout: stdout ?? '',
|
|
});
|
|
} catch (error) {
|
|
log(`User Switcher: command communication failed: ${error}`);
|
|
resolve({
|
|
ok: false,
|
|
stdout: '',
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
_spawnIfAvailable(argv) {
|
|
if (!argv.length)
|
|
return false;
|
|
|
|
if (!GLib.find_program_in_path(argv[0]))
|
|
return false;
|
|
|
|
try {
|
|
Gio.Subprocess.new(argv, Gio.SubprocessFlags.NONE);
|
|
return true;
|
|
} catch (error) {
|
|
log(`User Switcher: failed to spawn ${argv[0]}: ${error}`);
|
|
return false;
|
|
}
|
|
}
|
|
});
|
|
|
|
export default class UserSwitcherExtension extends Extension {
|
|
enable() {
|
|
this.initTranslations();
|
|
this._indicator = new UserSwitcherIndicator(this);
|
|
Main.panel.addToStatusArea(this.uuid, this._indicator, 1, 'right');
|
|
}
|
|
|
|
disable() {
|
|
this._indicator?.destroy();
|
|
this._indicator = null;
|
|
}
|
|
}
|