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

6
README.md Normal file
View File

@@ -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`.

View File

@@ -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

View File

@@ -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
<uuid> <source>
```
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`).

View File

@@ -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 <<USAGE
Usage: sudo bash scripts/apply-extension-defaults.sh [options]
Options:
--extensions-file PATH Extensions list file (default: scripts/extensions.txt)
--users user1,user2 Override target users (default: kari,owen,romy)
--lock-default Lock dconf defaults
--refresh-existing Reinstall even if extension dir already exists
--dry-run Print actions without writing changes
-h, --help Show help
extensions.txt format:
<uuid> <source>
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 '<uuid> <source>'" >&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."

View File

@@ -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

View File

@@ -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"

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"