Files
gnome-user-switcher/gnome-user-switcher-extension/extension.js
2026-03-01 11:44:02 +01:00

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;
}
}