sync current state

This commit is contained in:
2026-03-01 11:44:02 +01:00
commit 7b52914920
18 changed files with 1137 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
UUID := user-switcher@felixfoertsch.de
EXT_DIR := $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
.PHONY: install install-shell uninstall test package package-release check-release enable disable
install:
mkdir -p "$(EXT_DIR)"
cp metadata.json extension.js "$(EXT_DIR)/"
if [ -d locale ]; then cp -r locale "$(EXT_DIR)/"; fi
@echo "Installed to $(EXT_DIR)"
install-shell: package
gnome-extensions install --force "dist/$(UUID).shell-extension.zip"
@echo "Installed via gnome-extensions CLI"
uninstall:
rm -rf "$(EXT_DIR)"
@echo "Removed $(EXT_DIR)"
test:
bash tests/smoke.sh
bash tests/release-smoke.sh
package:
mkdir -p dist
gnome-extensions pack --force --out-dir dist .
package-release:
bash scripts/build-release.sh
check-release:
bash tests/release-smoke.sh
enable:
gnome-extensions enable "$(UUID)"
disable:
gnome-extensions disable "$(UUID)"

View File

@@ -0,0 +1,49 @@
# GNOME User Switcher
A GNOME Shell extension that adds a user switcher to the top bar.
## Features
- Shows the current username in the top bar.
- Opens a dropdown with all regular local users from `/etc/passwd`.
- Switches directly to the selected user if that user already has an active session (`loginctl activate`).
- Uses `dm-tool switch-to-user` when available.
- Falls back to the GNOME switch-user login screen.
## Install (recommended)
```bash
make install-shell
make enable
```
This path uses GNOME's extension installer and registers the extension in the running shell session.
## Install (manual copy)
```bash
make install
make enable
```
## Build For extensions.gnome.org
Create a release bundle with compiled translations:
```bash
make package-release
```
Validate release bundle contents:
```bash
make check-release
```
Upload file:
`dist/user-switcher@felixfoertsch.de.shell-extension.zip`
## Notes
- Direct switching to a user that is not logged in is display-manager dependent. On GDM, the reliable fallback is opening the login screen.

View File

@@ -0,0 +1,286 @@
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;
}
}

View File

@@ -0,0 +1,25 @@
msgid ""
msgstr ""
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
msgid "Switch User"
msgstr "Benutzer wechseln"
msgid "Open Login Screen"
msgstr "Anmeldebildschirm öffnen"
msgid "User Switcher"
msgstr "Benutzerwechsler"
msgid "Direct switch is unavailable. Opening login screen instead."
msgstr "Direktes Wechseln ist nicht verfügbar. Stattdessen wird der Anmeldebildschirm geöffnet."
msgid "No supported switch command was found on this system."
msgstr "Auf diesem System wurde kein unterstützter Befehl zum Benutzerwechsel gefunden."
msgid "Log Out"
msgstr "Abmelden"
msgid "No supported logout command was found on this system."
msgstr "Auf diesem System wurde kein unterstützter Befehl zum Abmelden gefunden."

View File

@@ -0,0 +1,25 @@
msgid ""
msgstr ""
"Language: en\n"
"Content-Type: text/plain; charset=UTF-8\n"
msgid "Switch User"
msgstr "Switch User"
msgid "Open Login Screen"
msgstr "Open Login Screen"
msgid "User Switcher"
msgstr "User Switcher"
msgid "Direct switch is unavailable. Opening login screen instead."
msgstr "Direct switch is unavailable. Opening login screen instead."
msgid "No supported switch command was found on this system."
msgstr "No supported switch command was found on this system."
msgid "Log Out"
msgstr "Log Out"
msgid "No supported logout command was found on this system."
msgstr "No supported logout command was found on this system."

View File

@@ -0,0 +1,25 @@
msgid ""
msgstr ""
"Language: es\n"
"Content-Type: text/plain; charset=UTF-8\n"
msgid "Switch User"
msgstr "Cambiar usuario"
msgid "Open Login Screen"
msgstr "Abrir pantalla de inicio de sesión"
msgid "User Switcher"
msgstr "Selector de usuario"
msgid "Direct switch is unavailable. Opening login screen instead."
msgstr "El cambio directo no está disponible. Se abrirá la pantalla de inicio de sesión."
msgid "No supported switch command was found on this system."
msgstr "No se encontró un comando compatible para cambiar de usuario en este sistema."
msgid "Log Out"
msgstr "Cerrar sesión"
msgid "No supported logout command was found on this system."
msgstr "No se encontró un comando compatible para cerrar sesión en este sistema."

View File

@@ -0,0 +1,25 @@
msgid ""
msgstr ""
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
msgid "Switch User"
msgstr "Changer d'utilisateur"
msgid "Open Login Screen"
msgstr "Ouvrir l'écran de connexion"
msgid "User Switcher"
msgstr "Sélecteur d'utilisateur"
msgid "Direct switch is unavailable. Opening login screen instead."
msgstr "Le changement direct n'est pas disponible. L'écran de connexion va s'ouvrir."
msgid "No supported switch command was found on this system."
msgstr "Aucune commande de changement d'utilisateur prise en charge n'a été trouvée sur ce système."
msgid "Log Out"
msgstr "Se déconnecter"
msgid "No supported logout command was found on this system."
msgstr "Aucune commande de déconnexion prise en charge n'a été trouvée sur ce système."

View File

@@ -0,0 +1,11 @@
{
"uuid": "user-switcher@felixfoertsch.de",
"name": "User Switcher",
"description": "Adds a top bar user menu for fast user switching.",
"version": 1,
"shell-version": [
"49"
],
"gettext-domain": "user-switcher",
"url": "https://github.com/felixfoertsch/gnome-user-switcher"
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
UUID="user-switcher@felixfoertsch.de"
STAGE_DIR="${ROOT_DIR}/.build/release/${UUID}"
DIST_DIR="${ROOT_DIR}/dist"
ZIP_PATH="${DIST_DIR}/${UUID}.shell-extension.zip"
if ! command -v msgfmt >/dev/null 2>&1; then
echo "msgfmt is required to build release translations (install gettext)." >&2
exit 1
fi
if ! command -v zip >/dev/null 2>&1; then
echo "zip is required to create the release archive." >&2
exit 1
fi
rm -rf "$STAGE_DIR"
mkdir -p "$STAGE_DIR"
mkdir -p "$DIST_DIR"
cp "$ROOT_DIR/metadata.json" "$ROOT_DIR/extension.js" "$STAGE_DIR/"
for lang in en de es fr; do
po_path="${ROOT_DIR}/locale/${lang}/LC_MESSAGES/user-switcher.po"
mo_dir="${STAGE_DIR}/locale/${lang}/LC_MESSAGES"
mo_path="${mo_dir}/user-switcher.mo"
if [[ ! -f "$po_path" ]]; then
echo "missing translation source: $po_path" >&2
exit 1
fi
mkdir -p "$mo_dir"
msgfmt "$po_path" -o "$mo_path"
done
rm -f "$ZIP_PATH"
(
cd "$STAGE_DIR"
zip -qr "$ZIP_PATH" metadata.json extension.js locale
)
echo "Release package created: $ZIP_PATH"

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
UUID="user-switcher@felixfoertsch.de"
ZIP_PATH="${ROOT_DIR}/dist/${UUID}.shell-extension.zip"
cd "$ROOT_DIR"
make package-release >/dev/null
if [[ ! -f "$ZIP_PATH" ]]; then
echo "missing release zip: $ZIP_PATH" >&2
exit 1
fi
ZIP_LIST="$(unzip -l "$ZIP_PATH")"
echo "$ZIP_LIST" | rg -q "metadata.json"
echo "$ZIP_LIST" | rg -q "extension.js"
echo "$ZIP_LIST" | rg -q "locale/.*/LC_MESSAGES/user-switcher.mo"
echo "release smoke test passed"

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
for required in metadata.json extension.js; do
if [[ ! -f "$required" ]]; then
echo "missing required file: $required" >&2
exit 1
fi
done
if ! command -v gnome-extensions >/dev/null 2>&1; then
echo "gnome-extensions CLI not found; skipped shell validation"
exit 0
fi
gnome-extensions pack --force --out-dir /tmp . >/dev/null
if rg -n "communicate_utf8\\(null, null\\)" extension.js >/dev/null; then
echo "found blocking subprocess call in extension.js" >&2
exit 1
fi
echo "smoke test passed"