sync current state
This commit is contained in:
38
gnome-user-switcher-extension/Makefile
Normal file
38
gnome-user-switcher-extension/Makefile
Normal 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)"
|
||||
49
gnome-user-switcher-extension/README.md
Normal file
49
gnome-user-switcher-extension/README.md
Normal 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.
|
||||
BIN
gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip
vendored
Normal file
BIN
gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip
vendored
Normal file
Binary file not shown.
286
gnome-user-switcher-extension/extension.js
Normal file
286
gnome-user-switcher-extension/extension.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
11
gnome-user-switcher-extension/metadata.json
Normal file
11
gnome-user-switcher-extension/metadata.json
Normal 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"
|
||||
}
|
||||
46
gnome-user-switcher-extension/scripts/build-release.sh
Executable file
46
gnome-user-switcher-extension/scripts/build-release.sh
Executable 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"
|
||||
22
gnome-user-switcher-extension/tests/release-smoke.sh
Executable file
22
gnome-user-switcher-extension/tests/release-smoke.sh
Executable 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"
|
||||
27
gnome-user-switcher-extension/tests/smoke.sh
Executable file
27
gnome-user-switcher-extension/tests/smoke.sh
Executable 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"
|
||||
Reference in New Issue
Block a user