Полсотни вкладок, половина с несохранённым состоянием: черновики, веб-консоли, статьи «дочитаю вечером». Рука на автомате добивает Ctrl+W до конца группы, промахивается на Ctrl+Shift+W — всё, окно схлопнулось. Браузер не спросил.
Мне сейчас скажут про восстановление сессии и Ctrl+Shift+T. Оно возвращает адреса вкладок, но не их содержимое. Набранный и не отправленный комментарий, состояние SPA, залогиненная веб-консоль, SSH-сессия в веб-терминале — ничего этого после восстановления нет, есть заново открытые URL.
Сам браузер умеет переспрашивать перед закрытием ровно в одном случае: когда идёт загрузка. Выглядит это так:

Этим я долго пользовался вручную: перед рискованными манёврами ставил какую-нибудь загрузку на паузу, и браузер становился незакрываемым. Работает, но каждый раз руками — надоело. Про «смени браузер, в Firefox это одна галочка» знаю сам, только переезжать с синхронизацией и десятилетними привычками ради одного рефлекса не хотелось.
Кончилось тем, что у меня systemd кормит браузер одной вечной загрузкой. Стоит это 0,4 МБ памяти, 64 КБ на диске и байт по loopback раз в пять минут. Убирается одной командой. Яндекс-специфики в решении нет, подойдёт любой Chromium.
Сначала я искал штатную настройку
browser://flags и настройки я перерыл без результата, поэтому пошёл спрашивать бинарник:
strings -n 8 /opt/yandex/browser/yandex_browser \ | grep -aE '^[a-z_]+\.[a-z_.]+$' \ | grep -aiE 'quit|close|warn|confirm|exit'
Среди prefs нашлась многообещающая пара:
ya.ui_spaces.warn_about_groups_on_browser_close ya.ui_spaces.warn_about_groups_on_window_close
В локалях (locales/ru.pak, обычный UTF-8, грепается питоном) под неё лежат готовые тексты: настройка «Предупреждать о закрытии окна с группами», диалог «Закрыть браузер? Группы и вкладки будут удалены», кнопки «Включить восстановление и закрыть браузер» и «Просто закрыть».
Дальше хуже. В справке Яндекса такой настройки нет: в разделе про группы вкладок перечислены пять опций, этой среди них не значится. На одноразовом профиле я выставил ya.ui_spaces.enabled = true и оба warn-ключа прямо в Preferences, запустил, закрыл окно. Закрылось молча. Интерфейс групп от ключа тоже не поднялся.
И даже если бы заработало — по текстам видно, что это предупреждение о потере несохранённых групп при выключенном восстановлении сессии, а не общее «вы уверены?».
Почему не расширение
Сначала полезно понять, что вообще способно заблокировать закрытие Chromium-браузера. Исходников Яндекс Браузера не существует в открытом доступе, так что я читал апстримный Chromium близкой версии — всё, что отсюда, ниже помечено «по коду». Смотреть надо в chrome/browser/ui/unload_controller.cc и chrome/browser/lifetime/browser_close_manager.cc. Механизма два:
beforeunloadво вкладках — диалог «Покинуть сайт?»;активные загрузки —
OkToCloseWithInProgressDownloads(), тот диалог со скриншота. Срабатывает при закрытии последнего окна (веткаkBrowserShutdown), на Linux блокирует любая незавершённая загрузка.
Есть ещё корпоративная политика, которая делает PWA-окно вообще незакрываемым, но это не про обычный браузер. Формы, WebRTC, полноэкранное видео закрытие не блокируют. Третьего механизма я в коде не нашёл, и поведение Яндекс Браузера в моих тестах этому не противоречило.
Перехват снаружи браузера тоже прикидывал. Хук в оконном менеджере видит только крестик и Alt+F4: Ctrl+Shift+W и пункт меню закрывают окно изнутри, до WM это не доходит. Глобальный захват хоткея через xbindkeys отберёт комбинацию у всех приложений сразу. Работать надо изнутри, а изнутри — два механизма, см. выше.
Магазинные расширения «confirm before closing» все сидят на beforeunload, и у него две проблемы:
диалог показывается только при sticky activation. Пока в защищаемой вкладке не было клика или нажатия клавиши, браузер закроет её молча. После запуска браузера ни у одной восстановленной вкладки активации нет, то есть защита отсутствует ровно тогда, когда нужна;
чтобы повесить обработчик на все вкладки, расширению нужен content script на
<all_urls>. Полный доступ к каждой странице, включая банк, ради диалога подтверждения — спасибо, не надо. Вbrowser://*и PDF-вьюер он всё равно не попадает.
Отдельный сюрприз: --load-extension Яндекс Браузер молча игнорирует (брендированный Chrome со 137-й версии делает так же). Проверил: после запуска с флагом в профиле лежат только четыре встроенных расширения. Распакованное ставится только руками через режим разработчика.
Остаются загрузки. То есть мой ручной трюк, который надо автоматизировать.
Какой должна быть загрузка-сторож
Существует всегда, пока браузер запущен, без моего участия.
Никогда не завершается.
Ничего не стоит: ни памяти, ни диска, ни трафика.
Не зависит от интернета — в самолёте тоже работает.
Не мусорит ни в списке загрузок, ни в истории, ни в
Downloads.
Заголовков мало
Первая версия сервера была наивной: на любой запрос отвечаем заголовками с большим Content-Length и молчим. Браузер увидит бесконечную загрузку, закрытие заблокируется.
Не увидит. Вкладка крутит спиннер, список загрузок пустой. Незарегистрированная загрузка не блокирует ничего. Оказалось, браузер оформляет загрузку только после первого блока данных. Добавил к заголовкам 64 КБ нулей — в списке появилось честное «close-guard.bin — 0/1 ГБ». Дальше соединение просто держится открытым, раз в пять минут уходит один байт. Байт — это страховка от таймаутов простоя; есть ли такой таймаут на самом деле, я проверять не стал, страховка бесплатная.

Сервер целиком:
#!/bin/sh # serve.sh — отдаёт "вечную" загрузку: браузер считает её активной # и при закрытии последнего окна показывает диалог подтверждения. printf 'HTTP/1.1 200 OK\r\n' printf 'Content-Type: application/octet-stream\r\n' printf 'Content-Length: 1073741824\r\n' printf 'Accept-Ranges: bytes\r\n' printf 'Content-Disposition: attachment; filename="close-guard.bin"\r\n' printf '\r\n' # Первый блок обязателен: по одним заголовкам браузер загрузку не оформляет. dd if=/dev/zero bs=65536 count=1 2>/dev/null # Держим соединение. Байт раз в 5 минут — страховка от таймаутов простоя. while :; do sleep 300 printf '\0' || exit 0 done
Сокетов тут нет. Слушать порт будет systemd.
systemd, socket activation и два несработавших disarm
Держать постоянный демон ради одного соединения жалко. Это ровно тот случай, для которого придуман socket activation с Accept=yes: systemd слушает порт сам, на входящее соединение запускает скрипт и отдаёт ему сокет прямо в stdin/stdout.
# ~/.config/systemd/user/close-guard.socket [Unit] Description=Источник сторожевой загрузки (защита браузера от закрытия) [Socket] ListenStream=127.0.0.1:47311 Accept=yes [Install] WantedBy=sockets.target
Про порт отвечу сразу, не дожидаясь комментариев. Сервер из соединения ничего не читает и отдаёт нули: стучаться в 127.0.0.1:47311 может кто угодно, максимум он запустит лишнюю пару sh+sleep на 0,4 МБ. Занять порт чужим исходящим соединением теоретически можно, 47311 лежит в ip_local_port_range. На практике socket-юнит встаёт при логине, когда эфемерные порты ещё свободны; не повезёт — число меняется в двух местах, socket-юните и arm.sh.
# ~/.config/systemd/user/close-guard@.service [Unit] Description=Сторожевая загрузка (соединение %i) [Service] ExecStart=%h/.local/share/close-guard/serve.sh StandardInput=socket StandardOutput=socket StandardError=null # Соединение оборвалось: если браузер ещё жив — вооружить заново (самолечение). ExecStopPost=/usr/bin/systemctl --user start --no-block close-guard-rearm.service
В простое не запущено ничего, порт держит systemd. Под нагрузкой — один sh и один sleep.
Теперь автозапуск. Признак запущенного браузера — файл SingletonSocket в профиле. Соседний SingletonLock, кстати, не подходит: это висячий симлинк вида hostname-PID, stat() на нём падает. systemd умеет ждать файлы:
# ~/.config/systemd/user/close-guard-arm.path [Unit] Description=Ждать запуска браузера, чтобы вооружить защиту [Path] PathExists=%h/.config/yandex-browser/SingletonSocket Unit=close-guard-arm.service [Install] WantedBy=default.target
Скрипт arm.sh при срабатывании просит уже запущенный браузер открыть сторожевой URL. Ключевое слово «запущенный»: сам браузер скрипт не стартует никогда, иначе выйдет некромант — ты браузер закрыл, а он воскрес.
На .path я попался. PathExists= активирует юнит, пока путь существует, а не в момент появления файла — это написано в man systemd.path, но кто ж его читает. Юнит отработал и вышел, файл на месте, systemd запускает опять. В журнале это выглядело как вооружение по кругу каждые восемь секунд. Вылечилось тем, что юнит перестал выходить: Type=simple, в конце скрипта цикл, который спит, пока жив браузер. Активный юнит повторно не триггерится.
Самолечение досталось бесплатно: соединение оборвалось по любой причине — ExecStopPost инстанса дёргает close-guard-rearm.service, тот проверяет, жив ли браузер, и вооружает заново.
С этим самолечением я потом дважды воевал, оба раза из-за команды disarm. Сначала она не работала, потому что ExecStopPost честно чинил то, что я сам руками сломал — завёл флаг разоружения, который arm.sh уважает. Потом она не заработала снова, причём уже без всякого rearm: браузер видел обрыв возобновляемой загрузки (Accept-Ranges: bytes) и переподключался к живому сокету сам. Теперь disarm глушит заодно и сокет, а arm поднимает его обратно.
Последнее — уборка. Пока загрузка активна, в каталоге загрузок лежит её временный файл на 64 КБ («Не подтверждено NNN.~»). Закрыл браузер через диалог — браузер удаляет файл сам, это проверено. Убил жёстко — файл остаётся, поэтому arm.sh запоминает путь и подчищает хвост при следующем вооружении. В историю загрузок (таблица downloads в SQLite-базе History) сторожевые загрузки не пишутся вовсе, смотрел напрямую: ноль строк.
Вся конструкция:
браузер запустился (появился SingletonSocket) │ ▼ ┌──────────────────────┐ ┌───────────────────────────┐ │ close-guard-arm.path │──▶│ close-guard-arm.service │ вооружить и ждать, └──────────────────────┘ │ arm.sh 8 hold │ пока браузер жив └─────────────┬─────────────┘ │ yandex-browser http://127.0.0.1:47311/close-guard.bin ▼ ┌──────────────────────┐ accept() ┌───────────────────────────┐ │ close-guard.socket │───────────▶│ close-guard@N.service │──▶ 64 КБ + байт раз в 5 мин │ 127.0.0.1:47311 │ │ serve.sh │ └──────────────────────┘ └─────────────┬─────────────┘ │ соединение оборвалось ▼ (ExecStopPost) ┌───────────────────────────┐ │ close-guard-rearm.service │──▶ браузер жив? вооружить снова └───────────────────────────┘
N в имени инстанса на самом деле длиннее: при Accept=yes systemd подставляет номер соединения и адреса пар, получается close-guard@42-127.0.0.1:47311-….service. Поэтому в командах везде глоб close-guard@*.service.
Тесты, которые чуть не убили то, что защищали
Эксперименты шли на одноразовых профилях (--user-data-dir во временном каталоге) под отдельным Xvfb-дисплеем, чтобы не трогать рабочий браузер. Казалось бы, полная изоляция. Ага, сейчас.
Трей. Xvfb изолирует экран, но не DBus. Тестовые инстансы исправно регистрировали иконки в настоящем трее, в какой-то момент в панели висели четыре жёлтых «Я». Лечится dbus-run-session: каждому тестовому браузеру своя session-шина.
earlyoom. Тут я отличился: на радостях запустил четыре тестовых инстанса параллельно. Машина с 16 ГБ, earlyoom настроен агрессивно, порог 8% доступной памяти. Память просела, и SIGTERM прилетел рендереру рабочего браузера. Защита от потери вкладок начала карьеру с убийства вкладки. Дальше тесты шли по одному и только в systemd-run --user --scope -p MemoryMax=900M: не хватает памяти — умирает тест, а не чужое.
pkill -f. Дважды за вечер убил собственную шелл-сессию командой вида pkill -f 'yandex_browser.*scratchpad'. Паттерн с -f матчится по всей командной строке, в том числе по строке самой команды pkill, которую только что набрал. Убивать тестовые процессы надо по явному списку PID, исключив собственное дерево.
xdotool windowclose. Мой первый «успешный» тест защиты оказался враньём. windowclose не просит окно закрыться, а разрушает X-окно в обход браузера: процесс остаётся жить безголовым, и «браузер выжил» ничего не доказывает. Честно закрывает wmctrl -ic — он просит оконный менеджер (_NET_CLOSE_WINDOW), а уже WM шлёт клиенту WM_DELETE_WINDOW, ровно как при клике по крестику. Ради этого в Xvfb пришлось поднимать настоящий оконный менеджер: без него в цепочке некому работать.
Попутно выяснились две мелочи: Ctrl+Q в Яндекс Браузере не назначен вообще (нажатие не делает ничего), а Ctrl+W на последней вкладке окно не закрывает, просто остаётся Табло.
Что в итоге перехватывается
Путь закрытия | Результат |
|---|---|
Крестик окна / Alt+F4 | диалог, закрытие блокируется |
| диалог, закрытие блокируется |
Меню → «Закрыть браузер» | диалог, закрытие блокируется — по коду тот же путь через |
| не назначен, ничего не происходит |
| окно не закрывается — остаётся Табло |
Выключение ПК / логаут | не блокируется: SIGTERM гасит браузер за секунду |
Последняя строка — не дыра. При завершении сессии Chromium пропускает все подтверждения сознательно (по коду browser_shutdown::ShouldIgnoreUnloadHandlers()), чтобы выключение машины не висело на модальном диалоге. Сессия потом восстанавливается как после обычной перезагрузки.
Ограничения:
диалог появляется при закрытии последнего окна; если окон два, первое закроется молча — так устроен сам Chromium;
в панели постоянно горит иконка загрузок, в списке висит строка «close-guard.bin, 0/1 ГБ». Такая цена;
штатное закрытие теперь на один клик длиннее: тот же диалог, кнопка «Закрыть браузер». Собственно, ради этого всё и затевалось;
«Отмена» в диалоге защиту не снимает, загрузка продолжает висеть.
Установка
Пять юнитов, два скрипта, одна CLI-обёртка. Всё в домашнем каталоге, root не нужен.
mkdir -p ~/.local/share/close-guard ~/.local/bin ~/.config/systemd/user
~/.local/share/close-guard/serve.sh и три юнита уже были выше. К ним два маленьких сервиса:
# ~/.config/systemd/user/close-guard-arm.service [Unit] Description=Вооружение защиты браузера от случайного закрытия [Service] Type=simple ExecStart=%h/.local/share/close-guard/arm.sh 8 hold
# ~/.config/systemd/user/close-guard-rearm.service [Unit] Description=Восстановление сторожевой загрузки после обрыва [Service] Type=oneshot ExecStart=%h/.local/share/close-guard/arm.sh 6 TimeoutStartSec=60
Скрипт вооружения разросся из-за уборки временных файлов и флага разоружения, целиком он под спойлером.
arm.sh целиком
#!/bin/bash # arm.sh <задержка> [hold] — просит УЖЕ ЗАПУЩЕННЫЙ браузер начать сторожевую # загрузку. Сам браузер не запускает никогда. # hold — после вооружения держать юнит активным, пока браузер жив # (иначе close-guard-arm.path перезапускает нас по кругу). set -u PORT=47311 URL="http://127.0.0.1:$PORT/close-guard.bin" PROFILE="$HOME/.config/yandex-browser" BROWSER=$(command -v yandex-browser || command -v yandex-browser-stable) || true STATE="$HOME/.local/state/close-guard" LAST="$STATE/last-temp" DELAY="${1:-6}" HOLD="${2:-}" [ -n "${BROWSER:-}" ] || exit 0 mkdir -p "$STATE" # Флаг ручного разоружения: ставит команда disarm, снимает arm/on. # Новый запуск браузера (hold-режим) тоже снимает — «до следующего запуска». if [ "$HOLD" = hold ]; then rm -f "$STATE/disarmed" elif [ -e "$STATE/disarmed" ]; then exit 0; fi running() { [ -e "$PROFILE/SingletonSocket" ] && pgrep -x yandex_browser >/dev/null 2>&1; } armed() { ss -tnH state established "sport = :$PORT" 2>/dev/null | grep -q .; } dl_dir() { local d d=$(python3 -c "import json try: print(json.load(open('$PROFILE/Default/Preferences')).get('download',{}).get('default_directory') or '') except Exception: print('')" 2>/dev/null) [ -n "$d" ] && [ -d "$d" ] && { printf '%s\n' "$d"; return; } d=$(xdg-user-dir DOWNLOAD 2>/dev/null) [ -n "$d" ] && [ -d "$d" ] && { printf '%s\n' "$d"; return; } printf '%s\n' "$HOME/Downloads" } # После жёсткого выключения остаётся недокачанный временный файл. # Удаляем ровно тот, что запомнили, и только если он похож на наш по размеру. cleanup_prev() { [ -f "$LAST" ] || return 0 local f sz; f=$(cat "$LAST" 2>/dev/null) if [ -n "$f" ] && [ -f "$f" ]; then sz=$(stat -c %s "$f" 2>/dev/null || echo 0) if [ "$sz" -ge 65536 ] && [ "$sz" -le 131072 ]; then rm -f "$f"; fi fi rm -f "$LAST" } remember_temp() { local d f; d=$(dl_dir) f=$(find "$d" -maxdepth 1 -type f -newermt '-30 seconds' -size 65536c \ \( -name '*.~' -o -name '*.crdownload' \) -printf '%T@ %p\n' 2>/dev/null \ | sort -rn | head -1 | cut -d' ' -f2-) [ -n "$f" ] && printf '%s\n' "$f" > "$LAST" } hold() { [ "$HOLD" = hold ] || return 0 while [ -e "$PROFILE/SingletonSocket" ]; do sleep 30; done } sleep "$DELAY" running || exit 0 # браузер закрыт — защищать нечего # Источник могли остановить (disarm глушит его от авто-резюма браузера). # Если юнит включён в автозагрузку — поднимаем; выключен (off/uninstall) — выходим. if systemctl --user is-enabled --quiet close-guard.socket 2>/dev/null; then systemctl --user start close-guard.socket 2>/dev/null || exit 0 else exit 0 fi if armed; then hold; exit 0; fi cleanup_prev sleep 1 running || exit 0 # повторная проверка вплотную к вызову timeout 20 "$BROWSER" "$URL" >/dev/null 2>&1 sleep 5 remember_temp hold
CLI-обёртка close-guard
#!/bin/bash # ~/.local/bin/close-guard — управление защитой от случайного закрытия set -u PORT=47311 PROFILE="$HOME/.config/yandex-browser" SHARE="$HOME/.local/share/close-guard" STATE="$HOME/.local/state/close-guard" UNITS="$HOME/.config/systemd/user" running() { [ -e "$PROFILE/SingletonSocket" ] && pgrep -x yandex_browser >/dev/null 2>&1; } armed() { ss -tnH state established "sport = :$PORT" 2>/dev/null | grep -q .; } uc() { systemctl --user "$@"; } case "${1:-status}" in status) printf 'источник (socket): %s\n' "$(uc is-active close-guard.socket 2>/dev/null)" printf 'автозапуск (path): %s\n' "$(uc is-active close-guard-arm.path 2>/dev/null)" printf 'браузер запущен: %s\n' "$(running && echo да || echo нет)" printf 'защита вооружена: %s\n' "$(armed && echo ДА || echo нет)" [ -e "$STATE/disarmed" ] && echo 'разоружено вручную (до следующего запуска браузера)' ;; arm) rm -f "$STATE/disarmed" "$SHARE/arm.sh" 0 armed && echo "вооружено" || echo "не удалось (браузер запущен?)" ;; disarm) mkdir -p "$STATE"; touch "$STATE/disarmed" # сокет глушим тоже: иначе браузер сам возобновит загрузку (Accept-Ranges). # Юнит остаётся enabled — arm.sh поднимет его при следующем вооружении. uc stop close-guard.socket 'close-guard@*.service' 2>/dev/null echo "защита снята до следующего запуска браузера (вернуть раньше: close-guard arm)" ;; on) rm -f "$STATE/disarmed" uc enable --now close-guard.socket close-guard-arm.path "$SHARE/arm.sh" 0; echo "включено" ;; off) uc disable --now close-guard.socket close-guard-arm.path 'close-guard@*.service' 2>/dev/null echo "выключено" ;; uninstall) mkdir -p "$STATE"; touch "$STATE/disarmed" uc disable --now close-guard.socket close-guard-arm.path 2>/dev/null uc stop 'close-guard@*.service' 2>/dev/null sleep 8 # дать rearm-у, запущенному ExecStopPost, тихо выйти rm -f "$UNITS"/close-guard.socket "$UNITS"/close-guard@.service \ "$UNITS"/close-guard-arm.path "$UNITS"/close-guard-arm.service \ "$UNITS"/close-guard-rearm.service rm -rf "$SHARE" "$STATE" uc daemon-reload echo "удалено полностью" rm -f "$HOME/.local/bin/close-guard" # напоследок скрипт удаляет сам себя ;; *) echo "close-guard {status|arm|disarm|on|off|uninstall}"; exit 1 ;; esac
Обоим скриптам и обёртке нужен chmod +x; ~/.local/bin должен быть в PATH (в Mint и Ubuntu подхватывается из ~/.profile сам). Включение:
systemctl --user daemon-reload systemctl --user enable --now close-guard.socket close-guard-arm.path
Проверка простая: запустить браузер, подождать секунд десять, нажать крестик. Появился диалог с первого скриншота — работает. close-guard status показывает состояние, close-guard disarm снимает защиту до следующего запуска браузера, close-guard uninstall сносит всё.
Итого
Костыль, да. Но бинарники не патчатся, расширений с доступом ко всем страницам нет, интернет не нужен, обновления браузера решение переживает — механизм загрузок штатный и публичный. Удаляется одной командой. И оно автоматическое: от меня требуется только нажимать «Отмена».
Для Chrome или Vivaldi достаточно поменять в arm.sh пути к профилю и бинарнику, механика загрузок у всех Chromium общая.
Если знаете штатный флаг или политику, которые я пропустил, — напишите. Искал я честно: strings по бинарнику, ru.pak, browser://flags, список enterprise-политик из сборки. Нашёл только недокументированное предупреждение про группы, и оно про другое.
Проверял на yandex-browser-stable 26.6.1.1083, Linux Mint 22.3, X11. Wayland не проверял. На Windows механика загрузок та же, но вместо systemd придётся городить планировщик задач и PowerShell.
