#!/bin/sh # WaySwitch — установка одной командой для любого дистрибутива: # curl -fsSL https://wayswitch.wetoshkin.ru/install | sh # Удаление: # curl -fsSL https://wayswitch.wetoshkin.ru/install | sh -s -- --uninstall # # Запускать от обычного пользователя, не через sudo: пароль спросят только на # шаге установки пакета. Без подтверждения ничего не ставится (--yes — # не спрашивать). Повторный запуск обновляет WaySwitch до последней версии. # Для проверки на своих исходниках: WAYSWITCH_SRC=/путь/к/WaySwitch. # # Скрипт — POSIX sh (в Debian и Ubuntu это dash): без bash-конструкций. set -eu REPO="Wetoshkin/WaySwitch" API="https://api.github.com/repos/$REPO/releases?per_page=1" # включает альфа-релизы, в отличие от /latest SOURCE_UNINSTALL=/usr/local/share/wayswitch/uninstall.sh # кладёт packaging/install.sh MODE="install" YES=0 for arg in "$@"; do case "$arg" in --uninstall) MODE="uninstall" ;; --yes|-y) YES=1 ;; esac done case "${LANG:-}${LC_ALL:-}" in ru*|*ru_*) RU=1 ;; *) RU=0 ;; esac say() { if [ "$RU" = 1 ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; } step() { printf '\n\033[1m==> '; say "$1" "$2"; printf '\033[0m'; } warn() { printf '\033[33m! '; say "$1" "$2"; printf '\033[0m'; } die() { { printf '\033[31m✗ '; say "$1" "$2"; printf '\033[0m'; } >&2; exit 1; } ask() { # вопрос да/нет; читаем с терминала, потому что stdin занят самим скриптом # $3 = no — ответ по умолчанию «нет» (для удаления: Enter не должен удалять) [ "$YES" = 1 ] && return 0 # /dev/tty есть всегда, но без управляющего терминала (cron, контейнер) # не открывается — проверяем открытием, а не наличием. # Подоболочка: в dash ошибка перенаправления у «:» завершила бы весь скрипт. if ! (true /dev/null; then die "Нет терминала, чтобы спросить подтверждение. Запустите с --yes, если уверены." \ "No terminal to ask for confirmation. Run with --yes if you are sure." fi if [ "${3:-}" = no ]; then printf '%s ' "$( [ "$RU" = 1 ] && echo "$1 [д/Н]" || echo "$2 [y/N]" )" else printf '%s ' "$( [ "$RU" = 1 ] && echo "$1 [Д/н]" || echo "$2 [Y/n]" )" fi read -r a /dev/null 2>&1; } is_gnome() { case "${XDG_CURRENT_DESKTOP:-}" in *COSMIC*|*cosmic*|*Cosmic*) return 1 ;; *GNOME*) return 0 ;; *) return 1 ;; esac } native_gui_pid() ( [ "$ATOMIC" != 1 ] && [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] && [ -z "${SSH_CONNECTION:-}${SSH_TTY:-}" ] || return 1 mode=${1:-} if [ -z "$mode" ] || [ "$mode" = --all ]; then reply=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus \ ListNames 2>/dev/null) || return 1 # busctl quotes names; split without eval or pathname expansion. set -f set -- $reply [ "$#" -ge 2 ] && [ "$1" = as ] || return 1 count=$2 shift 2 [ "$#" = "$count" ] || return 1 found=1 for token do case "$token" in \"*\") name=${token#\"}; name=${name%\"} ;; *) continue ;; esac record=$(native_gui_pid "$name") || continue if [ "$mode" = --all ]; then printf '%s\n' "$record" found=0 else printf '%s' "${record##* }" return 0 fi done return "$found" fi name=$mode case "$name" in *[!a-zA-Z0-9_.-]*|.WaySwitch.Settings) return 1 ;; esac case "$name" in *.WaySwitch.Settings) ;; *) return 1 ;; esac reply=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus \ GetNameOwner s "$name" 2>/dev/null) || return 1 case "$reply" in 's ":'*'"') owner=${reply#s \"}; owner=${owner%\"} ;; *) return 1 ;; esac unique=${owner#:} bus=${unique%%.*}; serial=${unique#*.} [ "$unique" != "$serial" ] && [ -n "$bus" ] && [ -n "$serial" ] || return 1 case "$bus$serial" in *[!0-9]*) return 1 ;; esac reply=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus \ GetConnectionUnixProcessID s "$owner" 2>/dev/null) || return 1 case "$reply" in 'u '[1-9]*) pid=${reply#u } ;; *) return 1 ;; esac case "$pid" in *[!0-9]*) return 1 ;; esac uid=$(ps -o uid= -p "$pid" 2>/dev/null) || return 1 [ "$(printf '%s' "$uid" | tr -d ' ')" = "$(id -u)" ] || return 1 reply=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus \ GetNameOwner s "$name" 2>/dev/null) || return 1 [ "$reply" = "s \"$owner\"" ] || return 1 printf '%s %s %s\n' "$name" "$owner" "$pid" ) stop_gui() { records=$(native_gui_pid --all) || return 1 signaled=" " while read -r name owner pid; do [ "$(native_gui_pid "$name")" = "$name $owner $pid" ] || continue case "$signaled" in *" $pid "*) continue ;; esac kill "$pid" 2>/dev/null || return 1 signaled="$signaled$pid " done <&2 return 1 } curl -fsSL -o "$TMP/release.json" "$API" || return 1 python3 -I - "$TMP" "$1" "$REPO" <<'PYMETA' import json import re import sys from pathlib import Path from urllib.parse import quote def unique_object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError("duplicate JSON key") result[key] = value return result try: folder, extension, repo = sys.argv[1:] releases = json.loads((Path(folder) / "release.json").read_text(), object_pairs_hook=unique_object) if not isinstance(releases, list) or not releases: raise ValueError("missing release") release = releases[0] tag = release["tag_name"] assets = release["assets"] if not isinstance(tag, str) or not tag or not isinstance(assets, list): raise ValueError("invalid release") if any(not isinstance(a, dict) or not isinstance(a.get("name"), str) for a in assets): raise ValueError("invalid assets") matches = [a for a in assets if a["name"].endswith("." + extension)] if not matches: sys.exit(2) if len(matches) != 1: raise ValueError("ambiguous package") asset = matches[0] name = asset["name"] if not re.fullmatch(r"[A-Za-z0-9_.+~-]+", name): raise ValueError("invalid asset name") url = asset["browser_download_url"] expected = f"https://github.com/{repo}/releases/download/{quote(tag, safe='')}/{name}" digest = asset["digest"] size = asset["size"] if url != expected or not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): raise ValueError("invalid URL or digest") if type(size) is not int or size <= 0: raise ValueError("invalid size") (Path(folder) / "package.meta").write_text(f"{url}\n{digest[7:]}\n{size}\n", newline="\n") except (ValueError, KeyError, TypeError, OSError): sys.exit(1) PYMETA rc=$? [ "$rc" = 0 ] || return "$rc" { read -r url; read -r digest; read -r size; } < "$TMP/package.meta" package="$TMP/package.$1" curl -fL --progress-bar -o "$package" "$url" || return 1 python3 -I - "$package" "$digest" "$size" <<'PYVERIFY' import hashlib import sys from pathlib import Path try: path = Path(sys.argv[1]) digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) valid = path.stat().st_size == int(sys.argv[3]) and digest.hexdigest() == sys.argv[2] except (OSError, ValueError): valid = False sys.exit(0 if valid else 1) PYVERIFY [ "$?" = 0 ] || return 1 printf '%s' "$package" } get_package() { # $1 = deb | rpm; путь в $PKG, пусто — ставить из исходников PKG=$(fetch "$1") && rc=0 || rc=$? case "$rc" in 0) ;; 2) PKG="" [ "$ATOMIC" = 1 ] && die "В последнем релизе нет пакета .$1 — на этой системе без него не поставить. Попробуйте позже." "The latest release has no .$1 package; this system cannot install without it. Try again later." warn "В последнем релизе нет пакета .$1 — ставлю из исходников." "The latest release has no .$1 package — installing from source." ;; *) die "Не удалось скачать и проверить SHA256 пакета GitHub. Установка остановлена; проверьте сеть и метаданные релиза." \ "Could not download and verify the GitHub package SHA256. Installation stopped; check the network and release metadata." ;; esac } from_source() { # $1 = install.sh | uninstall.sh if [ -n "${WAYSWITCH_SRC:-}" ]; then sudo bash "$WAYSWITCH_SRC/packaging/$1" return fi have git || { case "$FAMILY" in arch) sudo pacman -S --needed --noconfirm git ;; *) die "Нужен git. Установите его менеджером пакетов и запустите снова." \ "git is required. Install it with your package manager and run again." ;; esac } git clone -q --depth 1 "https://github.com/$REPO.git" "$TMP/src" \ || die "Не удалось скачать исходники с GitHub (нет сети?)." \ "Could not download the source from GitHub (no network?)." sudo bash "$TMP/src/packaging/$1" } TRAY_AUTOSTART="${XDG_CONFIG_HOME:-$HOME/.config}/autostart/io.github.Wetoshkin.WaySwitch.Settings.desktop" migrate_user_identifiers() { for migration in /usr/share/wayswitch/migrate.py /usr/local/share/wayswitch/migrate.py; do [ -f "$migration" ] || continue python3 -I "$migration" "$@" || return 1 done } preserve_source_tray() { python3 -I - "$1" "${2:-}" "$(dirname "$TRAY_AUTOSTART")" <<'PYTRAY' import configparser import os from pathlib import Path import shlex import stat import sys mode, backup, folder = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3]) def own_entry(path): if "wayswitch" not in path.name.lower(): return None try: before = path.lstat() if not stat.S_ISREG(before.st_mode): return None fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) with os.fdopen(fd, "rb") as stream: opened = os.fstat(stream.fileno()) if (not stat.S_ISREG(opened.st_mode) or (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino)): return None data = stream.read(16385) if len(data) > 16384: return None parser = configparser.ConfigParser(interpolation=None) parser.optionxform = str parser.read_string(data.decode("utf-8")) entry = parser["Desktop Entry"] command = shlex.split(entry.get("Exec", "")) if (not parser.defaults() and entry.get("Type") == "Application" and entry.get("Name") == "WaySwitch" and command and command[0] in {"wayswitch-gui", "/usr/bin/wayswitch-gui", "/usr/local/bin/wayswitch-gui"} and command[1:] in ([], ["--tray"])): return data except (OSError, UnicodeError, ValueError, KeyError, configparser.Error): pass return None try: if folder.is_symlink(): raise ValueError("unsafe autostart directory") source = backup if mode == "restore" else folder count = 0 for scanned, path in enumerate(source.glob("*.desktop")): if scanned >= 4096: raise ValueError("too many autostart files") data = own_entry(path) if data is None: continue count += 1 if count > 64: raise ValueError("too many WaySwitch entries") if mode == "remove": path.unlink() continue destination = backup if mode == "save" else folder destination.mkdir(parents=True, exist_ok=True) try: fd = os.open(destination / path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) except FileExistsError: continue with os.fdopen(fd, "wb") as stream: stream.write(data) except (OSError, ValueError): raise SystemExit("Could not preserve WaySwitch autostart entries") PYTRAY } drop_source_copy() { # копия из исходников мешает пакету: её служба и расширение перекрывают пакетные if [ -f "$SOURCE_UNINSTALL" ]; then say " Убираю прежнюю установку из исходников (/usr/local)…" \ " Removing the previous from-source installation (/usr/local)…" # Old uninstall scripts remove their own desktop names even in migration mode. tray_backup=$(mktemp -d "$TMP/tray.XXXXXX") || return 1 preserve_source_tray save "$tray_backup" || return 1 uninstall_status=0 sudo WAYSWITCH_MIGRATE=1 bash "$SOURCE_UNINSTALL" >/dev/null || uninstall_status=$? preserve_source_tray restore "$tray_backup" || return 1 rm -rf "$tray_backup" [ "$uninstall_status" = 0 ] || return "$uninstall_status" HAD=0 fi } aur_helper() { for h in paru yay; do have "$h" && { echo "$h"; return; }; done; } # Ставим из AUR, только если пакет wayswitch там ведёт автор: имя пакета может # занять кто угодно, а paru --noconfirm собрал бы чужой PKGBUILD от root. AUR_MAINTAINER=Wetoshkin aur_is_ours() { have curl || return 1 curl -fsSL "https://aur.archlinux.org/rpc/v5/info?arg[]=wayswitch" 2>/dev/null \ | grep -q "\"Maintainer\":\"$AUR_MAINTAINER\"" } enable_extension() { # $1 = uuid; GNOME: включить расширение сразу (подхватится после входа) is_gnome || return 0 gnome-extensions enable "$1" 2>/dev/null && return 0 have gsettings || return 0 cur=$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || echo "@as []") case "$cur" in *"$1"*) ;; "@as []"|"[]") gsettings set org.gnome.shell enabled-extensions "['$1']" 2>/dev/null ;; *) gsettings set org.gnome.shell enabled-extensions "${cur%]}, '$1']" 2>/dev/null ;; esac } A11Y_PREV="${XDG_CONFIG_HOME:-$HOME/.config}/wayswitch/a11y-prev" enable_a11y() { # мост доступности: без него не работают защита паролей и исключения программ have gsettings || return 0 # Установка из исходников сохраняет прежнее значение сама (install.sh). [ -f "/var/lib/wayswitch/a11y-prev-$(id -un)" ] && return 0 # Уже включали раньше — при обновлении не перебиваем то, что человек мог # выключить сам. [ -f "$A11Y_PREV" ] && return 0 key="org.gnome.desktop.interface toolkit-accessibility" prev=$(gsettings get $key 2>/dev/null) || return 0 mkdir -p "$(dirname "$A11Y_PREV")" && printf '%s\n' "$prev" > "$A11Y_PREV" gsettings set $key true 2>/dev/null || true } restore_a11y() { [ -f "$A11Y_PREV" ] && have gsettings || return 0 prev=$(cat "$A11Y_PREV") case "$prev" in true|false) gsettings set org.gnome.desktop.interface toolkit-accessibility "$prev" 2>/dev/null || true ;; esac rm -f "$A11Y_PREV" } check_adwaita_deb() { # окну нужна libadwaita 1.5+ (Ubuntu 24.04+, Debian 13+) have apt-cache || return 0 policy=$(LC_ALL=C apt-cache policy gir1.2-adw-1 2>/dev/null) || policy="" v=$(printf '%s\n' "$policy" | awk '/Candidate:/ {print $2}') if [ -z "$v" ] || [ "$v" = "(none)" ]; then die "Не удалось определить доступную версию libadwaita. Выполните sudo apt update, проверьте источники пакетов и запустите установку снова." \ "Cannot determine the available libadwaita version. Run sudo apt update, check your package sources, then try again." fi if ! dpkg --compare-versions "$v" ge 1.5; then die "Эта версия системы слишком старая: WaySwitch нужны Ubuntu 24.04 или новее (Debian 13+). Ничего не установлено. Обновите систему через «Обновление приложений» и запустите команду снова." \ "This system is too old: WaySwitch needs Ubuntu 24.04 or newer (Linux Mint 22+, Debian 13+)." fi } # --- Удаление ---------------------------------------------------------------- if [ "$MODE" = uninstall ]; then step "Удаляю WaySwitch (${PRETTY_NAME:-$ID})" "Removing WaySwitch (${PRETTY_NAME:-$ID})" ask "Удалить WaySwitch?" "Remove WaySwitch?" no || exit 0 need_sudo systemctl --user disable --now wayswitch 2>/dev/null || true migrate_user_identifiers --uninstall || true if have dpkg && dpkg -s wayswitch >/dev/null 2>&1; then sudo apt purge -y wayswitch elif [ "$ATOMIC" = 1 ] && rpm -q wayswitch >/dev/null 2>&1; then sudo rpm-ostree uninstall wayswitch elif have rpm && rpm -q wayswitch >/dev/null 2>&1; then if have dnf; then sudo dnf remove -y wayswitch; else sudo zypper --non-interactive remove wayswitch; fi elif have pacman && pacman -Q wayswitch >/dev/null 2>&1; then sudo pacman -Rns --noconfirm wayswitch fi if [ -f "$SOURCE_UNINSTALL" ]; then sudo bash "$SOURCE_UNINSTALL" elif [ -n "${WAYSWITCH_SRC:-}" ]; then from_source uninstall.sh fi preserve_source_tray remove restore_a11y stop_gui || true # окно и значок удалённой программы больше не нужны say "Готово. Настройки остались в ~/.config/wayswitch — удалите папку, если они не нужны." \ "Done. Settings are kept in ~/.config/wayswitch — delete it if you don't need them." exit 0 fi # --- Проверка окружения -------------------------------------------------------- step "Проверяю систему" "Checking the system" say " Дистрибутив: ${PRETTY_NAME:-$ID}" " Distribution: ${PRETTY_NAME:-$ID}" case "${XDG_CURRENT_DESKTOP:-}" in *COSMIC*|*cosmic*|*Cosmic*) say " Рабочий стол: COSMIC" " Desktop: COSMIC" ;; *GNOME*) say " Рабочий стол: GNOME" " Desktop: GNOME" ;; *KDE*) say " Рабочий стол: KDE Plasma" " Desktop: KDE Plasma" ;; *Hyprland*) say " Композитор: Hyprland" " Compositor: Hyprland" ;; *sway*|*Sway*) say " Композитор: Sway" " Compositor: Sway" ;; *XFCE*) say " Рабочий стол: Xfce" " Desktop: Xfce" ;; *MATE*) say " Рабочий стол: MATE" " Desktop: MATE" ;; *Cinnamon*) warn "Рабочий стол Cinnamon (Linux Mint) WaySwitch пока не поддерживает: установить можно, но слова, скорее всего, исправляться не будут." \ "The Cinnamon desktop (Linux Mint) is not supported yet: you can install, but words will most likely not be fixed." ;; *Budgie*) say " Рабочий стол: Budgie" " Desktop: Budgie" ;; *LXQt*) say " Рабочий стол: LXQt" " Desktop: LXQt" ;; *) warn "Рабочий стол «${XDG_CURRENT_DESKTOP:-неизвестно}»: WaySwitch его не знает. Поддерживаются GNOME, COSMIC, KDE, Xfce, MATE, Cinnamon, Budgie, LXQt, Sway, Hyprland." \ "Desktop \"${XDG_CURRENT_DESKTOP:-unknown}\": WaySwitch does not know it. Supported: GNOME, COSMIC, KDE, Xfce, MATE, Cinnamon, Budgie, LXQt, Sway, Hyprland." ;; esac NOT_WAYLAND=0 if [ "${XDG_SESSION_TYPE:-}" != wayland ]; then NOT_WAYLAND=1 warn "Сейчас включён старый режим графики (${XDG_SESSION_TYPE:-неизвестно}) — в нём WaySwitch работать НЕ будет. Как переключить: выйдите из системы, на экране входа нажмите на своё имя, затем на значок шестерёнки в правом нижнем углу и выберите «Ubuntu» или «GNOME» (не «on Xorg»); в KDE — «Plasma (Wayland)». Потом запустите эту команду снова." \ "The old graphics mode (${XDG_SESSION_TYPE:-unknown}) is on — WaySwitch will NOT work in it. Log out, click your name on the login screen, then the gear icon at the bottom right, and pick a session without «Xorg»; in KDE — «Plasma (Wayland)». Then run this command again." fi case "$FAMILY" in deb|fedora|suse) have curl || die "Нужен curl: установите его менеджером пакетов и запустите снова." \ "curl is required: install it with your package manager and run again." ;; esac [ "$FAMILY" = deb ] && check_adwaita_deb h="" case "$FAMILY" in deb) PLAN_RU="скачать пакет .deb и установить через apt"; PLAN_EN="download the .deb package and install it with apt" ;; fedora) if [ "$ATOMIC" = 1 ]; then PLAN_RU="скачать пакет .rpm и добавить через rpm-ostree (нужна перезагрузка)"; PLAN_EN="download the .rpm and layer it with rpm-ostree (reboot required)" else PLAN_RU="установить пакет .rpm через dnf"; PLAN_EN="install the .rpm package with dnf"; fi ;; suse) PLAN_RU="скачать пакет .rpm и установить через zypper"; PLAN_EN="download the .rpm package and install it with zypper" ;; arch) h=$(aur_helper) if [ -n "$h" ] && aur_is_ours; then PLAN_RU="установить пакет wayswitch из AUR через $h"; PLAN_EN="install the wayswitch AUR package with $h" else h=""; PLAN_RU="собрать из исходников (git + packaging/install.sh)"; PLAN_EN="install from source (git + packaging/install.sh)"; fi ;; *) PLAN_RU="установить из исходников (packaging/install.sh)"; PLAN_EN="install from source (packaging/install.sh)" ;; esac say " План: $PLAN_RU." " Plan: $PLAN_EN." if [ "$NOT_WAYLAND" = 1 ]; then ask "Всё равно установить?" "Install anyway?" || exit 0 else ask "Продолжить?" "Continue?" || exit 0 fi # --- Установка --------------------------------------------------------------- # Обновление: автозапуск службы, выключенный человеком, не включаем снова. WAS=$(systemctl --user is-enabled wayswitch 2>/dev/null || true) # Уже стоял — значит, это обновление. Переход с исходников на пакет — нет: # uninstall.sh останавливает службу и убирает расширение, нужен новый вход. HAD=0; have wayswitch && HAD=1 # После обновления восстанавливаем работавший трей, окно открывается из меню. GUI_WAS=0 if gui_records=$(native_gui_pid --all); then while read -r gui_name gui_owner gui_pid; do [ "$(native_gui_pid "$gui_name")" = "$gui_name $gui_owner $gui_pid" ] || continue gui_args=$(ps -ww -o args= -p "$gui_pid" 2>/dev/null) || gui_args="" case " $gui_args " in *" --tray "*) GUI_WAS=1; break ;; esac done </dev/null 2>&1 \ && enable_extension appindicatorsupport@rgcjonas.gmail.com || true fi ;; fedora) get_package rpm if [ -z "$PKG" ]; then from_source install.sh elif [ "$ATOMIC" = 1 ]; then drop_source_copy if rpm -q wayswitch >/dev/null 2>&1; then # Обновление: старый наложенный пакет заменяем новым за один шаг. sudo rpm-ostree install -y --uninstall=wayswitch "$PKG" else sudo rpm-ostree install -y "$PKG"; fi else drop_source_copy; sudo dnf install -y "$PKG"; fi ;; suse) get_package rpm if [ -n "$PKG" ]; then drop_source_copy; sudo zypper --non-interactive install --allow-unsigned-rpm "$PKG" else from_source install.sh; fi ;; arch) if [ -n "$h" ]; then drop_source_copy; "$h" -S --needed --noconfirm wayswitch; else from_source install.sh; fi ;; *) from_source install.sh ;; esac systemctl --user daemon-reload >/dev/null 2>&1 || true [ "$WAS" = disabled ] || systemctl --user enable wayswitch >/dev/null 2>&1 || true migrate_user_identifiers || true # Обновление: работающая служба перечитает новый код. systemctl --user try-restart wayswitch >/dev/null 2>&1 || true enable_extension wayswitch@wetoshkin.github.io || true enable_a11y || true if stop_gui && [ "$GUI_WAS" = 1 ]; then # Значок — старый код в памяти: перезапускаем его на новой версии. if have setsid; then setsid wayswitch-gui --tray >/dev/null 2>&1 /dev/null 2>&1