commit 7b529149206b383757e3362b7bc63c63315b73d9 Author: Felix Förtsch Date: Sun Mar 1 11:44:02 2026 +0100 sync current state diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e5eec0 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Workspace Layout + +This repository now contains two separate projects: + +- `gnome-user-switcher-extension/`: GNOME Shell extension project (source, packaging, release zip). +- `global-extension-installer/`: System-wide installer project driven by `extensions.txt`. diff --git a/global-extension-installer/Makefile b/global-extension-installer/Makefile new file mode 100644 index 0000000..651f27f --- /dev/null +++ b/global-extension-installer/Makefile @@ -0,0 +1,10 @@ +.PHONY: test apply dry-run + +test: + bash tests/extensions-defaults-smoke.sh + +apply: + sudo bash scripts/apply-extension-defaults.sh + +dry-run: + bash scripts/apply-extension-defaults.sh --dry-run diff --git a/global-extension-installer/README.md b/global-extension-installer/README.md new file mode 100644 index 0000000..542aefd --- /dev/null +++ b/global-extension-installer/README.md @@ -0,0 +1,47 @@ +# Global Extension Installer + +Installs GNOME Shell extensions system-wide, sets default enabled extensions for new users, and applies the same list to existing users. + +## Source List + +Edit `scripts/extensions.txt`: + +```text + +``` + +Supported source values: +- local zip path +- zip URL +- git URL +- git URL with subdir (`...git#path/to/extension`) + +Example (`scripts/extensions.txt`): + +```text +user-switcher@felixfoertsch.de ../gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip +dash-to-panel@jderose9.github.com https://github.com/home-sweet-gnome/dash-to-panel.git +``` + +## Run + +```bash +sudo bash scripts/apply-extension-defaults.sh +``` + +Dry run: + +```bash +bash scripts/apply-extension-defaults.sh --dry-run +``` + +Override users: + +```bash +sudo bash scripts/apply-extension-defaults.sh --users kari,owen,romy +``` + +## Notes + +- System install target: `/usr/share/gnome-shell/extensions/` +- The script normalizes permissions after install (`root:root`, dirs `755`, files `644`). diff --git a/global-extension-installer/scripts/apply-extension-defaults.sh b/global-extension-installer/scripts/apply-extension-defaults.sh new file mode 100755 index 0000000..b688dc1 --- /dev/null +++ b/global-extension-installer/scripts/apply-extension-defaults.sh @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_USERS=(kari owen romy) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SYSTEM_EXT_DIR="/usr/share/gnome-shell/extensions" +DEFAULT_EXTENSIONS_FILE="${SCRIPT_DIR}/extensions.txt" + +DCONF_PROFILE_FILE="/etc/dconf/profile/user" +DCONF_DEFAULTS_FILE="/etc/dconf/db/local.d/00-gnome-extensions" +DCONF_LOCKS_FILE="/etc/dconf/db/local.d/locks/gnome-extensions" + +DRY_RUN=0 +LOCK_DEFAULT=0 +REFRESH_EXISTING=0 +USERS=("${DEFAULT_USERS[@]}") +EXTENSIONS_FILE="${DEFAULT_EXTENSIONS_FILE}" + +EXTENSION_UUIDS=() +EXTENSION_SOURCES=() +EXTENSIONS_BASE_DIR="" + +print_usage() { + cat < + +Supported source values: + - local zip path (relative or absolute) + - zip URL (https://...zip) + - git URL (https://...git) + - git URL with subdir (https://...git#path/to/extension) +USAGE +} + +run_cmd() { + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] ' + printf '%q ' "$@" + echo + return 0 + fi + "$@" +} + +run_cmd_may_fail() { + if [[ $DRY_RUN -eq 1 ]]; then + printf '[dry-run] ' + printf '%q ' "$@" + echo + return 0 + fi + "$@" +} + +write_file() { + local path="$1" + local content="$2" + + if [[ $DRY_RUN -eq 1 ]]; then + echo "[dry-run] write ${path}" + echo "$content" + return 0 + fi + + mkdir -p "$(dirname "$path")" + printf '%s\n' "$content" > "$path" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --extensions-file) + EXTENSIONS_FILE="$2" + shift 2 + ;; + --users) + IFS=',' read -r -a USERS <<< "$2" + shift 2 + ;; + --lock-default) + LOCK_DEFAULT=1 + shift + ;; + --refresh-existing) + REFRESH_EXISTING=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + print_usage + exit 1 + ;; + esac + done +} + +ensure_root() { + if [[ $DRY_RUN -eq 1 ]]; then + return 0 + fi + + if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo "Run as root (sudo) unless using --dry-run." >&2 + exit 1 + fi +} + +extract_uuid() { + local metadata_file="$1" + sed -n 's/.*"uuid"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata_file" | head -n1 +} + +resolve_source_path() { + local source="$1" + if [[ "$source" = /* ]]; then + printf '%s' "$source" + else + printf '%s/%s' "$EXTENSIONS_BASE_DIR" "$source" + fi +} + +is_zip_source() { + local source="$1" + [[ "$source" == *.zip ]] || [[ "$source" == *.zip\?* ]] +} + +is_url_source() { + local source="$1" + [[ "$source" =~ ^https?:// ]] +} + +normalize_extension_permissions() { + local target_dir="$1" + run_cmd chown -R root:root "$target_dir" + run_cmd find "$target_dir" -type d -exec chmod 755 {} + + run_cmd find "$target_dir" -type f -exec chmod 644 {} + +} + +copy_dir_into_system_extensions() { + local source_dir="$1" + local uuid="$2" + local target_dir="${SYSTEM_EXT_DIR}/${uuid}" + + if [[ ! -f "$source_dir/metadata.json" ]]; then + echo "Invalid extension source for $uuid: metadata.json missing in $source_dir" >&2 + return 1 + fi + + if [[ "$(extract_uuid "$source_dir/metadata.json")" != "$uuid" ]]; then + echo "UUID mismatch for $uuid in $source_dir/metadata.json" >&2 + return 1 + fi + + run_cmd mkdir -p "$target_dir" + run_cmd cp -a "$source_dir/." "$target_dir/" + normalize_extension_permissions "$target_dir" + echo "System-wide extension ensured: $uuid" +} + +resolve_extension_dir_from_tree() { + local tree_root="$1" + local uuid="$2" + local subdir="$3" + local candidate + local metadata + + if [[ -n "$subdir" ]]; then + candidate="$tree_root/$subdir" + if [[ -f "$candidate/metadata.json" ]] && [[ "$(extract_uuid "$candidate/metadata.json")" == "$uuid" ]]; then + printf '%s' "$candidate" + return 0 + fi + echo "Subdir '$subdir' does not contain extension $uuid" >&2 + return 1 + fi + + if [[ -f "$tree_root/metadata.json" ]] && [[ "$(extract_uuid "$tree_root/metadata.json")" == "$uuid" ]]; then + printf '%s' "$tree_root" + return 0 + fi + + while IFS= read -r metadata; do + if [[ "$(extract_uuid "$metadata")" == "$uuid" ]]; then + printf '%s' "$(dirname "$metadata")" + return 0 + fi + done < <(find "$tree_root" -maxdepth 6 -type f -name metadata.json) + + echo "Could not locate extension $uuid in source tree" >&2 + return 1 +} + +install_from_zip_source() { + local uuid="$1" + local source="$2" + local work_dir + local zip_path + local extract_dir + local ext_dir + + if [[ $DRY_RUN -eq 1 ]]; then + if is_url_source "$source"; then + run_cmd curl -fsSL "$source" -o /tmp/extension.zip + run_cmd unzip -q /tmp/extension.zip -d /tmp/extension-unzip + else + zip_path="$(resolve_source_path "$source")" + run_cmd unzip -q "$zip_path" -d /tmp/extension-unzip + fi + echo "[dry-run] resolve extension directory for $uuid from zip" + run_cmd mkdir -p "${SYSTEM_EXT_DIR}/${uuid}" + run_cmd cp -a /tmp/extension-unzip/. "${SYSTEM_EXT_DIR}/${uuid}/" + normalize_extension_permissions "${SYSTEM_EXT_DIR}/${uuid}" + echo "System-wide extension ensured: $uuid" + return 0 + fi + + work_dir="$(mktemp -d)" + extract_dir="$work_dir/unzip" + mkdir -p "$extract_dir" + + if is_url_source "$source"; then + zip_path="$work_dir/source.zip" + curl -fsSL "$source" -o "$zip_path" + else + zip_path="$(resolve_source_path "$source")" + if [[ ! -f "$zip_path" ]]; then + echo "Missing zip source for $uuid: $zip_path" >&2 + rm -rf "$work_dir" + return 1 + fi + fi + + unzip -q "$zip_path" -d "$extract_dir" + ext_dir="$(resolve_extension_dir_from_tree "$extract_dir" "$uuid" "")" + copy_dir_into_system_extensions "$ext_dir" "$uuid" + rm -rf "$work_dir" +} + +install_from_git_source() { + local uuid="$1" + local source="$2" + local repo_url + local subdir="" + local work_dir + local repo_dir + local ext_dir + + repo_url="${source%%#*}" + if [[ "$source" == *"#"* ]]; then + subdir="${source#*#}" + fi + + if [[ $DRY_RUN -eq 1 ]]; then + run_cmd git clone --depth 1 "$repo_url" /tmp/extension-repo + echo "[dry-run] resolve extension directory for $uuid (subdir: ${subdir:-auto})" + run_cmd mkdir -p "${SYSTEM_EXT_DIR}/${uuid}" + run_cmd cp -a /tmp/extension-repo/. "${SYSTEM_EXT_DIR}/${uuid}/" + normalize_extension_permissions "${SYSTEM_EXT_DIR}/${uuid}" + echo "System-wide extension ensured: $uuid" + return 0 + fi + + work_dir="$(mktemp -d)" + repo_dir="$work_dir/repo" + git clone --depth 1 "$repo_url" "$repo_dir" >/dev/null 2>&1 + ext_dir="$(resolve_extension_dir_from_tree "$repo_dir" "$uuid" "$subdir")" + copy_dir_into_system_extensions "$ext_dir" "$uuid" + rm -rf "$work_dir" +} + +install_extension() { + local uuid="$1" + local source="$2" + local target_dir="${SYSTEM_EXT_DIR}/${uuid}" + + if [[ -f "$target_dir/metadata.json" && $REFRESH_EXISTING -eq 0 ]]; then + normalize_extension_permissions "$target_dir" + echo "System-wide extension already present: $uuid" + return 0 + fi + + if is_zip_source "$source"; then + install_from_zip_source "$uuid" "$source" + return + fi + + if is_url_source "$source"; then + install_from_git_source "$uuid" "$source" + return + fi + + echo "Unsupported source for $uuid: $source" >&2 + return 1 +} + +load_extensions_file() { + local file="$1" + local line + local lineno=0 + local uuid + local source + + if [[ ! -f "$file" ]]; then + echo "Missing extensions file: $file" >&2 + exit 1 + fi + + EXTENSIONS_BASE_DIR="$(cd "$(dirname "$file")" && pwd)" + + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + line="${line%%$'\r'}" + [[ -z "$line" ]] && continue + [[ "${line:0:1}" == "#" ]] && continue + + uuid="${line%%[[:space:]]*}" + source="${line#"$uuid"}" + source="$(echo "$source" | sed 's/^[[:space:]]*//')" + + if [[ -z "$uuid" || -z "$source" || "$uuid" == "$line" ]]; then + echo "Invalid line ${lineno} in ${file}: expected ' '" >&2 + exit 1 + fi + + EXTENSION_UUIDS+=("$uuid") + EXTENSION_SOURCES+=("$source") + done < "$file" + + if [[ ${#EXTENSION_UUIDS[@]} -eq 0 ]]; then + echo "No extensions loaded from ${file}" >&2 + exit 1 + fi + + echo "Loaded ${#EXTENSION_UUIDS[@]} extension entries from ${file}" +} + +install_system_wide_extensions() { + local i + local failures=0 + + for i in "${!EXTENSION_UUIDS[@]}"; do + if ! install_extension "${EXTENSION_UUIDS[$i]}" "${EXTENSION_SOURCES[$i]}"; then + echo "Failed to install ${EXTENSION_UUIDS[$i]} from ${EXTENSION_SOURCES[$i]}" >&2 + failures=$((failures + 1)) + fi + done + + if [[ $failures -gt 0 ]]; then + echo "System-wide install failed for ${failures} extension(s)." >&2 + return 1 + fi +} + +json_array_from_uuids() { + local out="[" + local first=1 + local uuid + + for uuid in "${EXTENSION_UUIDS[@]}"; do + if [[ $first -eq 0 ]]; then + out+=", " + fi + out+="'${uuid}'" + first=0 + done + + out+="]" + printf '%s' "$out" +} + +apply_dconf_defaults() { + local extensions_variant + extensions_variant="$(json_array_from_uuids)" + + if [[ -f "$DCONF_PROFILE_FILE" ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + echo "[dry-run] ensure ${DCONF_PROFILE_FILE} contains system-db:local" + elif ! rg -q '^system-db:local$' "$DCONF_PROFILE_FILE"; then + printf '%s\n' 'system-db:local' >> "$DCONF_PROFILE_FILE" + fi + else + write_file "$DCONF_PROFILE_FILE" $'user-db:user\nsystem-db:local' + fi + + write_file "$DCONF_DEFAULTS_FILE" "[org/gnome/shell] +enabled-extensions=${extensions_variant} +disable-user-extensions=false" + + if [[ $LOCK_DEFAULT -eq 1 ]]; then + write_file "$DCONF_LOCKS_FILE" $'/org/gnome/shell/enabled-extensions\n/org/gnome/shell/disable-user-extensions' + fi + + run_cmd dconf update +} + +apply_user_settings() { + local extensions_variant + extensions_variant="$(json_array_from_uuids)" + local failures=0 + local user + local uuid + + for user in "${USERS[@]}"; do + if ! id "$user" >/dev/null 2>&1; then + echo "Skipping unknown user: $user" + continue + fi + + if run_cmd_may_fail runuser -u "$user" -- dbus-run-session -- gsettings set org.gnome.shell enabled-extensions "$extensions_variant" \ + && run_cmd_may_fail runuser -u "$user" -- dbus-run-session -- gsettings set org.gnome.shell disable-user-extensions false; then + echo "Applied extension list for user: $user" + else + echo "Primary gsettings path failed for user: $user, trying dconf fallback" + if run_cmd_may_fail runuser -u "$user" -- dbus-run-session -- dconf write /org/gnome/shell/enabled-extensions "$extensions_variant" \ + && run_cmd_may_fail runuser -u "$user" -- dbus-run-session -- dconf write /org/gnome/shell/disable-user-extensions false; then + echo "Applied extension list for user via dconf fallback: $user" + else + echo "Failed to apply extension list for user: $user" >&2 + failures=$((failures + 1)) + fi + fi + + for uuid in "${EXTENSION_UUIDS[@]}"; do + run_cmd_may_fail runuser -u "$user" -- dbus-run-session -- gnome-extensions enable "$uuid" >/dev/null 2>&1 || true + done + done + + if [[ $failures -gt 0 ]]; then + echo "One or more user updates failed (${failures})." >&2 + return 1 + fi +} + +parse_args "$@" +ensure_root +load_extensions_file "$EXTENSIONS_FILE" +install_system_wide_extensions +apply_dconf_defaults +apply_user_settings + +echo "System-wide extension install complete, defaults configured for new users, settings applied to selected existing users." +echo "Users should log out and back in once to ensure GNOME Shell loads the new defaults cleanly." diff --git a/global-extension-installer/scripts/extensions.txt b/global-extension-installer/scripts/extensions.txt new file mode 100644 index 0000000..a94d974 --- /dev/null +++ b/global-extension-installer/scripts/extensions.txt @@ -0,0 +1,10 @@ +# uuid source +# source supports local zip paths, zip URLs, and git URLs (+ optional #subdir) + +user-switcher@felixfoertsch.de ../gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip +dash-to-panel@jderose9.github.com https://github.com/home-sweet-gnome/dash-to-panel.git +just-perfection-desktop@just-perfection https://gitlab.gnome.org/jrahmatzadeh/just-perfection.git +logomenu@aryan_k https://github.com/Aryan20/Logomenu.git +drive-menu@gnome-shell-extensions.gcampax.github.com https://gitlab.gnome.org/GNOME/gnome-shell-extensions.git#extensions/drive-menu +appindicatorsupport@rgcjonas.gmail.com https://github.com/ubuntu/gnome-shell-extension-appindicator.git +caffeine@patapon.info https://github.com/eonpatapon/gnome-shell-extension-caffeine.git diff --git a/global-extension-installer/tests/extensions-defaults-smoke.sh b/global-extension-installer/tests/extensions-defaults-smoke.sh new file mode 100755 index 0000000..317c127 --- /dev/null +++ b/global-extension-installer/tests/extensions-defaults-smoke.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +OUTPUT="$(bash scripts/apply-extension-defaults.sh --dry-run 2>&1)" + +echo "$OUTPUT" | rg -q "scripts/extensions.txt" +echo "$OUTPUT" | rg -q "user-switcher@felixfoertsch.de" +echo "$OUTPUT" | rg -q "/etc/dconf/db/local.d/00-gnome-extensions" +echo "$OUTPUT" | rg -q "user-switcher@felixfoertsch.de" +echo "$OUTPUT" | rg -q "dash-to-panel@jderose9.github.com" +echo "$OUTPUT" | rg -q "just-perfection-desktop@just-perfection" +echo "$OUTPUT" | rg -q "logomenu@aryan_k" +echo "$OUTPUT" | rg -q "drive-menu@gnome-shell-extensions.gcampax.github.com" +echo "$OUTPUT" | rg -q "appindicatorsupport@rgcjonas.gmail.com" +echo "$OUTPUT" | rg -q "caffeine@patapon.info" + +echo "$OUTPUT" | rg -q "Applied extension list for user: kari" +echo "$OUTPUT" | rg -q "Applied extension list for user: owen" +echo "$OUTPUT" | rg -q "Applied extension list for user: romy" + +echo "extension defaults smoke test passed" diff --git a/gnome-user-switcher-extension/Makefile b/gnome-user-switcher-extension/Makefile new file mode 100644 index 0000000..f378458 --- /dev/null +++ b/gnome-user-switcher-extension/Makefile @@ -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)" diff --git a/gnome-user-switcher-extension/README.md b/gnome-user-switcher-extension/README.md new file mode 100644 index 0000000..08395d4 --- /dev/null +++ b/gnome-user-switcher-extension/README.md @@ -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. diff --git a/gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip b/gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip new file mode 100644 index 0000000..06e68bd Binary files /dev/null and b/gnome-user-switcher-extension/dist/user-switcher@felixfoertsch.de.shell-extension.zip differ diff --git a/gnome-user-switcher-extension/extension.js b/gnome-user-switcher-extension/extension.js new file mode 100644 index 0000000..fab0f87 --- /dev/null +++ b/gnome-user-switcher-extension/extension.js @@ -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; + } +} diff --git a/gnome-user-switcher-extension/locale/de/LC_MESSAGES/user-switcher.po b/gnome-user-switcher-extension/locale/de/LC_MESSAGES/user-switcher.po new file mode 100644 index 0000000..dbbe41f --- /dev/null +++ b/gnome-user-switcher-extension/locale/de/LC_MESSAGES/user-switcher.po @@ -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." diff --git a/gnome-user-switcher-extension/locale/en/LC_MESSAGES/user-switcher.po b/gnome-user-switcher-extension/locale/en/LC_MESSAGES/user-switcher.po new file mode 100644 index 0000000..abb3ed9 --- /dev/null +++ b/gnome-user-switcher-extension/locale/en/LC_MESSAGES/user-switcher.po @@ -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." diff --git a/gnome-user-switcher-extension/locale/es/LC_MESSAGES/user-switcher.po b/gnome-user-switcher-extension/locale/es/LC_MESSAGES/user-switcher.po new file mode 100644 index 0000000..2b05c5b --- /dev/null +++ b/gnome-user-switcher-extension/locale/es/LC_MESSAGES/user-switcher.po @@ -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." diff --git a/gnome-user-switcher-extension/locale/fr/LC_MESSAGES/user-switcher.po b/gnome-user-switcher-extension/locale/fr/LC_MESSAGES/user-switcher.po new file mode 100644 index 0000000..14293e3 --- /dev/null +++ b/gnome-user-switcher-extension/locale/fr/LC_MESSAGES/user-switcher.po @@ -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." diff --git a/gnome-user-switcher-extension/metadata.json b/gnome-user-switcher-extension/metadata.json new file mode 100644 index 0000000..0b80cc4 --- /dev/null +++ b/gnome-user-switcher-extension/metadata.json @@ -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" +} diff --git a/gnome-user-switcher-extension/scripts/build-release.sh b/gnome-user-switcher-extension/scripts/build-release.sh new file mode 100755 index 0000000..01a4eb9 --- /dev/null +++ b/gnome-user-switcher-extension/scripts/build-release.sh @@ -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" diff --git a/gnome-user-switcher-extension/tests/release-smoke.sh b/gnome-user-switcher-extension/tests/release-smoke.sh new file mode 100755 index 0000000..6eed345 --- /dev/null +++ b/gnome-user-switcher-extension/tests/release-smoke.sh @@ -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" diff --git a/gnome-user-switcher-extension/tests/smoke.sh b/gnome-user-switcher-extension/tests/smoke.sh new file mode 100755 index 0000000..9a362a3 --- /dev/null +++ b/gnome-user-switcher-extension/tests/smoke.sh @@ -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"