Compare commits

...

5 Commits

Author SHA1 Message Date
grendervill 4d39002a1f docs(desktop): манифест обновлений, поведение при клике по уведомлению, замеры и открытые пункты
README обёртки приведён в соответствие с кодом:

- раздел «Автообновление»: раскладка каталога `UPDATES_DIR`, публикация релиза
  (`make desktop-release`, `make desktop-release-upload HOST=…`), зачем нужен
  `latest.json`, проверка через curl и лог обёртки; отдельно отмечено, что
  владельцу внешнего прокси добавлять ничего не нужно;
- раздел «Уведомления и переход в канал»: реализованное поведение по активации
  приложения и честное ограничение (ОС не сообщает, что активация вызвана
  кликом по уведомлению);
- раздел «Ресурсы и холодный старт»: замеры 708–740 мс и 252 МБ RSS, как
  повторить (`scripts/desktop-perf.py`);
- «Что осталось оператору»: точные команды для `.dmg`
  (`make desktop-build` из обычного терминала), подписи Developer ID и
  нотаризации (`APPLE_*`, `stapler validate`, `spctl`), сборки и подписи
  Windows/Linux, требования к ключу обновлений; отдельно — поведение на Wayland
  (нативная тряска окна не работает, остаётся CSS).
2026-09-22 23:17:48 +03:00
grendervill 72a51f083d test(desktop): замеры холодного старта и памяти обёртки
Требование спецификации §1/§7 (idle RAM ≤ 350 МБ, холодный старт ≤ 2 с) до сих
пор не было измерено. `scripts/desktop-perf.py` запускает собранный `.app`,
меряет время от `open` до строки «загружена страница» в логе обёртки и память
после того, как клиент успокоился.

`ps` и `top` в песочнице агента недоступны, поэтому на macOS память и список
процессов читаются через `libproc` (`proc_pid_rusage`, `proc_listpids`), а для
Linux есть ветка на `/proc`. Помощники WebKit (`WebContent`, `GPU`,
`Networking`) запускаются через launchd, их родитель — не приложение, поэтому
«свои» определяются сравнением списка процессов до и после запуска: иначе RSS
главного процесса занижал бы потребление втрое.

Замеры (macOS arm64, Mac на M-серии, инстанс через внешний прокси):
холодный старт 708–740 мс, RSS открытого клиента 252 МБ (обёртка 117 + WebKit
135), в трее 251 МБ, физический след 103 МБ. Результаты — в `docs/PERF.md` и
`desktop/README.md`.

В Makefile добавлена цель `desktop-build-app` (`tauri build --bundles app`):
артефакты обновления собираются без `.dmg`, который в песочнице и headless-
окружении падает на `bundle_dmg.sh` (монтирование образа + AppleScript).
2026-09-22 23:17:39 +03:00
grendervill 085454d555 feat(web): палитра команд Ctrl/Cmd+K и подтверждение выхода с несохранёнными правками
Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в
веб-слое — том же, что и в браузере:

- **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от
  обёртки) открывает быстрый переход: серверы (в первую доступную комнату),
  комнаты выбранного сервера с группировкой по категориям, личные беседы из уже
  загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида,
  настройки сервера, создание сервера, друзья, микрофон и «не слышу» при
  активной голосовой сессии). Новых запросов палитра не делает; поиск по
  подстроке без учёта регистра, 8 пунктов на раздел, стрелки со
  `scrollIntoView`, `role=listbox/option` и `aria-activedescendant`;

- **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем
  реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает
  один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки
  `closeRequested` клиент отвечает тряской контента и диалогом «Сохранить /
  Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет
  подсветка рамки; в браузере вместо сообщения обёртки работает штатный
  `beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»:
  профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки,
  лимиты инстанса, переименование сервера (автосохраняемые переключатели
  намеренно не помечаются);

- Deep link `glchat://dm/<channelId>` (личная беседа без сервера) — его
  присылает обёртка при клике по уведомлению о личном сообщении.

Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог
закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
2026-09-22 23:17:32 +03:00
grendervill 72c5189e8f feat(desktop): переход в комнату по уведомлению, выход без потери правок, Ctrl/Cmd+K
Три пункта спецификации клиента (docs/client-tauri.md §5), которые оставались
открытыми в обёртке:

- **Клик по уведомлению → переход в канал.** У `tauri-plugin-notification` на
  desktop нет колбэка действия, поэтому реализовано лучшее доступное поведение:
  уведомление, показанное при неактивном окне, запоминается, и когда приложение
  активируется в течение 10 секунд (окно получило фокус или macOS прислала
  `RunEvent::Reopen`), окно поднимается и открывается комната из уведомления —
  `glchat://guild/<id>/channel/<id>`, для личной беседы `glchat://dm/<id>`.
  Идентификаторы проверяются (только цифры), чтобы из текста уведомления нельзя
  было собрать произвольный deep link. Ограничение: ОС не сообщает, что
  активация вызвана именно кликом, поэтому переход сработает и при обычном
  возврате в приложение в эти 10 секунд — это описано в desktop/README.md;

- **Выход при несохранённых изменениях.** Клиент сообщает о правках командой
  `desktop_set_dirty`; закрытие окна и пункт «Выход» в трее перехватываются
  (`window::request_close_confirmation`), окно best-effort покачивается
  (`Window::set_position`, на Wayland молча пропускается), а решение принимает
  пользователь в диалоге клиента «Сохранить / Выйти без сохранения»; закрывает
  приложение команда `desktop_close_window`. Раньше выход из трея терял правки
  молча;

- **Ctrl/Cmd+K.** Комбинация регистрируется глобально (`shortcuts.rs`) и при
  неактивном окне поднимает окно и просит клиент открыть палитру команд
  (`WebMessage::OpenPalette`); повторы комбинаций с настраиваемыми шорткатами
  отсекаются по разобранной комбинации, иначе вторая регистрация падала бы.

Заодно: `updates.rs` получил тест, который разбирает манифест релиза типом
самого `tauri-plugin-updater` (`RemoteRelease`) — он ловит расхождение формата
(например, дату не в RFC 3339), из-за которого обновление молча не приходило бы.
Проверки: `make desktop-check` (fmt, clippy, 10 тестов).
2026-09-22 23:17:27 +03:00
grendervill 498201b413 feat(server): инстанс отдаёт манифест автообновления desktop-обёртки
Обёртка с первого инкремента Фазы 6 запрашивает
`GET /updates/{target}/{arch}/{current_version}`, но на инстансе этого пути не
было: запрос попадал в SPA-заглушку, клиент получал HTML с кодом 200 и писал
в лог «error decoding response body».

Что сделано:

- `internal/server/updates.go` — статика автообновления: каталог `UPDATES_DIR`
  (в контейнере `/app/updates:ro`), раскладка `<target>/<arch>/<version>.json` +
  артефакт и `.sig`; версия без расширения отображается на `<version>.json`,
  а если его нет — на `latest.json` (обёртка приходит со своей установленной
  версией, «новее ли релиз» решает клиент сравнением semver). Для имён
  с расширением артефакта запасного варианта нет: иначе вместо архива клиент
  скачал бы манифест и подпись не сошлась бы. Сегменты пути проверяются по
  символам, выход за каталог невозможен; `/updates/` добавлен в reservedPrefixes,
  поэтому отсутствующий манифест — честный 404, а не HTML-шелл;
- `scripts/desktop-release.sh` и цели `make desktop-release` /
  `make desktop-release-upload HOST=…`: берут собранный `glchat.app.tar.gz` + `.sig`,
  определяют target/arch по имени артефакта, копируют их в каталог инстанса и
  собирают манифест (версия из `tauri.conf.json`), объединяя платформы одной
  версии; есть `--dry-run` и загрузка на инстанс по SSH;
- `deploy/install.sh` и шаблоны compose: каталог `${GLCHAT_UPDATES_DIR}`
  (`/opt/glchat/updates`) создаётся установщиком и монтируется в контейнер
  только для чтения; в `.env` добавлен `GLCHAT_UPDATES_DIR`.

Владельцу внешнего прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения, как статика клиента.

Проверено на стенде: `curl https://gl.mhspx.su/updates/darwin/aarch64/0.1.0`
→ 200 `application/json; charset=utf-8`, артефакт → 200 `application/gzip`,
отсутствующие платформа и версия → 404 (не HTML), `readyz` → 200, обёртка
в логе пишет «обновлений нет» вместо ошибки разбора. Тесты: 6 новых в
`internal/server/updates_test.go`, манифест разбирается типом самого плагина
обновлений (`updates::tests::манифест_разбирается_плагином_обновлений`).
2026-09-22 23:17:22 +03:00
47 changed files with 3503 additions and 79 deletions
+26 -2
View File
@@ -191,6 +191,9 @@ web-build: ## Сборка web-клиента в web/dist
DESKTOP_DIR := $(ROOT)/desktop DESKTOP_DIR := $(ROOT)/desktop
DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri
DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key
# Бандлы Tauri: `all` — обычная сборка, `app` — только .app (и артефакты
# обновления) без .dmg: сборка .dmg монтирует образ и требует Finder.
DESKTOP_BUNDLES ?= all
TAURI_CLI ?= npx --yes @tauri-apps/cli@^2 TAURI_CLI ?= npx --yes @tauri-apps/cli@^2
export CARGO_HOME ?= $(CACHE_DIR)/cargo export CARGO_HOME ?= $(CACHE_DIR)/cargo
export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm
@@ -208,17 +211,38 @@ desktop-build: ## Собрать desktop-приложение (бандлы по
cd $(DESKTOP_TAURI_DIR) && \ cd $(DESKTOP_TAURI_DIR) && \
TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \ TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \ TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \
$(TAURI_CLI) build; \ $(TAURI_CLI) build --bundles $(DESKTOP_BUNDLES); \
else \ else \
echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \ echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \
cd $(DESKTOP_TAURI_DIR) && \ cd $(DESKTOP_TAURI_DIR) && \
$(TAURI_CLI) build --config '{"bundle":{"createUpdaterArtifacts":false}}'; \ $(TAURI_CLI) build --bundles $(DESKTOP_BUNDLES) \
--config '{"bundle":{"createUpdaterArtifacts":false}}'; \
fi fi
.PHONY: desktop-run .PHONY: desktop-run
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=… desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS) open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS)
.PHONY: desktop-build-app
desktop-build-app: ## Собрать .app и артефакты обновления без .dmg (dmg требует Finder/AppleScript)
@$(MAKE) --no-print-directory desktop-build DESKTOP_BUNDLES=app
# Каталог обновлений инстанса и его публичный адрес (docs/client-tauri.md §4).
# Раскладку делает scripts/desktop-release.sh: манифест версии + latest.json,
# артефакт и подпись; приложение отдаёт их по /updates/{target}/{arch}/{version}.
DESKTOP_UPDATES_DIR ?= /opt/glchat/updates
DESKTOP_BASE_URL ?= https://gl.mhspx.su
.PHONY: desktop-release
desktop-release: ## Разложить артефакты обновления в DESKTOP_UPDATES_DIR (нужен sudo)
bash scripts/desktop-release.sh --dir "$(DESKTOP_UPDATES_DIR)" \
--base-url "$(DESKTOP_BASE_URL)" $(ARGS)
.PHONY: desktop-release-upload
desktop-release-upload: ## Загрузить обновления на инстанс: make desktop-release-upload HOST=user@server
bash scripts/desktop-release.sh --remote "$(HOST)" \
--base-url "$(DESKTOP_BASE_URL)" $(ARGS)
.PHONY: docker-build .PHONY: docker-build
docker-build: ## Собрать образ приложения docker-build: ## Собрать образ приложения
docker build \ docker build \
+4
View File
@@ -15,6 +15,9 @@ x-app-env: &app-env
LISTEN_ADDR: "0.0.0.0:8080" LISTEN_ADDR: "0.0.0.0:8080"
DATA_DIR: "/app/data" DATA_DIR: "/app/data"
WEB_ROOT: "/app/web" WEB_ROOT: "/app/web"
# Каталог обновлений desktop-обёртки: манифесты и артефакты релиза
# (docs/client-tauri.md §4); смонтирован только для чтения.
UPDATES_DIR: "/app/updates"
LOG_LEVEL: "${LOG_LEVEL}" LOG_LEVEL: "${LOG_LEVEL}"
LOG_FORMAT: "json" LOG_FORMAT: "json"
SQLITE_READ_POOL: "${SQLITE_READ_POOL}" SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
@@ -39,6 +42,7 @@ services:
- "${GLCHAT_DATA_DIR}/db:/app/data/db" - "${GLCHAT_DATA_DIR}/db:/app/data/db"
- "${GLCHAT_DATA_DIR}/files:/app/data/files" - "${GLCHAT_DATA_DIR}/files:/app/data/files"
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups" - "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
- "${GLCHAT_UPDATES_DIR}:/app/updates:ro"
logging: *default-logging logging: *default-logging
deploy: deploy:
resources: resources:
+4
View File
@@ -16,6 +16,9 @@ x-app-env: &app-env
LISTEN_ADDR: "0.0.0.0:8080" LISTEN_ADDR: "0.0.0.0:8080"
DATA_DIR: "/app/data" DATA_DIR: "/app/data"
WEB_ROOT: "/app/web" WEB_ROOT: "/app/web"
# Каталог обновлений desktop-обёртки: манифесты и артефакты релиза
# (docs/client-tauri.md §4); смонтирован только для чтения.
UPDATES_DIR: "/app/updates"
LOG_LEVEL: "${LOG_LEVEL}" LOG_LEVEL: "${LOG_LEVEL}"
LOG_FORMAT: "json" LOG_FORMAT: "json"
SQLITE_READ_POOL: "${SQLITE_READ_POOL}" SQLITE_READ_POOL: "${SQLITE_READ_POOL}"
@@ -46,6 +49,7 @@ services:
- "${GLCHAT_DATA_DIR}/db:/app/data/db" - "${GLCHAT_DATA_DIR}/db:/app/data/db"
- "${GLCHAT_DATA_DIR}/files:/app/data/files" - "${GLCHAT_DATA_DIR}/files:/app/data/files"
- "${GLCHAT_DATA_DIR}/backups:/app/data/backups" - "${GLCHAT_DATA_DIR}/backups:/app/data/backups"
- "${GLCHAT_UPDATES_DIR}:/app/updates:ro"
logging: *default-logging logging: *default-logging
deploy: deploy:
resources: resources:
+14 -2
View File
@@ -122,6 +122,9 @@ GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}"
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
GLCHAT_ETC_DIR="$TARGET_DIR" GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data" GLCHAT_DATA_DIR="${TARGET_DIR}/data"
# Каталог обновлений desktop-обёртки: манифесты и артефакты раскладывает
# scripts/desktop-release.sh, приложение отдаёт их по /updates/… (docs/client-tauri.md §4).
GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR:-${TARGET_DIR}/updates}"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env" GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
@@ -273,6 +276,7 @@ parse_args() {
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)" TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
GLCHAT_ETC_DIR="$TARGET_DIR" GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data" GLCHAT_DATA_DIR="${TARGET_DIR}/data"
GLCHAT_UPDATES_DIR="${TARGET_DIR}/updates"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env" GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}" info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}"
@@ -390,6 +394,7 @@ resolve_derived_values() {
fi fi
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}" GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}"
GLCHAT_UPDATES_DIR="${GLCHAT_UPDATES_DIR}"
GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}" GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}"
GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}" GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}"
# TLS считается включённым и когда сертификаты обслуживает внешний прокси # TLS считается включённым и когда сертификаты обслуживает внешний прокси
@@ -413,7 +418,7 @@ resolve_derived_values() {
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \ export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
livekit_extra_codecs \ livekit_extra_codecs \
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \ PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
GLCHAT_DATA_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \ GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \ GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \ TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
TURN_RELAY_USER TURN_RELAY_PASSWORD TURN_RELAY_USER TURN_RELAY_PASSWORD
@@ -672,6 +677,10 @@ generate_secrets() {
mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups} mkdir -p "${GLCHAT_DATA_DIR}"/{db,files,backups}
chmod 700 "$GLCHAT_DATA_DIR" chmod 700 "$GLCHAT_DATA_DIR"
# Каталог обновлений монтируется в контейнер только для чтения: его
# наполняет релизный скрипт от root, приложению нужен лишь доступ на чтение.
mkdir -p "${GLCHAT_UPDATES_DIR}"
chmod 755 "${GLCHAT_UPDATES_DIR}"
# Каталоги монтируются в контейнер, который работает не от root (UID 10001): # Каталоги монтируются в контейнер, который работает не от root (UID 10001):
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3). # без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
set_data_ownership set_data_ownership
@@ -751,6 +760,7 @@ TLS_BY_PROXY=@TLS_BY_PROXY@
# --- интерполяция compose (пути, адреса привязки, профиль) --- # --- интерполяция compose (пути, адреса привязки, профиль) ---
GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@ GLCHAT_DATA_DIR=@GLCHAT_DATA_DIR@
GLCHAT_UPDATES_DIR=@GLCHAT_UPDATES_DIR@
GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@ GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@ GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@ GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
@@ -856,6 +866,7 @@ TLS_ENABLED=${TLS_ENABLED}
SKIP_TLS=${SKIP_TLS} SKIP_TLS=${SKIP_TLS}
TLS_BY_PROXY=${TLS_BY_PROXY} TLS_BY_PROXY=${TLS_BY_PROXY}
GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR} GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}
GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}
CADDY_PROFILE=${CADDY_PROFILE} CADDY_PROFILE=${CADDY_PROFILE}
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR} GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
GLCHAT_APP_BIND=${APP_BIND_ADDR} GLCHAT_APP_BIND=${APP_BIND_ADDR}
@@ -1113,7 +1124,8 @@ render_all_templates() {
"PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \ "PROFILE=${PROFILE_RESOLVED}" "APP_IMAGE=${APP_IMAGE}" \
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \ "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
"LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \ "LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}" "LIVEKIT_UDP_PORT=${LIVEKIT_UDP_PORT}" \
"GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "LOG_LEVEL=${LOG_LEVEL}" \ "GLCHAT_DATA_DIR=${GLCHAT_DATA_DIR}" "GLCHAT_UPDATES_DIR=${GLCHAT_UPDATES_DIR}" \
"LOG_LEVEL=${LOG_LEVEL}" \
"SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \ "SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}" \
"SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \ "SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000" \
"SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \ "SQLITE_MMAP_SIZE=$((PROFILE_SQLITE_MMAP_MB * 1024 * 1024))" \
+201 -7
View File
@@ -20,12 +20,18 @@ desktop/
```bash ```bash
make desktop-check # cargo fmt --check, clippy, тесты (кэш в .cache/) make desktop-check # cargo fmt --check, clippy, тесты (кэш в .cache/)
make desktop-build # бандлы текущей ОС (macOS: .app и .dmg) make desktop-build # бандлы текущей ОС (macOS: .app и .dmg)
make desktop-build-app # только .app + артефакты обновления, без .dmg
open desktop/src-tauri/target/release/bundle/macos/glchat.app open desktop/src-tauri/target/release/bundle/macos/glchat.app
``` ```
Требуется Rust 1.77+ и Node 22 (Tauri CLI ставится через `npx`). Кэши `cargo` и Требуется Rust 1.77+ и Node 22 (Tauri CLI ставится через `npx`). Кэши `cargo` и
`npm` держатся в `.cache/` — как у остальных инструментов проекта. `npm` держатся в `.cache/` — как у остальных инструментов проекта.
`make desktop-build-app` (`tauri build --bundles app`) нужен там, где `.dmg`
собрать нельзя: `bundle_dmg.sh` монтирует образ через `hdiutil` и управляет
Finder через AppleScript, поэтому из песочницы агента и из headless-окружения он
падает (см. «Что не сделано»).
Сборка релиза с артефактами автообновления использует ключ подписи: Сборка релиза с артефактами автообновления использует ключ подписи:
```bash ```bash
@@ -74,17 +80,205 @@ make desktop-build
Ссылка, пришедшая до загрузки клиента (холодный старт), ждёт в очереди и Ссылка, пришедшая до загрузки клиента (холодный старт), ждёт в очереди и
доставляется после `desktop_ready`. доставляется после `desktop_ready`.
## Автообновление
Обёртка проверяет обновления сама: фоновая проверка через 30 секунд после старта
и вручную — трей → «Проверить обновления…». Адрес манифеста —
`GET {instance}/updates/{target}/{arch}/{current_version}` (target:
`darwin`/`windows`/`linux`, arch: `aarch64`/`x86_64`; см. `src/updates.rs`).
Манифест отдаёт сам инстанс (`internal/server/updates.go`), отдельная статика в
Caddy не нужна: и встроенный Caddy профиля, и внешний прокси стенда проксируют
весь домен на приложение, поэтому `/updates/...` доходит до него без правок
чужих конфигов.
Раскладка каталога (`UPDATES_DIR`; на стенде `/opt/glchat/updates`, в контейнере
смонтирован как `/app/updates:ro`):
```
/opt/glchat/updates/
└── darwin/aarch64/
├── glchat.app.tar.gz # артефакт релиза (создаёт tauri build)
├── glchat.app.tar.gz.sig # подпись ed25519 (формат minisign)
├── 0.1.0.json # манифест версии
└── latest.json # манифест последнего релиза — его и отдаёт инстанс
```
Публикация релиза:
```bash
make desktop-build # собрать бандл (.app/.dmg) и артефакты обновления
sudo make desktop-release # разложить в /opt/glchat/updates + создать манифест
make desktop-release-upload HOST=user@server # то же, но по SSH (sudo спросит пароль в терминале)
make desktop-release ARGS='--dry-run' # посмотреть план, ничего не записывая
```
Скрипт `scripts/desktop-release.sh` берёт версию из `tauri.conf.json`, сам
определяет target/arch по имени артефакта, копирует артефакт и `.sig`, собирает
манифест (`version`, `notes`, `pub_date`, `platforms.<target>-<arch>.signature/url`)
и кладёт его и как `<version>.json`, и как `latest.json`. Несколько платформ
одной версии (macOS + Windows + Linux) складываются в один манифест: запускайте
скрипт по разу на артефакт.
`latest.json` нужен потому, что обёртка приходит со **своей установленной**
версией: «новее ли релиз» решает клиент сравнением semver
(`tauri-plugin-updater`), поэтому на запрос любой версии сервер отдаёт
последний манифест, а если есть манифест конкретной версии — его. Без этого
обновление не получил бы никто, кроме тех, у кого уже стоит последняя версия.
Владельцу прокси добавлять ничего не нужно: `/updates/...` уходит в
catch-all `handle` и доходит до приложения (так же, как статика клиента).
Вариант «раздавать обновления статикой мимо приложения» требует правила
`rewrite` на `latest.json` с подстановкой `target`/`arch` в Caddy — лишняя
работа без выгоды, поэтому не рекомендуется.
Проверка (стенд):
```bash
curl -s https://gl.mhspx.su/updates/darwin/aarch64/0.1.0 | jq .
# в логе обёртки (~/Library/Logs/su.mhspx.glchat/glchat.log) —
# «обновлений нет» (манифест разобран) вместо
# «проверка обновлений не удалась: … 404»
```
Порядок обновления: проверка → уведомление в интерфейсе → скачивание →
подтверждение в диалоге (во время звонка перезапуск без согласия не делается) →
подпись проверяется до установки, downgrade отклоняется.
## Уведомления и переход в канал
Решение «показывать ли уведомление» принимает веб-клиент (упоминания и личные
сообщения, окно неактивно или комната не открыта), обёртка показывает системное
уведомление и обновляет счётчик.
Клик по уведомлению: у `tauri-plugin-notification` на desktop нет колбэка
действия, поэтому реализовано лучшее доступное поведение — уведомление,
показанное при неактивном окне, запоминается, и когда приложение
активируется в течение 10 секунд (окно получило фокус или macOS прислала
`Reopen`), окно поднимается и открывается комната из уведомления
(`glchat://guild/<id>/channel/<id>`, для личной беседы `glchat://dm/<id>`).
Ограничение (честно): ОС не сообщает, что активация вызвана именно кликом по
уведомлению, поэтому переход сработает и при обычном возврате в приложение в
течение этих 10 секунд. Клик по уведомлению старше 10 секунд только поднимает
окно. Полноценный колбэк требует своего моста к `UNUserNotificationCenter`
(`UNUserNotificationCenterDelegate`) — отдельная задача.
## Ресурсы и холодный старт
Замеры собраны скриптом `scripts/desktop-perf.py` (macOS arm64, Mac на M-серии,
инстанс `gl.mhspx.su` через внешний прокси; подробности — `docs/PERF.md`):
| Метрика | Значение | Бюджет (docs/client-tauri.md §1) |
|---|---|---|
| Холодный старт (`open` → процесс → строка «загружена страница») | 708–740 мс (три прогона) | ≤ 2 с |
| Из них: запуск процесса | 45–74 мс | — |
| Из них: загрузка страницы клиента | 637–682 мс | — |
| RAM, клиент открыт и простаивает (RSS: обёртка + 4 процесса WebKit) | 252 МБ (117 + 135) | ≤ 350 МБ |
| RAM, окно свёрнуто в трей (`--minimized`) | 251 МБ | ≤ 350 МБ |
| Физический след (Activity Monitor) | 103 МБ | — |
Наблюдения:
- свёрнутое в трей окно памяти не освобождает: webview с загруженным клиентом
продолжает жить (так работают WKWebView и трей-режим) — экономия была бы
только при выгрузке страницы, а это потеря мгновенного возврата в клиент;
- первый запуск сразу после сборки бандла (холодный кэш файловой системы и
страницы) дал 1,26 с — тоже в бюджете;
- почти вся память — WebKit (страница клиента), сама обёртка занимает ~117 МБ.
Повторить замер:
```bash
make desktop-build-app # свежий .app (+ артефакты обновления)
python3 scripts/desktop-perf.py --runs 3 # 3 холодных старта + простой в трее
```
## Что не сделано (открытые пункты Фазы 6) ## Что не сделано (открытые пункты Фазы 6)
- **Манифест обновлений на сервере**: `GET /updates/{target}/{arch}/{current_version}` - **Автообновление end-to-end не проверено на реальном релизе**: манифест
ещё не публикуется — нужна статика на стороне инстанса (Caddy) и релизный отдаётся и разбирается (проверено на стенде), но установка «поверх старой
контур с приватным ключом в CI. Клиентская часть готова: подпись проверяется, версии» требует публикации версии с бо́льшим номером — это действие оператора
downgrade отклоняется, при недоступном манифесте ошибка только логируется. (см. «Публикация релиза» выше).
- **Клик по уведомлению** → открытие канала: у `tauri-plugin-notification` нет
колбэка действия на desktop, нужен свой мост к `UNUserNotificationCenter`.
- **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения. - **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения.
- **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных - **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных
экранов-инструкций у обёртки нет. экранов-инструкций у обёртки нет.
- **Подпись и нотаризация сборки** macOS/Windows: Developer ID/EV только в CI. - **Developer ID и нотаризация** macOS, EV/Trusted Signing для Windows — только
в CI с платными сертификатами (ниже — точные команды).
- **`.dmg`**: собирается только из обычного терминала (Finder/AppleScript).
- **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и - **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и
проверялись только macOS-arm64. проверялись только macOS-arm64.
## Что осталось оператору (точные команды)
### macOS: `.dmg`
`bundle_dmg.sh` монтирует образ (`hdiutil`) и управляет Finder через AppleScript,
поэтому из песочницы агента и из headless-сессии он падает. Из обычного
терминала на macOS:
```bash
cd /Volumes/Samsung/projects/glchat
make desktop-build # .app + .dmg + артефакты обновления
ls -lh desktop/src-tauri/target/release/bundle/dmg/
```
Артефакты без `.dmg` (для автообновления) собираются где угодно:
`make desktop-build-app`.
### macOS: подпись Developer ID и нотаризация
Нужны сертификат Developer ID Application в связке ключей и пароль приложения
(appleid.apple.com → App-Specific Passwords):
```bash
security find-identity -v -p codesigning # проверить наличие сертификата
export APPLE_SIGNING_IDENTITY="Developer ID Application: … (TEAMID)"
export APPLE_ID="…@example.com"
export APPLE_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="TEAMID"
make desktop-build # Tauri подпишет и отправит на нотаризацию
xcrun stapler validate desktop/src-tauri/target/release/bundle/macos/glchat.app
spctl --assess --type execute --verbose=4 desktop/src-tauri/target/release/bundle/macos/glchat.app
```
Переменные читает сам Tauri (`APPLE_*`), в репозиторий они не попадают.
Проверка «не подписано»: `codesign -dv --verbose=4 <app>` покажет `Signature=adhoc`.
### Windows (x64): MSI/NSIS + подпись
Сборка идёт на Windows-машине или в CI (`windows-latest`), Rust + Node 22:
```powershell
npx @tauri-apps/cli@^2 build --bundles nsis,msi
# подпись (EV/Trusted Signing или signtool с сертификатом):
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 `
/f cert.pfx /p $env:CERT_PASSWORD `
desktop\src-tauri\target\release\bundle\nsis\glchat_0.1.0_x64-setup.exe
```
Артефакты обновления (`*.nsis.zip`/`*.msi` + `.sig`) публикуются тем же
скриптом: `bash scripts/desktop-release.sh --artifact <файл> --target windows --arch x86_64`.
### Linux (AppImage + deb)
Сборка на Ubuntu 24.04/26.04 (нужны `libwebkit2gtk-4.1-dev`, `librsvg2-dev`,
`patchelf`, `libssl-dev`, `build-essential`):
```bash
sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev patchelf libssl-dev build-essential
npx @tauri-apps/cli@^2 build --bundles appimage,deb
bash scripts/desktop-release.sh --artifact desktop/src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \
--target linux --arch x86_64
```
На Wayland нативная тряска окна при несохранённых изменениях не работает
(композитор запрещает `set_position`) — остаётся CSS-тряска контента; это
ожидаемое поведение, а не дефект.
### Ключ подписи обновлений
Приватный ключ — только в CI-секретах (`TAURI_SIGNING_PRIVATE_KEY`,
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`); локально — `build/desktop-keys/`
(каталог в `.gitignore`). Публичный ключ уже вшит в сборку и меняется только
вместе с ключом: старые сборки отвергнут манифест, подписанный новым ключом.
+6
View File
@@ -30,6 +30,12 @@ pub enum WebMessage {
Settings { settings: Settings }, Settings { settings: Settings },
/// Доступно обновление — клиент показывает уведомление в интерфейсе. /// Доступно обновление — клиент показывает уведомление в интерфейсе.
UpdateAvailable { version: String }, UpdateAvailable { version: String },
/// Обёртка перехватила закрытие окна: в клиенте есть несохранённые
/// изменения, он показывает тряску и диалог «Сохранить / Выйти без
/// сохранения» (docs/client-tauri.md §5).
CloseRequested,
/// Открыть палитру команд: глобальный Ctrl/Cmd+K при неактивном окне.
OpenPalette,
} }
/// Отправляет сообщение в веб-клиент. /// Отправляет сообщение в веб-клиент.
+15
View File
@@ -84,6 +84,21 @@ pub fn desktop_set_badge<R: Runtime>(app: AppHandle<R>, count: u32) {
notifications::set_badge(&app, count); notifications::set_badge(&app, count);
} }
/// Веб-клиент сообщает, есть ли несохранённые изменения: от этого зависит
/// перехват закрытия окна (docs/client-tauri.md §5).
#[tauri::command]
pub fn desktop_set_dirty<R: Runtime>(app: AppHandle<R>, dirty: bool) {
app.state::<AppState>().set_dirty(dirty);
}
/// Закрывает приложение по решению пользователя в диалоге клиента
/// («Сохранить» после успешного сохранения или «Выйти без сохранения»).
#[tauri::command]
pub fn desktop_close_window<R: Runtime>(app: AppHandle<R>) {
log::info!("закрываем приложение по решению пользователя");
app.exit(0);
}
/// Открывает локальную страницу настроек обёртки. /// Открывает локальную страницу настроек обёртки.
#[tauri::command] #[tauri::command]
pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> { pub fn desktop_open_settings<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
+32 -4
View File
@@ -120,6 +120,8 @@ pub fn run() {
commands::desktop_ready, commands::desktop_ready,
commands::desktop_notify, commands::desktop_notify,
commands::desktop_set_badge, commands::desktop_set_badge,
commands::desktop_set_dirty,
commands::desktop_close_window,
commands::desktop_open_settings, commands::desktop_open_settings,
commands::desktop_open_instance, commands::desktop_open_instance,
commands::desktop_reload, commands::desktop_reload,
@@ -131,22 +133,48 @@ pub fn run() {
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
let app = window.app_handle();
match event {
// Закрытие окна: либо сворачиваемся в трей, либо выходим целиком // Закрытие окна: либо сворачиваемся в трей, либо выходим целиком
// (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой). // (docs/client-tauri.md §2; открытый вопрос §9.2 решён настройкой).
if let WindowEvent::CloseRequested { api, .. } = event { WindowEvent::CloseRequested { api, .. } => {
let app = window.app_handle();
if app.state::<AppState>().settings().close_to_tray { if app.state::<AppState>().settings().close_to_tray {
api.prevent_close(); api.prevent_close();
if let Err(error) = window.hide() { if let Err(error) = window.hide() {
log::warn!("не удалось свернуть окно в трей: {error}"); log::warn!("не удалось свернуть окно в трей: {error}");
} }
} else if window::request_close_confirmation(app) {
// В веб-клиенте есть несохранённые изменения: закрытие
// перехватываем, окно покачиваем, а решение принимает
// пользователь в диалоге клиента — «Сохранить / Выйти
// без сохранения» (docs/client-tauri.md §5).
api.prevent_close();
} else { } else {
app.exit(0); app.exit(0);
} }
} }
// Окно получило фокус: если только что было показано уведомление,
// считаем активацию кликом по нему и открываем комнату.
WindowEvent::Focused(true) => {
notifications::open_pending(app);
}
_ => {}
}
}) })
.run(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("не удалось запустить glchat desktop"); .expect("не удалось запустить glchat desktop")
.run(|app, event| {
// macOS: applicationShouldHandleReopen — клик по иконке в доке или
// активация приложения без видимых окон (например, окно свёрнуто в
// трей). Тот же путь использует клик по уведомлению.
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = event {
window::show_main(app);
notifications::open_pending(app);
}
#[cfg(not(target_os = "macos"))]
let _ = (app, event);
});
} }
/// Подключает логирование: файл в каталоге логов приложения и stdout. /// Подключает логирование: файл в каталоге логов приложения и stdout.
+104 -4
View File
@@ -3,14 +3,28 @@
//! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про //! Решение о том, показывать ли уведомление, принимает веб-клиент (он знает про
//! упоминания, личные сообщения и настройки пользователя), а обёртка только //! упоминания, личные сообщения и настройки пользователя), а обёртка только
//! показывает системное уведомление и обновляет бейдж. //! показывает системное уведомление и обновляет бейдж.
//!
//! Клик по уведомлению: `tauri-plugin-notification` на desktop не даёт колбэка
//! действия, поэтому используется лучшее доступное поведение — обёртка
//! запоминает уведомление, показанное при неактивном окне, и когда приложение
//! активируется (окно получило фокус, macOS прислала `Reopen`), открывает канал
//! из этого уведомления. Ограничение честно описано в `desktop/README.md`:
//! отличить клик по уведомлению от обычного возврата в приложение ОС не даёт,
//! поэтому переход делается только для «свежего» уведомления (см.
//! [`ACTIVATION_WINDOW`]).
use std::time::Duration;
use serde::Deserialize; use serde::Deserialize;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use crate::bridge::WEB_WINDOW; use crate::bridge::{self, WebMessage, WEB_WINDOW};
use crate::state::AppState; use crate::state::AppState;
/// Сколько после показа уведомления активация приложения считается кликом по нему.
const ACTIVATION_WINDOW: Duration = Duration::from_secs(10);
/// Уведомление, пришедшее из веб-клиента. /// Уведомление, пришедшее из веб-клиента.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -19,10 +33,10 @@ pub struct NotifyPayload {
pub title: String, pub title: String,
/// Текст: начало сообщения. /// Текст: начало сообщения.
pub body: String, pub body: String,
/// Сервер и комната — нужны для перехода по клику (в текущем инкременте /// Сервер комнаты: нужен для перехода по клику (`None` — личная беседа).
/// только сохраняются в логе: клик по уведомлению обрабатывается системой).
#[serde(default)] #[serde(default)]
pub guild_id: Option<String>, pub guild_id: Option<String>,
/// Комната, которую открывает клик по уведомлению.
#[serde(default)] #[serde(default)]
pub channel_id: Option<String>, pub channel_id: Option<String>,
} }
@@ -43,7 +57,61 @@ pub fn show<R: Runtime>(app: &AppHandle<R>, payload: &NotifyPayload) -> Result<(
.title(payload.title.clone()) .title(payload.title.clone())
.body(payload.body.clone()) .body(payload.body.clone())
.show() .show()
.map_err(|error| format!("не удалось показать уведомление: {error}")) .map_err(|error| format!("не удалось показать уведомление: {error}"))?;
// Запоминаем уведомление только для неактивного окна: если окно в фокусе,
// пользователь и так видит клиента, и переход по «активации» был бы ложным.
if let Some(channel_id) = payload.channel_id.clone() {
if !window_focused(app) {
app.state::<AppState>()
.remember_notification(payload.guild_id.clone(), channel_id);
}
}
Ok(())
}
/// Открывает канал последнего уведомления при активации приложения.
///
/// Возвращает `true`, если переход был отправлен в веб-клиент.
pub fn open_pending<R: Runtime>(app: &AppHandle<R>) -> bool {
let Some(pending) = app.state::<AppState>().take_notification(ACTIVATION_WINDOW) else {
return false;
};
let Some(url) = channel_link(pending.guild_id.as_deref(), &pending.channel_id) else {
log::warn!("уведомление пришло с непригодным идентификатором комнаты");
return false;
};
log::info!("активация приложения после уведомления: открываем комнату");
// Окно могло быть свёрнуто в трей: показываем его до перехода.
crate::window::show_main(app);
bridge::send(app, &WebMessage::DeepLink { url })
}
/// Ссылка на комнату из уведомления: сервер с комнатой или личная беседа.
///
/// Форма ссылок — как у внешних deep links (docs/client-tauri.md §5); разбор
/// на стороне клиента общий, поэтому переход работает и для личных бесед.
fn channel_link(guild_id: Option<&str>, channel_id: &str) -> Option<String> {
if !is_safe_id(channel_id) {
return None;
}
Some(match guild_id {
Some(guild) if is_safe_id(guild) => format!("glchat://guild/{guild}/channel/{channel_id}"),
_ => format!("glchat://dm/{channel_id}"),
})
}
/// Идентификаторы приходят из веб-клиента: в ссылку попадают только «снежинки»
/// (цифры), чтобы из чужого текста нельзя было собрать произвольный deep link.
fn is_safe_id(value: &str) -> bool {
!value.is_empty() && value.len() <= 32 && value.bytes().all(|byte| byte.is_ascii_digit())
}
/// В фокусе ли главное окно (нет окна — считаем, что нет).
fn window_focused<R: Runtime>(app: &AppHandle<R>) -> bool {
app.get_webview_window(WEB_WINDOW)
.and_then(|window| window.is_focused().ok())
.unwrap_or(false)
} }
/// Обновляет число непрочитанных: значок приложения и подпись у иконки в трее. /// Обновляет число непрочитанных: значок приложения и подпись у иконки в трее.
@@ -70,3 +138,35 @@ pub fn set_badge<R: Runtime>(app: &AppHandle<R>, count: u32) {
})); }));
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn строит_ссылку_на_комнату_сервера() {
assert_eq!(
channel_link(Some("42"), "77").as_deref(),
Some("glchat://guild/42/channel/77")
);
}
#[test]
fn строит_ссылку_на_личную_беседу() {
assert_eq!(channel_link(None, "77").as_deref(), Some("glchat://dm/77"));
}
#[test]
fn отвергает_непригодные_идентификаторы() {
// Пустой, нечисловой и слишком длинный идентификатор в ссылку не попадают.
assert_eq!(channel_link(Some("42"), ""), None);
assert_eq!(channel_link(Some("42"), "77/../../etc"), None);
assert_eq!(channel_link(Some("42"), "канал"), None);
assert_eq!(channel_link(None, &"7".repeat(40)), None);
// Плохой сервер не мешает открыть личную беседу по тому же каналу.
assert_eq!(
channel_link(Some("сервер"), "77").as_deref(),
Some("glchat://dm/77")
);
}
}
+34 -8
View File
@@ -12,6 +12,10 @@ use crate::bridge::{self, WebMessage};
use crate::settings::Settings; use crate::settings::Settings;
use crate::state::AppState; use crate::state::AppState;
/// Палитра команд (docs/client-tauri.md §5): комбинация не настраивается —
/// это системный жест «быстрый переход», одинаковый во всех сборках.
const PALETTE_SHORTCUT: &str = "CommandOrControl+K";
/// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации. /// Обработчик нажатий: плагин вызывает его для любой зарегистрированной комбинации.
pub fn on_shortcut<R: Runtime>( pub fn on_shortcut<R: Runtime>(
app: &AppHandle<R>, app: &AppHandle<R>,
@@ -36,6 +40,12 @@ pub fn on_shortcut<R: Runtime>(
} }
if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) { if parse(&settings.deafen_shortcut).as_ref() == Some(shortcut) {
bridge::send(app, &WebMessage::ToggleDeafen); bridge::send(app, &WebMessage::ToggleDeafen);
return;
}
// Быстрый переход: окно поднимаем и просим клиент открыть палитру команд.
if parse(PALETTE_SHORTCUT).as_ref() == Some(shortcut) {
crate::window::show_main(app);
bridge::send(app, &WebMessage::OpenPalette);
} }
} }
@@ -46,17 +56,33 @@ pub fn apply<R: Runtime>(app: &AppHandle<R>, settings: &Settings) -> Result<(),
.unregister_all() .unregister_all()
.map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?; .map_err(|error| format!("не удалось снять старые шорткаты: {error}"))?;
for spec in [ // Палитра команд регистрируется всегда и последней: если пользователь занял
&settings.ptt_shortcut, // её комбинацию своим действием, второй раз её регистрировать нельзя —
&settings.mute_shortcut, // сработает то действие, чья проверка в `on_shortcut` стоит раньше.
&settings.deafen_shortcut, let wanted = [
] { settings.ptt_shortcut.as_str(),
if spec.trim().is_empty() { settings.mute_shortcut.as_str(),
settings.deafen_shortcut.as_str(),
PALETTE_SHORTCUT,
];
// Повторы отсекаются по разобранной комбинации: «Cmd+K» и «Command+K» —
// одна и та же клавиша, и вторая регистрация вернула бы ошибку.
let mut registered: Vec<Shortcut> = Vec::new();
for spec in wanted {
let trimmed = spec.trim();
if trimmed.is_empty() {
continue;
}
let Some(parsed) = parse(trimmed) else {
continue;
};
if registered.contains(&parsed) {
continue; continue;
} }
shortcuts shortcuts
.register(spec.as_str()) .register(trimmed)
.map_err(|error| format!("комбинация «{spec}» не зарегистрирована: {error}"))?; .map_err(|error| format!("комбинация «{trimmed}» не зарегистрирована: {error}"))?;
registered.push(parsed);
} }
Ok(()) Ok(())
} }
+55
View File
@@ -2,9 +2,28 @@
//! сообщений, которые нужно передать в страницу. //! сообщений, которые нужно передать в страницу.
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::settings::Settings; use crate::settings::Settings;
/// Уведомление, ожидающее активации приложения.
///
/// Клик по системному уведомлению на desktop не даёт колбэка
/// (`tauri-plugin-notification` его не предоставляет), поэтому обёртка
/// запоминает последнее уведомление, показанное при неактивном окне, и
/// открывает его канал, когда приложение активируется (см.
/// [`crate::notifications::open_pending`]).
#[derive(Debug, Clone)]
pub struct PendingNotification {
/// Сервер комнаты: `None` — личная беседа.
pub guild_id: Option<String>,
/// Комната, которую нужно открыть.
pub channel_id: String,
/// Момент показа уведомления: активация считается кликом, только если
/// прошло немного времени.
pub at: Instant,
}
/// Состояние, доступное командам, трею и обработчикам событий. /// Состояние, доступное командам, трею и обработчикам событий.
#[derive(Default)] #[derive(Default)]
pub struct AppState { pub struct AppState {
@@ -20,6 +39,12 @@ struct Inner {
pending_deep_links: Vec<String>, pending_deep_links: Vec<String>,
/// Последнее переданное в трей/док число непрочитанных. /// Последнее переданное в трей/док число непрочитанных.
badge: u32, badge: u32,
/// Уведомление, по которому ждём активации приложения.
pending_notification: Option<PendingNotification>,
/// В веб-клиенте есть несохранённые изменения (docs/client-tauri.md §5):
/// закрытие окна перехватывается и превращается в диалог «Сохранить /
/// Выйти без сохранения».
dirty: bool,
} }
impl AppState { impl AppState {
@@ -60,6 +85,36 @@ impl AppState {
inner.badge inner.badge
} }
/// Запоминает уведомление, показанное при неактивном окне.
pub fn remember_notification(&self, guild_id: Option<String>, channel_id: String) {
self.lock().pending_notification = Some(PendingNotification {
guild_id,
channel_id,
at: Instant::now(),
});
}
/// Забирает уведомление, если с момента показа прошло не больше `max_age`.
///
/// Устаревшее уведомление отбрасывается: активация приложения через
/// несколько минут — это уже не клик по уведомлению.
pub fn take_notification(&self, max_age: Duration) -> Option<PendingNotification> {
match self.lock().pending_notification.take() {
Some(pending) if pending.at.elapsed() <= max_age => Some(pending),
_ => None,
}
}
/// Сообщает обёртке, есть ли в веб-клиенте несохранённые изменения.
pub fn set_dirty(&self, dirty: bool) {
self.lock().dirty = dirty;
}
/// Есть ли несохранённые изменения (нужно при перехвате закрытия окна).
pub fn dirty(&self) -> bool {
self.lock().dirty
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
// Отравленный мьютекс не должен ронять приложение: состояние — // Отравленный мьютекс не должен ронять приложение: состояние —
// это только настройки и очередь событий. // это только настройки и очередь событий.
+7 -1
View File
@@ -124,7 +124,13 @@ fn on_menu_event<R: Runtime>(app: &AppHandle<R>, event: tauri::menu::MenuEvent)
updates::check(handle, true).await; updates::check(handle, true).await;
}); });
} }
"quit" => app.exit(0), // Выход из трея — такой же выход, как закрытие окна: если в клиенте
// есть несохранённые правки, спрашиваем пользователя, а не теряем их.
"quit" => {
if !window::request_close_confirmation(app) {
app.exit(0);
}
}
other => log::debug!("неизвестный пункт меню трея: {other}"), other => log::debug!("неизвестный пункт меню трея: {other}"),
} }
} }
+61
View File
@@ -157,3 +157,64 @@ async fn prompt_install<R: Runtime>(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// Манифест ровно в том виде, в каком его раскладывает
/// `scripts/desktop-release.sh` (проверяется вместе с серверной частью:
/// internal/server/updates.go).
const MANIFEST: &str = r#"{
"version": "0.2.0",
"notes": "Релиз glchat 0.2.0",
"pub_date": "2026-09-22T12:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=",
"url": "https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz"
}
}
}"#;
/// Разбор манифеста идёт тем же типом, что использует плагин обновлений:
/// тест ловит расхождение формата (например, дату не в RFC 3339), из-за
/// которого обновление молча не приходило бы.
#[test]
fn манифест_разбирается_плагином_обновлений() {
let release: tauri_plugin_updater::RemoteRelease =
serde_json::from_str(MANIFEST).expect("манифест должен разбираться плагином");
assert_eq!(release.version.to_string(), "0.2.0");
assert!(release.pub_date.is_some(), "дата публикации не разобрана");
assert_eq!(
release
.download_url("darwin-aarch64")
.expect("ссылка на артефакт")
.as_str(),
"https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz"
);
assert_eq!(
release
.signature("darwin-aarch64")
.expect("подпись платформы"),
"dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo="
);
// Другая платформа в манифесте отсутствует — плагин сообщит об этом.
assert!(release.signature("windows-x86_64").is_err());
}
/// Адрес манифеста собирается по адресу инстанса и не содержит двойных слэшей.
#[test]
fn адрес_манифеста_собирается_из_адреса_инстанса() {
let url = instance::normalize("https://gl.mhspx.su/").expect("адрес инстанса");
let endpoint = format!("{}{UPDATE_PATH}", instance::display(&url));
assert_eq!(
endpoint,
"https://gl.mhspx.su/updates/{{target}}/{{arch}}/{{current_version}}"
);
assert!(
!UPDATER_PUBKEY.is_empty(),
"публичный ключ подписи должен быть вшит в сборку"
);
}
}
+79 -1
View File
@@ -9,7 +9,7 @@ use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindo
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tauri_plugin_window_state::{StateFlags, WindowExt}; use tauri_plugin_window_state::{StateFlags, WindowExt};
use crate::bridge::WEB_WINDOW; use crate::bridge::{self, WebMessage, WEB_WINDOW};
use crate::instance; use crate::instance;
use crate::settings::Settings; use crate::settings::Settings;
use crate::state::AppState; use crate::state::AppState;
@@ -51,6 +51,61 @@ pub fn show_main<R: Runtime>(app: &AppHandle<R>) {
} }
} }
/// Смещения нативной тряски окна: три колебания по 6 px и возврат в исходную
/// точку (docs/client-tauri.md §5 — «нативная тряска best-effort»).
const SHAKE_OFFSETS: [(i32, i32); 8] = [
(6, 0),
(-6, 0),
(6, 0),
(-6, 0),
(4, 0),
(-4, 0),
(2, 0),
(0, 0),
];
/// Покачивает окно при попытке закрыть приложение с несохранёнными изменениями.
///
/// Best-effort: на Wayland композитор запрещает программе двигать своё окно,
/// тогда остаётся только CSS-тряска контента в веб-клиенте. Движение идёт в
/// отдельном потоке с паузами, чтобы не блокировать обработчик события окна.
pub fn shake<R: Runtime>(app: &AppHandle<R>) {
let Some(window) = app.get_webview_window(WEB_WINDOW) else {
return;
};
let Ok(start) = window.outer_position() else {
return;
};
std::thread::spawn(move || {
for (dx, dy) in SHAKE_OFFSETS {
let position = tauri::PhysicalPosition::new(start.x + dx, start.y + dy);
if window.set_position(position).is_err() {
// Платформа не даёт двигать окно программно — молча выходим.
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let _ = window.set_position(start);
});
}
/// Просит веб-клиент подтвердить выход, если есть несохранённые изменения.
///
/// Возвращает `true`, если выход отложен: клиент покажет тряску и диалог
/// «Сохранить / Выйти без сохранения», а окно останется открытым
/// (docs/client-tauri.md §5). Общий путь для закрытия окна и пункта «Выход» в
/// меню трея — иначе правки терялись бы при выходе из трея.
pub fn request_close_confirmation<R: Runtime>(app: &AppHandle<R>) -> bool {
if !app.state::<AppState>().dirty() {
return false;
}
log::info!("выход отложен: в клиенте есть несохранённые изменения");
show_main(app);
shake(app);
bridge::send(app, &WebMessage::CloseRequested);
true
}
/// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек). /// Проверяет, загружен ли сейчас в окне клиент инстанса (а не страница настроек).
pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool { pub fn is_instance_page<R: Runtime>(app: &AppHandle<R>) -> bool {
let Some(window) = app.get_webview_window(WEB_WINDOW) else { let Some(window) = app.get_webview_window(WEB_WINDOW) else {
@@ -133,3 +188,26 @@ fn navigation_guard<R: Runtime>(app: &AppHandle<R>, target: &url::Url) -> bool {
let _ = app.opener().open_url(target.as_str(), None::<&str>); let _ = app.opener().open_url(target.as_str(), None::<&str>);
false false
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn тряска_возвращает_окно_на_место() {
// Смещения должны быть короткими и заканчиваться нулём: иначе окно
// «уезжает» от исходной позиции.
let (last_x, last_y) = SHAKE_OFFSETS[SHAKE_OFFSETS.len() - 1];
assert_eq!((last_x, last_y), (0, 0));
for (dx, dy) in SHAKE_OFFSETS {
assert!(
dx.abs() <= 8 && dy.abs() <= 8,
"слишком большое смещение: {dx},{dy}"
);
}
assert!(
SHAKE_OFFSETS.iter().any(|(dx, _)| *dx != 0),
"тряска без смещений ничего не показывает"
);
}
}
+5
View File
@@ -21,6 +21,10 @@ type Config struct {
ListenAddr string ListenAddr string
DataDir string DataDir string
WebRoot string WebRoot string
// UpdatesDir — каталог с манифестами автообновления desktop-обёртки и
// артефактами релиза (docs/client-tauri.md §4). Наполняется релизным
// скриптом (scripts/desktop-release.sh), приложение только отдаёт файлы.
UpdatesDir string
DatabasePath string DatabasePath string
SQLiteCache string SQLiteCache string
SQLiteMmap string SQLiteMmap string
@@ -67,6 +71,7 @@ func Load() (Config, error) {
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr), ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
DataDir: env("DATA_DIR", DefaultDataDir), DataDir: env("DATA_DIR", DefaultDataDir),
WebRoot: env("WEB_ROOT", "/app/web"), WebRoot: env("WEB_ROOT", "/app/web"),
UpdatesDir: env("UPDATES_DIR", "/opt/glchat/updates"),
DatabasePath: env("DATABASE_PATH", ""), DatabasePath: env("DATABASE_PATH", ""),
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"), SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"), SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
+2 -1
View File
@@ -9,7 +9,8 @@ import (
// reservedPrefixes are handled by the API, the gateway or the file CDN; an // reservedPrefixes are handled by the API, the gateway or the file CDN; an
// unknown path under them is a real 404 and must not receive the SPA shell. // unknown path under them is a real 404 and must not receive the SPA shell.
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"} // `/updates/` — статика автообновления: обёртка ждёт JSON или 404, а не HTML.
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc", "/updates/"}
func isReservedPath(path string) bool { func isReservedPath(path string) bool {
for _, prefix := range reservedPrefixes { for _, prefix := range reservedPrefixes {
+10
View File
@@ -93,6 +93,8 @@ type Server struct {
logger *slog.Logger logger *slog.Logger
http *http.Server http *http.Server
static *staticHandler static *staticHandler
// updates отдаёт манифесты автообновления desktop-обёртки (docs/client-tauri.md §4).
updates *updatesHandler
api huma.API api huma.API
} }
@@ -105,6 +107,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
gateway: deps.Gateway, gateway: deps.Gateway,
logger: logger, logger: logger,
static: newStaticHandler(cfg.WebRoot), static: newStaticHandler(cfg.WebRoot),
updates: newUpdatesHandler(cfg.UpdatesDir),
// 5 попыток входа в минуту на IP и 120 запросов API в минуту на // 5 попыток входа в минуту на IP и 120 запросов API в минуту на
// пользователя/IP — дефолты AGENT.md 8.6. // пользователя/IP — дефолты AGENT.md 8.6.
authLimiter: httpx.NewRateLimiter(5, 5), authLimiter: httpx.NewRateLimiter(5, 5),
@@ -228,6 +231,13 @@ func (s *Server) registerRoutes(router chi.Router) {
s.registerFileDownload(router) s.registerFileDownload(router)
} }
// Манифест автообновления desktop-обёртки (docs/client-tauri.md §4): обычная
// статика без сессии — обёртка проверяет обновления до входа в аккаунт.
// Обработчик берётся через поле, а не значением метода: каталог обновлений
// задаётся конфигом и в тестах подменяется до первого запроса.
router.Get("/updates/*", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
router.Get("/updates", func(w http.ResponseWriter, r *http.Request) { s.updates.serve(w, r) })
router.NotFound(s.handleFallback) router.NotFound(s.handleFallback)
router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed") httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
+169
View File
@@ -0,0 +1,169 @@
package server
import (
"errors"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"glchat/internal/httpx"
)
// updatesHandler отдаёт манифест автообновления desktop-обёртки и артефакты
// релиза (docs/client-tauri.md §4). Раскладку каталога создаёт релизный скрипт
// `scripts/desktop-release.sh`:
//
// <root>/<target>/<arch>/<version>.json — манифест версии
// <root>/<target>/<arch>/latest.json — манифест последнего релиза
// <root>/<target>/<arch>/<artifact> — артефакт (.app.tar.gz, .msi, …)
// <root>/<target>/<arch>/<artifact>.sig — подпись ed25519 (формат minisign)
//
// Обёртка запрашивает `/updates/{target}/{arch}/{current_version}`: версия без
// расширения отображается на файл `<version>.json`, а если его нет — на
// `latest.json` (см. resolve). Подпись проверяет клиент: публичный ключ вшит в
// сборку, сервер подписью не занимается и приватного ключа не знает. Ручка
// публичная (обёртка проверяет обновления до входа в аккаунт) и не входит в
// лимиты API: это статика.
type updatesHandler struct {
root string
}
func newUpdatesHandler(root string) *updatesHandler {
return &updatesHandler{root: root}
}
// serve обрабатывает GET|HEAD /updates/{target}/{arch}/{version|файл}.
func (h *updatesHandler) serve(w http.ResponseWriter, r *http.Request) {
// path.Clean собирает «..» и хвостовые слэши: запрос вне /updates/ после
// этого не совпадёт с тремя сегментами и получит 404.
rest := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/updates/")
segments := strings.Split(rest, "/")
if len(segments) != 3 {
writeUpdatesNotFound(w)
return
}
for _, segment := range segments {
if !validUpdateSegment(segment) {
writeUpdatesNotFound(w)
return
}
}
rel := path.Join(segments...)
if !filepath.IsLocal(filepath.FromSlash(rel)) {
writeUpdatesNotFound(w)
return
}
full, ok := h.resolve(filepath.FromSlash(rel))
if !ok {
writeUpdatesNotFound(w)
return
}
h.serveFile(w, r, full)
}
// resolve ищет файл в каталоге обновлений: имя без известного расширения
// считается версией из адреса обёртки (`0.1.0` → `0.1.0.json`), всё остальное —
// артефактом или подписью.
//
// Если манифеста запрошенной версии нет, отдаётся `latest.json` того же
// каталога: обёртка спрашивает `/updates/{target}/{arch}/{current_version}`,
// то есть приходит с версией, которая у неё установлена, а решение «обновление
// новее текущего» принимает клиент сравнением semver (tauri-plugin-updater).
// Без такого запасного варианта статика отдавала бы 404 всем, у кого
// установлена не последняя версия, и обновление не приходило бы никому.
// Для имён с расширением артефакта запасного варианта нет: иначе вместо
// архива клиент скачал бы манифест и подпись не сошлась бы.
func (h *updatesHandler) resolve(rel string) (string, bool) {
name := filepath.Base(rel)
candidates := []string{rel}
if updateContentType(name) == "" {
candidates = append(candidates, rel+".json")
if name != "latest" {
candidates = append(candidates, filepath.Join(filepath.Dir(rel), "latest.json"))
}
}
for _, candidate := range candidates {
full := filepath.Join(h.root, candidate)
info, err := os.Stat(full)
if err == nil {
if info.Mode().IsRegular() {
return full, true
}
continue
}
if !errors.Is(err, fs.ErrNotExist) {
// Нечитаемый файл не должен скрывать следующий вариант имени.
continue
}
}
return "", false
}
// serveFile отдаёт найденный файл с типом и кэшированием по его роли.
// Имя берётся у самого файла: запрос версии без расширения приводит к
// манифесту `<version>.json`, и кэшировать его как артефакт нельзя.
func (h *updatesHandler) serveFile(w http.ResponseWriter, r *http.Request, full string) {
name := filepath.Base(full)
if contentType := updateContentType(name); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
if strings.HasSuffix(name, ".json") {
// Манифест по одному и тому же адресу меняется от релиза к релизу.
w.Header().Set("Cache-Control", "no-cache")
} else {
// Артефакт версии неизменяем: адрес содержит версию и имя файла.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
http.ServeFile(w, r, full)
}
// validUpdateSegment пропускает только безопасные имена сегментов: без
// слэшей, «..» и посторонних символов, поэтому запрос не выйдет за каталог
// обновлений даже без дополнительных проверок пути.
func validUpdateSegment(segment string) bool {
if segment == "" || len(segment) > 64 || segment == "." || segment == ".." {
return false
}
for _, symbol := range segment {
switch {
case symbol >= 'a' && symbol <= 'z',
symbol >= 'A' && symbol <= 'Z',
symbol >= '0' && symbol <= '9':
case symbol == '.', symbol == '-', symbol == '_', symbol == '+':
default:
return false
}
}
return true
}
// updateContentType задаёт тип по расширению: в минимальном alpine-окружении
// mime-таблица знает не все расширения релизных артефактов.
func updateContentType(name string) string {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".json"):
return "application/json; charset=utf-8"
case strings.HasSuffix(lower, ".sig"):
return "text/plain; charset=utf-8"
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".gz"):
return "application/gzip"
case strings.HasSuffix(lower, ".zip"):
return "application/zip"
case strings.HasSuffix(lower, ".msi"):
return "application/x-msi"
case strings.HasSuffix(lower, ".exe"):
return "application/vnd.microsoft.portable-executable"
case strings.HasSuffix(lower, ".appimage"), strings.HasSuffix(lower, ".deb"), strings.HasSuffix(lower, ".rpm"):
return "application/octet-stream"
}
return ""
}
func writeUpdatesNotFound(w http.ResponseWriter) {
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "update not found"))
}
+226
View File
@@ -0,0 +1,226 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// updatesFixture раскладывает каталог обновлений так, как это делает
// scripts/desktop-release.sh, и возвращает сервер с этим каталогом.
func updatesFixture(t *testing.T) (*Server, string) {
t.Helper()
root := t.TempDir()
dir := filepath.Join(root, "darwin", "aarch64")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create updates dir: %v", err)
}
manifest := `{"version":"0.2.0","notes":"тест","pub_date":"2026-09-22T12:00:00Z",` +
`"platforms":{"darwin-aarch64":{"signature":"dGVzdA==",` +
`"url":"https://gl.mhspx.su/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz"}}}`
files := map[string]string{
"0.2.0.json": manifest,
"glchat_0.2.0_aarch64.app.tar.gz": "архив",
"glchat_0.2.0_aarch64.app.tar.gz.sig": "dGVzdA==",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
srv, _ := newTestServer(t)
srv.updates = newUpdatesHandler(root)
return srv, root
}
func getUpdates(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil))
return rec
}
// baseURLForTests — адрес инстанса в ссылках манифеста (как в релизном скрипте).
const baseURLForTests = "https://gl.mhspx.su"
// TestUpdatesServesManifestByVersion: обёртка запрашивает версию без
// расширения, сервер отдаёт соответствующий ей манифест (docs/client-tauri.md §4).
func TestUpdatesServesManifestByVersion(t *testing.T) {
srv, _ := updatesFixture(t)
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.2.0")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if contentType := rec.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "no-cache") {
t.Errorf("Cache-Control = %q, want no-cache для манифеста", cache)
}
var manifest struct {
Version string `json:"version"`
Platforms map[string]struct {
Signature string `json:"signature"`
URL string `json:"url"`
} `json:"platforms"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
t.Fatalf("decode manifest: %v", err)
}
if manifest.Version != "0.2.0" {
t.Errorf("version = %q, want 0.2.0", manifest.Version)
}
if _, ok := manifest.Platforms["darwin-aarch64"]; !ok {
t.Errorf("в манифесте нет платформы darwin-aarch64: %v", manifest.Platforms)
}
}
// TestUpdatesServesArtifactAndSignature: артефакт и его подпись лежат рядом с
// манифестом и отдаются как есть.
func TestUpdatesServesArtifactAndSignature(t *testing.T) {
srv, _ := updatesFixture(t)
rec := getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz")
if rec.Code != http.StatusOK || rec.Body.String() != "архив" {
t.Fatalf("артефакт: status = %d, body = %q", rec.Code, rec.Body.String())
}
if contentType := rec.Header().Get("Content-Type"); contentType != "application/gzip" {
t.Errorf("Content-Type = %q, want application/gzip", contentType)
}
if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "immutable") {
t.Errorf("Cache-Control = %q, want immutable для артефакта", cache)
}
rec = getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.2.0_aarch64.app.tar.gz.sig")
if rec.Code != http.StatusOK || rec.Body.String() != "dGVzdA==" {
t.Fatalf("подпись: status = %d, body = %q", rec.Code, rec.Body.String())
}
if contentType := rec.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
t.Errorf("Content-Type подписи = %q, want text/plain", contentType)
}
}
// TestUpdatesMissingManifestIsNotFound: неизвестная версия — честный 404, а не
// SPA-заглушка: иначе обёртка получила бы HTML вместо манифеста.
func TestUpdatesMissingManifestIsNotFound(t *testing.T) {
srv, _ := updatesFixture(t)
for _, path := range []string{
"/updates/darwin/aarch64/0.1.0", // манифеста версии нет
"/updates/windows/x86_64/0.2.0", // нет такого каталога
"/updates/darwin/aarch64", // мало сегментов
"/updates", // корень каталога обновлений
"/updates/", // корень со слэшем
} {
rec := getUpdates(t, srv, path)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s: status = %d, want 404", path, rec.Code)
}
if strings.Contains(rec.Body.String(), "<!doctype html") {
t.Errorf("GET %s: отдан HTML-шелл вместо 404", path)
}
}
}
// TestUpdatesRejectsTraversal: выход за каталог обновлений невозможен —
// сегменты проверяются по символам, а «..» отсекается.
func TestUpdatesRejectsTraversal(t *testing.T) {
srv, root := updatesFixture(t)
secret := filepath.Join(filepath.Dir(root), "secret.json")
if err := os.WriteFile(secret, []byte("секрет"), 0o644); err != nil {
t.Fatalf("write secret: %v", err)
}
for _, path := range []string{
"/updates/darwin/aarch64/..%2f..%2fsecret",
"/updates/darwin/aarch64/%2e%2e%2fsecret",
"/updates/..%2fdarwin/aarch64/0.2.0",
"/updates/darwin/aarch64/....//secret",
"/updates/darwin/aarch64/0.2.0.json%00",
} {
rec := getUpdates(t, srv, path)
if rec.Code == http.StatusOK {
t.Errorf("GET %s: status = 200, запрос должен быть отклонён (body %q)", path, rec.Body.String())
}
}
}
// TestUpdatesRejectsPost: метод кроме GET|HEAD не поддержан.
func TestUpdatesRejectsPost(t *testing.T) {
srv, _ := updatesFixture(t)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/updates/darwin/aarch64/0.2.0", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
// TestUpdatesFallsBackToLatest: обёртка приходит со своей установленной
// версией, поэтому запрос версии без собственного манифеста должен получить
// манифест последнего релиза — иначе обновление не пришло бы никому, кроме
// тех, у кого уже стоит последняя версия.
func TestUpdatesFallsBackToLatest(t *testing.T) {
srv, root := updatesFixture(t)
latest := `{"version":"0.3.0","notes":"свежий релиз","pub_date":"2026-09-23T10:00:00Z",` +
`"platforms":{"darwin-aarch64":{"signature":"bmV3","url":"` + baseURLForTests +
`/updates/darwin/aarch64/glchat_0.3.0_aarch64.app.tar.gz"}}}`
if err := os.WriteFile(filepath.Join(root, "darwin", "aarch64", "latest.json"), []byte(latest), 0o644); err != nil {
t.Fatalf("write latest.json: %v", err)
}
// Манифеста версии 0.1.0 нет — приходит последний релиз.
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.1.0")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"0.3.0"`) {
t.Errorf("в ответе нет версии 0.3.0: %q", rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
t.Errorf("Cache-Control = %q, want no-cache", rec.Header().Get("Cache-Control"))
}
// Собственный манифест версии важнее latest.json.
rec = getUpdates(t, srv, "/updates/darwin/aarch64/0.2.0")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"0.2.0"`) {
t.Fatalf("манифест версии 0.2.0 не отдан: status = %d, body = %q", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "0.3.0") {
t.Errorf("для версии 0.2.0 отдан latest.json: %q", rec.Body.String())
}
}
// TestUpdatesArtifactWithoutLatestFallback: отсутствующий артефакт — это 404, а
// не манифест: иначе клиент скачал бы JSON вместо архива и подпись не сошлась бы.
func TestUpdatesArtifactWithoutLatestFallback(t *testing.T) {
srv, root := updatesFixture(t)
latest := `{"version":"0.3.0","platforms":{"darwin-aarch64":{"signature":"bmV3","url":"` +
baseURLForTests + `/updates/darwin/aarch64/glchat_0.3.0_aarch64.app.tar.gz"}}}`
if err := os.WriteFile(filepath.Join(root, "darwin", "aarch64", "latest.json"), []byte(latest), 0o644); err != nil {
t.Fatalf("write latest.json: %v", err)
}
rec := getUpdates(t, srv, "/updates/darwin/aarch64/glchat_0.9.9_aarch64.app.tar.gz")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body %q)", rec.Code, rec.Body.String())
}
}
// TestUpdatesDirectoryAbsent: инстанс без каталога обновлений (свежая
// установка до первого релиза) отвечает 404, а не падает.
func TestUpdatesDirectoryAbsent(t *testing.T) {
srv, _ := newTestServer(t)
srv.updates = newUpdatesHandler(filepath.Join(t.TempDir(), "нет-каталога"))
rec := getUpdates(t, srv, "/updates/darwin/aarch64/0.1.0")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 при отсутствующем каталоге", rec.Code)
}
}
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""desktop-perf.py — замеры холодного старта и памяти desktop-обёртки.
Требование спецификации: idle RAM ≤ 350 MB, холодный старт ≤ 2 с
(docs/client-tauri.md §1, §7). Скрипт запускает собранный бандл, замеряет время
от старта процесса до загрузки страницы клиента (строка «загружена страница» в
логе обёртки) и память после того, как клиент успокоился.
Память считается по дереву процессов: на macOS страница живёт в отдельных
процессах WebKit (`WebContent`, `GPU`, `Networking`), поэтому одного RSS
главного процесса мало. Помощники WebKit запускаются через launchd (их
ppid = 1), поэтому «свои» определяются сравнением списка процессов до и после
запуска приложения.
Примеры:
python3 scripts/desktop-perf.py # 3 холодных старта + простой
python3 scripts/desktop-perf.py --runs 5 --json build/desktop-perf.json
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEFAULT_APP = REPO / "desktop/src-tauri/target/release/bundle/macos/glchat.app"
APP_MARKER = "glchat.app/Contents/MacOS/glchat-desktop"
PAGE_LOADED = "загружена страница"
@dataclass
class ProcInfo:
"""Сведения о процессе, нужные для замера."""
pid: int
ppid: int
path: str
rss: int
footprint: int
class MacProcessTable:
"""Чтение процессов через libproc (ps и top в песочнице недоступны)."""
PROC_ALL_PIDS = 1
PROC_PIDTBSDINFO = 3
RUSAGE_INFO_V2 = 2
def __init__(self) -> None:
import ctypes
import ctypes.util
self.ctypes = ctypes
self.libproc = ctypes.CDLL(ctypes.util.find_library("proc"), use_errno=True)
class BsdInfo(ctypes.Structure):
"""struct proc_bsdinfo: нужны pbi_ppid и pbi_pid."""
_fields_ = [
("pbi_flags", ctypes.c_uint32), ("pbi_status", ctypes.c_uint32),
("pbi_xstatus", ctypes.c_uint32), ("pbi_pid", ctypes.c_uint32),
("pbi_ppid", ctypes.c_uint32), ("pbi_uid", ctypes.c_uint32),
("pbi_gid", ctypes.c_uint32), ("pbi_ruid", ctypes.c_uint32),
("pbi_rgid", ctypes.c_uint32), ("pbi_svuid", ctypes.c_uint32),
("pbi_svgid", ctypes.c_uint32), ("rfu_1", ctypes.c_uint32),
("pbi_comm", ctypes.c_char * 16), ("pbi_name", ctypes.c_char * 32),
("pbi_nfiles", ctypes.c_uint32), ("pbi_pgid", ctypes.c_uint32),
("pbi_pjobc", ctypes.c_uint32), ("e_tdev", ctypes.c_uint32),
("e_tpgid", ctypes.c_uint32), ("pbi_nice", ctypes.c_int32),
("pbi_start_tvsec", ctypes.c_uint64), ("pbi_start_tvusec", ctypes.c_uint64),
]
self.bsd_info = BsdInfo
def _ppid(self, pid: int) -> int | None:
ctypes = self.ctypes
info = self.bsd_info()
size = self.libproc.proc_pidinfo(
ctypes.c_int(pid), ctypes.c_int(self.PROC_PIDTBSDINFO), ctypes.c_uint64(0),
ctypes.byref(info), ctypes.c_int(ctypes.sizeof(info)),
)
return int(info.pbi_ppid) if size == ctypes.sizeof(info) else None
def _rusage(self, pid: int) -> tuple[int, int]:
"""RSS и физический след процесса (rusage_info_v2)."""
ctypes = self.ctypes
buf = (ctypes.c_uint64 * 32)()
if self.libproc.proc_pid_rusage(ctypes.c_int(pid), ctypes.c_int(self.RUSAGE_INFO_V2), ctypes.byref(buf)) != 0:
return 0, 0
# ri_uuid занимает два 64-битных слова, дальше: resident_size, phys_footprint.
return int(buf[8]), int(buf[9])
def _path(self, pid: int) -> str:
ctypes = self.ctypes
buf = ctypes.create_string_buffer(4096)
size = self.libproc.proc_pidpath(ctypes.c_int(pid), buf, ctypes.c_uint32(4096))
return buf.value.decode("utf-8", "replace") if size > 0 else ""
def snapshot(self) -> dict[int, ProcInfo]:
ctypes = self.ctypes
count = 8192
buf = (ctypes.c_int * count)()
size = self.libproc.proc_listpids(
ctypes.c_int(self.PROC_ALL_PIDS), ctypes.c_uint32(0), ctypes.byref(buf),
ctypes.c_int(ctypes.sizeof(buf)),
)
result: dict[int, ProcInfo] = {}
for index in range(max(size, 0) // ctypes.sizeof(ctypes.c_int)):
pid = buf[index]
if pid <= 0:
continue
ppid = self._ppid(pid)
if ppid is None:
continue
resident, footprint = self._rusage(pid)
result[pid] = ProcInfo(
pid=pid, ppid=ppid, path=self._path(pid), rss=resident, footprint=footprint,
)
return result
class LinuxProcessTable:
"""Чтение процессов через /proc (для сборок под Linux)."""
def snapshot(self) -> dict[int, ProcInfo]:
result: dict[int, ProcInfo] = {}
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
try:
fields = (entry / "stat").read_text().rsplit(") ", 1)[1].split()
ppid = int(fields[1])
rss = int(fields[21]) * os.sysconf("SC_PAGE_SIZE")
path = os.readlink(entry / "exe")
except (OSError, IndexError, ValueError):
continue
result[pid] = ProcInfo(pid=pid, ppid=ppid, path=path, rss=rss, footprint=rss)
return result
def process_table():
if platform.system() == "Darwin":
return MacProcessTable()
return LinuxProcessTable()
def app_pids(table, marker: str = APP_MARKER) -> list[int]:
return [info.pid for info in table.snapshot().values() if marker in info.path]
def log_candidates() -> list[Path]:
home = Path.home()
return [
home / "Library/Logs/su.mhspx.glchat/glchat.log",
home / ".local/share/su.mhspx.glchat/logs/glchat.log",
home / ".config/su.mhspx.glchat/logs/glchat.log",
]
def log_file() -> Path | None:
existing = [path for path in log_candidates() if path.exists()]
if not existing:
return None
return max(existing, key=lambda path: path.stat().st_mtime)
def quit_app(table, timeout: float = 15.0) -> None:
"""Завершает запущенные экземпляры обёртки и ждёт их исчезновения."""
for pid in app_pids(table):
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not app_pids(table):
time.sleep(0.6) # окну и WebKit нужно время освободить ресурсы
return
time.sleep(0.1)
raise SystemExit("не удалось завершить запущенный экземпляр glchat")
def measure_cold_start(table, app: Path, extra_args: list[str], timeout: float = 30.0) -> dict:
"""Запускает приложение и измеряет время до загрузки страницы клиента."""
before = table.snapshot()
log = log_file()
offset = log.stat().st_size if log is not None else 0
started = time.monotonic()
subprocess.run(["open", "-a", str(app), "--args", *extra_args], check=True)
pid: int | None = None
pid_time: float | None = None
page_time: float | None = None
deadline = started + timeout
while time.monotonic() < deadline:
if pid is None:
found = app_pids(table)
if found:
pid = found[0]
pid_time = time.monotonic()
if log is not None:
with log.open("rb") as handle:
handle.seek(offset)
chunk = handle.read().decode("utf-8", "replace")
if PAGE_LOADED in chunk:
page_time = time.monotonic()
break
time.sleep(0.02)
if pid is None or page_time is None:
raise SystemExit("приложение не запустилось или страница не загрузилась: смотрите лог обёртки")
return {
"pid": pid,
"open_ms": round((pid_time - started) * 1000),
"page_ms": round((page_time - pid_time) * 1000),
"total_ms": round((page_time - started) * 1000),
"before_pids": set(before),
}
def measure_memory(table, pid: int, before_pids: set[int], settle: float) -> dict:
"""Суммирует память обёртки и её процессов WebKit после паузы."""
time.sleep(settle)
snapshot = table.snapshot()
own = snapshot.get(pid)
helpers = [
info for info in snapshot.values()
if info.pid != pid and info.pid not in before_pids and "WebKit" in info.path
]
rss = (own.rss if own else 0) + sum(info.rss for info in helpers)
footprint = (own.footprint if own else 0) + sum(info.footprint for info in helpers)
return {
"rss_mb": round(rss / 1024 / 1024, 1),
"footprint_mb": round(footprint / 1024 / 1024, 1),
"app_rss_mb": round((own.rss if own else 0) / 1024 / 1024, 1),
"webkit_rss_mb": round(sum(info.rss for info in helpers) / 1024 / 1024, 1),
"webkit_processes": len(helpers),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Замеры холодного старта и памяти glchat.app")
parser.add_argument("--app", type=Path, default=DEFAULT_APP, help="путь к собранному .app")
parser.add_argument("--runs", type=int, default=3, help="сколько холодных стартов измерить")
parser.add_argument("--settle", type=float, default=20.0, help="пауза перед замером памяти, с")
parser.add_argument("--json", type=Path, default=None, help="сохранить результаты в JSON")
args = parser.parse_args()
if not args.app.exists():
print(f"нет собранного приложения: {args.app} (сначала make desktop-build)", file=sys.stderr)
return 1
table = process_table()
results: dict[str, object] = {"app": str(args.app), "runs": []}
# Настройки обёртки замер не меняет: приложение запускается как обычно,
# автозапуск не включается и не выключается.
for _ in range(args.runs):
quit_app(table)
run = measure_cold_start(table, args.app, [])
run.update(measure_memory(table, run["pid"], run.pop("before_pids"), args.settle))
results["runs"].append(run)
print(
f"старт: open→процесс {run['open_ms']} мс, процесс→страница {run['page_ms']} мс, "
f"итого {run['total_ms']} мс; память RSS {run['rss_mb']} МБ "
f"(обёртка {run['app_rss_mb']} + WebKit {run['webkit_rss_mb']}), "
f"footprint {run['footprint_mb']} МБ"
)
# Простой в трее: окно скрыто, клиент загружен (closeToTray).
quit_app(table)
tray = measure_cold_start(table, args.app, ["--minimized"])
tray.update(measure_memory(table, tray["pid"], tray.pop("before_pids"), args.settle))
results["tray"] = tray
print(
f"трей: старт {tray['total_ms']} мс; память RSS {tray['rss_mb']} МБ "
f"(обёртка {tray['app_rss_mb']} + WebKit {tray['webkit_rss_mb']}), "
f"footprint {tray['footprint_mb']} МБ"
)
cold = [run["total_ms"] for run in results["runs"]]
results["summary"] = {
"cold_ms_min": min(cold),
"cold_ms_max": max(cold),
"cold_ms_avg": round(sum(cold) / len(cold)),
"ram_open_mb": max(run["rss_mb"] for run in results["runs"]),
"ram_tray_mb": tray["rss_mb"],
"budget_ram_mb": 350,
"budget_cold_ms": 2000,
}
print(
f"итог: холодный старт {results['summary']['cold_ms_min']}–"
f"{results['summary']['cold_ms_max']} мс (бюджет 2000), "
f"RAM открытого клиента {results['summary']['ram_open_mb']} МБ, "
f"в трее {results['summary']['ram_tray_mb']} МБ (бюджет 350)"
)
if args.json is not None:
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
print(f"результаты: {args.json}")
quit_app(table)
return 0
if __name__ == "__main__":
sys.exit(main())
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env bash
# desktop-release.sh — раскладывает собранные артефакты desktop-обёртки по
# каталогу обновлений инстанса и генерирует манифест автообновления
# (docs/client-tauri.md §4, desktop/README.md).
#
# Что делает:
# 1. берёт артефакт обновления (`glchat.app.tar.gz` и его `.sig` для macOS,
# `.nsis.zip`/`.msi` для Windows, `.AppImage.tar.gz` для Linux);
# 2. копирует артефакт и подпись в <каталог>/<target>/<arch>/;
# 3. пишет манифест <каталог>/<target>/<arch>/<version>.json и его копию
# latest.json — её инстанс отдаёт в ответ на
# GET /updates/{target}/{arch}/{current_version} (обёртка приходит со
# своей установленной версией, а «новее ли релиз» решает клиент).
#
# Приватный ключ подписи не нужен: подпись уже лежит рядом с артефактом (её
# делает `make desktop-build`), сервер только отдаёт файлы, а проверяет
# подпись клиент вшитым публичным ключом.
#
# Примеры:
# # macOS-артефакт из target/release/bundle/macos → /opt/glchat/updates
# sudo bash scripts/desktop-release.sh
# # разложить в свой каталог и посмотреть план
# bash scripts/desktop-release.sh --dir /tmp/updates --dry-run
# # загрузить на стенд (sudo спросит пароль в терминале)
# bash scripts/desktop-release.sh --remote alex@192.168.1.247
# # Windows/Linux-артефакты указываются явно
# bash scripts/desktop-release.sh --artifact bundle/nsis/glchat_0.1.0_x64-setup.nsis.zip
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TAURI_DIR="${REPO_DIR}/desktop/src-tauri"
BUNDLE_DIR="${TAURI_DIR}/target/release/bundle"
# Каталог обновлений на инстансе: тот же путь, что в UPDATES_DIR приложения
# (в контейнере смонтирован в /app/updates только для чтения).
UPDATES_DIR="${GLCHAT_UPDATES_DIR:-/opt/glchat/updates}"
# Публичный адрес инстанса: из него собираются ссылки на артефакты в манифесте.
BASE_URL="${GLCHAT_BASE_URL:-https://gl.mhspx.su}"
VERSION=""
TARGET=""
ARCH=""
NOTES=""
REMOTE=""
DRY_RUN=0
ARTIFACTS=()
info() { printf ' %s\n' "$*"; }
ok() { printf ' ✓ %s\n' "$*"; }
warn() { printf ' ! %s\n' "$*" >&2; }
die() {
printf 'ошибка: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
desktop-release.sh — раскладка артефактов обновления desktop-обёртки на инстансе.
Использование: bash scripts/desktop-release.sh [флаги]
Флаги:
--artifact FILE файл артефакта (можно несколько); по умолчанию берётся
первый найденный артефакт текущей ОС в каталоге бандла
--version V версия релиза (по умолчанию — из desktop/src-tauri/tauri.conf.json)
--target T платформа обновления: darwin|windows|linux (по умолчанию — по имени файла)
--arch A архитектура: aarch64|x86_64|i686|armv7 (по умолчанию — по имени файла)
--dir DIR каталог обновлений (по умолчанию $GLCHAT_UPDATES_DIR или /opt/glchat/updates)
--base-url URL публичный адрес инстанса для ссылок (по умолчанию https://gl.mhspx.su)
--notes TEXT текст «что нового» в манифесте
--remote HOST разложить в --dir на удалённом хосте: rsync через sudo по SSH
--dry-run показать план, ничего не записывать
-h, --help эта справка
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--artifact)
[ $# -ge 2 ] || die "--artifact без значения"
ARTIFACTS+=("$2")
shift 2
;;
--version)
VERSION="${2:-}"
[ -n "$VERSION" ] || die "--version без значения"
shift 2
;;
--target)
TARGET="${2:-}"
shift 2
;;
--arch)
ARCH="${2:-}"
shift 2
;;
--dir)
UPDATES_DIR="${2:-}"
[ -n "$UPDATES_DIR" ] || die "--dir без значения"
shift 2
;;
--base-url)
BASE_URL="${2:-}"
[ -n "$BASE_URL" ] || die "--base-url без значения"
shift 2
;;
--notes)
NOTES="${2:-}"
shift 2
;;
--remote)
REMOTE="${2:-}"
[ -n "$REMOTE" ] || die "--remote без значения"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h | --help)
usage
exit 0
;;
*) die "неизвестный флаг: $1 (см. --help)" ;;
esac
done
have() { command -v "$1" >/dev/null 2>&1; }
have jq || die "нужен jq (генерация манифеста)"
BASE_URL="${BASE_URL%/}"
# --- сбор артефактов ------------------------------------------------------
# Версия релиза: единственный источник правды — tauri.conf.json
# (docs/client-tauri.md §3: версия синхронизирована с сервером).
if [ -z "$VERSION" ]; then
VERSION="$(jq -r '.version' "${TAURI_DIR}/tauri.conf.json")"
fi
[ -n "$VERSION" ] && [ "$VERSION" != "null" ] || die "не удалось определить версию релиза"
if [ "${#ARTIFACTS[@]}" -eq 0 ]; then
shopt -s nullglob
found=("${BUNDLE_DIR}"/macos/*.app.tar.gz "${BUNDLE_DIR}"/nsis/*.nsis.zip \
"${BUNDLE_DIR}"/msi/*.msi "${BUNDLE_DIR}"/appimage/*.AppImage.tar.gz)
shopt -u nullglob
[ "${#found[@]}" -gt 0 ] || die "в ${BUNDLE_DIR} нет артефактов обновления: сначала соберите бандл (make desktop-build)"
ARTIFACTS=("${found[0]}")
if [ "${#found[@]}" -gt 1 ]; then
warn "найдено несколько артефактов, берём ${found[0]}; остальные укажите через --artifact"
fi
fi
# platform_from_name разбирает имя артефакта Tauri: платформа и архитектура
# берутся из имени, если не заданы флагами (glchat_0.1.0_x64-setup.nsis.zip).
platform_from_name() {
case "$1" in
*.app.tar.gz) printf 'darwin' ;;
*.msi | *.nsis.zip) printf 'windows' ;;
*.AppImage.tar.gz) printf 'linux' ;;
*) die "не удалось определить платформу по имени $1: укажите --target" ;;
esac
}
arch_from_name() {
case "$1" in
*aarch64* | *arm64*) printf 'aarch64'; return ;;
*x86_64* | *x64*) printf 'x86_64'; return ;;
*i686* | *i386* | *x86*) printf 'i686'; return ;;
*armv7*) printf 'armv7'; return ;;
esac
# Имя macOS-бандла архитектуру не содержит (glchat.app.tar.gz): берём её у
# машины, которая собрала бандл. Явный --arch всегда важнее.
case "$(uname -m)" in
arm64 | aarch64) printf 'aarch64' ;;
x86_64 | amd64) printf 'x86_64' ;;
i686 | i386) printf 'i686' ;;
armv7l) printf 'armv7' ;;
*) die "не удалось определить архитектуру по имени $1: укажите --arch" ;;
esac
}
# Подготовленный каталог раскладки: <target>/<arch>/<файлы>. В него же
# собирается манифест, поэтому локальная запись и загрузка по SSH идут одним
# и тем же кодом.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
: >"${STAGE}/layout.txt"
PLATFORMS_FILE="${STAGE}/platforms.json"
printf '{}' >"$PLATFORMS_FILE"
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
for artifact in "${ARTIFACTS[@]}"; do
[ -f "$artifact" ] || die "нет файла ${artifact}"
artifact_name="$(basename "$artifact")"
# У .sig-файла артефакт ищется рядом: имя без .sig.
case "$artifact_name" in
*.sig) artifact_name="${artifact_name%.sig}" ;;
esac
artifact_dir="$(cd "$(dirname "$artifact")" && pwd)"
artifact_path="${artifact_dir}/${artifact_name}"
signature_path="${artifact_path}.sig"
target="${TARGET:-$(platform_from_name "$artifact_name")}"
arch="${ARCH:-$(arch_from_name "$artifact_name")}"
[ -f "$artifact_path" ] || die "нет артефакта ${artifact_path}"
[ -f "$signature_path" ] || die "нет подписи ${signature_path}: бандл собран без ключа обновлений (desktop/README.md)"
install_dir="${UPDATES_DIR}/${target}/${arch}"
# В манифесте — только содержимое подписи (одна строка minisign).
signature="$(tr -d '\n' <"$signature_path")"
url="${BASE_URL}/updates/${target}/${arch}/${artifact_name}"
platform_key="${target}-${arch}"
info "артефакт: ${artifact_path}"
info "подпись: ${signature_path}"
info "назначение: ${install_dir}/${artifact_name}"
mkdir -p "${STAGE}/${target}/${arch}"
cp "$artifact_path" "${STAGE}/${target}/${arch}/${artifact_name}"
cp "$signature_path" "${STAGE}/${target}/${arch}/${artifact_name}.sig"
printf '%s %s %s\n' "$target" "$arch" "$artifact_name" >>"${STAGE}/layout.txt"
# Манифест версии: по записи на платформу. Несколько платформ одной версии
# (macOS + Windows + Linux) складываются в один манифест, а не затирают
# друг друга.
jq --arg key "$platform_key" --arg url "$url" --arg signature "$signature" \
--arg version "$VERSION" --arg notes "$NOTES" --arg date "$DATE" \
'.version = $version
| .notes = (if $notes == "" then (.notes // ("Релиз glchat " + $version)) else $notes end)
| .pub_date = $date
| .platforms = ((.platforms // {}) + {($key): {signature: $signature, url: $url}})' \
"$PLATFORMS_FILE" >"${PLATFORMS_FILE}.tmp"
mv "${PLATFORMS_FILE}.tmp" "$PLATFORMS_FILE"
done
while read -r target arch artifact_name; do
install_dir="${UPDATES_DIR}/${target}/${arch}"
mkdir -p "${STAGE}/${target}/${arch}"
cp "$PLATFORMS_FILE" "${STAGE}/${target}/${arch}/${VERSION}.json"
cp "$PLATFORMS_FILE" "${STAGE}/${target}/${arch}/latest.json"
info "манифест: ${install_dir}/${VERSION}.json (+ latest.json)"
done <"${STAGE}/layout.txt"
if [ "$DRY_RUN" = "1" ]; then
info "dry-run: файлы не записаны (${UPDATES_DIR})"
exit 0
fi
# --- раскладка -------------------------------------------------------------
if [ -z "$REMOTE" ]; then
while read -r target arch artifact_name; do
install_dir="${UPDATES_DIR}/${target}/${arch}"
mkdir -p "$install_dir"
cp -f "${STAGE}/${target}/${arch}/${artifact_name}" "${install_dir}/${artifact_name}"
cp -f "${STAGE}/${target}/${arch}/${artifact_name}.sig" "${install_dir}/${artifact_name}.sig"
# Манифест этой версии мог уже существовать (другая платформа той же
# версии): записи платформ объединяются.
if [ -f "${install_dir}/${VERSION}.json" ]; then
jq -s '.[0] * .[1]' "${install_dir}/${VERSION}.json" "$PLATFORMS_FILE" >"${install_dir}/${VERSION}.json.tmp"
mv "${install_dir}/${VERSION}.json.tmp" "${install_dir}/${VERSION}.json"
else
cp -f "$PLATFORMS_FILE" "${install_dir}/${VERSION}.json"
fi
# latest.json указывает на последнюю опубликованную версию для этой
# платформы: именно её отдаёт инстанс на запрос обёртки.
cp -f "$PLATFORMS_FILE" "${install_dir}/latest.json"
chmod 644 "${install_dir}/${artifact_name}" "${install_dir}/${artifact_name}.sig" \
"${install_dir}/${VERSION}.json" "${install_dir}/latest.json"
ok "разложено в ${install_dir}"
done <"${STAGE}/layout.txt"
cat <<EOF
Готово. Проверка (подставьте target/arch из вывода выше):
curl -s ${BASE_URL}/updates/<target>/<arch>/${VERSION} | jq .
Обёртка проверяет манифест сама (трей → «Проверить обновления…»); каталог
смонтирован в контейнер только для чтения, перезапуск приложения не нужен.
EOF
exit 0
fi
# --- доставка на удалённый инстанс ---------------------------------------
# Каталог обновлений принадлежит root (его создаёт установщик), поэтому на
# удалённой стороне rsync запускается через sudo: пароль спросит ssh -t.
have rsync || die "нужен rsync для --remote"
info "загружаем на ${REMOTE}:${UPDATES_DIR} (sudo спросит пароль)"
rsync -az --rsync-path="sudo rsync" -e "ssh -t" "${STAGE}/" "${REMOTE}:${UPDATES_DIR}/" ||
die "не удалось загрузить артефакты на ${REMOTE}"
ssh -t "$REMOTE" "sudo find '${UPDATES_DIR}' -type f -exec chmod 644 {} +"
ok "обновления на ${REMOTE}:${UPDATES_DIR}"
cat <<EOF
Проверка:
curl -s ${BASE_URL}/updates/<target>/<arch>/${VERSION} | jq .
EOF
+459
View File
@@ -0,0 +1,459 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import type { Channel } from '@/api/types';
import { Modal } from '@/components/ui/Modal';
import { groupChannels } from '@/lib/channels';
import { useSessionStore, type SessionGuild } from '@/stores/session';
import { useUiStore, type SettingsTarget } from '@/stores/ui';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/** Сколько пунктов показываем в разделе: палитра — быстрый переход, не список. */
const SECTION_LIMIT = 8;
interface PaletteItem {
/** Устойчивый id: по нему строятся DOM-id опций и выбор мышью. */
id: string;
label: string;
/** Серый хвост строки: например, сервер комнаты. */
hint?: string;
icon: string;
/** Текст для поиска помимо подписи (имя сервера, синонимы). */
keywords: string;
run: () => void;
}
interface PaletteSection {
title: string;
items: PaletteItem[];
}
/** id опции в разметке: по нему `aria-activedescendant` находит активный пункт. */
function optionId(id: string): string {
return `palette-option-${id}`;
}
/**
* Совпадение по подстроке без учёта регистра. `toLowerCase` одинаково работает
* и с латиницей, и с кириллицей, поэтому раскладка значения не имеет.
*/
function matches(item: PaletteItem, needle: string): boolean {
return `${item.label} ${item.hint ?? ''} ${item.keywords}`.toLowerCase().includes(needle);
}
/** Оставляет только совпадения и обрезает разделы до `SECTION_LIMIT`. */
function filterSections(sections: PaletteSection[], query: string): PaletteSection[] {
const needle = query.trim().toLowerCase();
const result: PaletteSection[] = [];
for (const section of sections) {
const items =
needle === '' ? section.items : section.items.filter((item) => matches(item, needle));
if (items.length > 0) {
result.push({ title: section.title, items: items.slice(0, SECTION_LIMIT) });
}
}
return result;
}
/** Разделы палитры: серверы, комнаты выбранного сервера, беседы и действия. */
function buildSections(input: {
t: (key: string) => string;
guilds: SessionGuild[];
selectedGuildId: string | null;
firstChannelId: (guildId: string) => string | null;
dmChannels: Channel[];
voiceActive: boolean;
micEnabled: boolean;
deafened: boolean;
navigate: (path: string) => void;
openSettings: (target?: SettingsTarget) => void;
openGuildCreate: () => void;
setMicEnabled: (enabled: boolean) => Promise<void>;
setDeafened: (enabled: boolean) => Promise<void>;
}): PaletteSection[] {
const { t, guilds, selectedGuildId, firstChannelId, dmChannels, navigate } = input;
const sections: PaletteSection[] = [];
// Серверы: ведём в первую доступную комнату — она же открывается при выборе
// сервера в рейке.
if (guilds.length > 0) {
sections.push({
title: t('palette.sections.guilds'),
items: guilds.map((guild) => {
const channelId = firstChannelId(guild.id);
return {
id: `guild-${guild.id}`,
label: guild.name,
icon: '🏠',
keywords: t('palette.keywords.guild'),
run: () => {
navigate(channelId === null ? `/app/${guild.id}` : `/app/${guild.id}/${channelId}`);
},
};
}),
});
}
// Комнаты выбранного сервера: та же группировка по категориям, что в сайдбаре.
const selected = guilds.find((guild) => guild.id === selectedGuildId) ?? null;
if (selected !== null) {
for (const group of groupChannels(selected.channels)) {
sections.push({
title: group.category?.name ?? t('palette.sections.channels'),
items: group.channels.map((channel) => ({
id: `channel-${channel.id}`,
label: channel.name,
hint: selected.name,
icon: channel.type === 'voice' ? '🔊' : '#',
keywords: t('palette.keywords.channel'),
run: () => {
navigate(`/app/${selected.id}/${channel.id}`);
},
})),
});
}
}
// Личные беседы: берём уже загруженный снапшот, новых запросов не делаем.
if (dmChannels.length > 0) {
sections.push({
title: t('palette.sections.dms'),
items: dmChannels.map((channel) => ({
id: `dm-${channel.id}`,
label: channel.name === '' ? t('friends.dm.unknown') : channel.name,
icon: '💬',
keywords: t('palette.keywords.dm'),
run: () => {
navigate(`/app/friends/${channel.id}`);
},
})),
});
}
const actions: PaletteItem[] = [
{
id: 'action-profile',
label: t('palette.actions.profile'),
icon: '👤',
keywords: 'settings profile account',
run: () => {
navigate('/settings/account/profile');
},
},
{
id: 'action-security',
label: t('palette.actions.security'),
icon: '🔒',
keywords: 'settings security password 2fa',
run: () => {
navigate('/settings/account/security');
},
},
{
id: 'action-appearance',
label: t('palette.actions.appearance'),
icon: '🎨',
keywords: 'settings appearance theme',
run: () => {
navigate('/settings/account/appearance');
},
},
{
id: 'action-friends',
label: t('palette.actions.friends'),
icon: '👥',
keywords: t('palette.keywords.dm'),
run: () => {
navigate('/app/friends');
},
},
{
id: 'action-create-guild',
label: t('palette.actions.createGuild'),
icon: '➕',
keywords: t('palette.keywords.guild'),
run: input.openGuildCreate,
},
];
// Настройки сервера — только когда сервер выбран: открываем тот же пункт
// окна настроек, что и шестерёнка в шапке сайдбара.
if (selected !== null) {
actions.push({
id: 'action-guild-settings',
label: t('palette.actions.server'),
hint: selected.name,
icon: '⚙',
keywords: 'settings guild server',
run: () => {
input.openSettings({ section: 'server', guildId: selected.id });
},
});
}
// Голосовые действия имеют смысл только в активной сессии.
if (input.voiceActive) {
actions.push(
{
id: 'action-toggle-mic',
label: t('palette.actions.toggleMic'),
icon: input.micEnabled ? '🎙' : '🔇',
keywords: 'voice microphone mute',
run: () => {
void input.setMicEnabled(!input.micEnabled);
},
},
{
id: 'action-deafen',
label: t('palette.actions.deafen'),
icon: '🎧',
keywords: 'voice deafen sound',
run: () => {
void input.setDeafened(!input.deafened);
},
},
);
}
sections.push({ title: t('palette.sections.actions'), items: actions });
return sections;
}
/**
* Палитра команд (docs/client-tauri.md §5): быстрый переход по серверам,
* комнатам и действиям.
*
* Открывается по Ctrl/Cmd+K (в том числе из поля ввода — это ожидаемое
* поведение шортката) и по сообщению `openPalette` от desktop-обёртки, когда
* окно было неактивно. Данные берутся из уже загруженных сторов: палитра не
* делает ни одного запроса и на пустом снапшоте показывает только действия.
*/
export function CommandPalette() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const paletteOpen = useUiStore((state) => state.paletteOpen);
const openPalette = useUiStore((state) => state.openPalette);
const closePalette = useUiStore((state) => state.closePalette);
const openSettings = useUiStore((state) => state.openSettings);
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
const guilds = useSessionStore((state) => state.guilds);
const dmChannels = useSessionStore((state) => state.dmChannels);
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
const firstChannelId = useSessionStore((state) => state.firstChannelId);
const voiceStatus = useVoiceConnectionStore((state) => state.status);
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
const deafened = useVoiceConnectionStore((state) => state.deafened);
const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
// id списка уникален: `aria-controls` не должен указывать на чужой узел.
const listId = useId();
// Палитра живёт в защищённой части клиента: на публичных экранах (вход,
// приглашение) перехватывать Ctrl+K нечего.
const enabled = location.pathname.startsWith('/app') || location.pathname.startsWith('/settings');
// Ctrl/Cmd+K на уровне документа: работает и когда фокус в поле ввода.
useEffect(() => {
if (!enabled) {
return;
}
const onKeyDown = (event: KeyboardEvent): void => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
openPalette();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [enabled, openPalette]);
// Каждое открытие начинается с чистого поиска (подстройка состояния под
// изменившийся проп во время рендера — приём из `ui/Modal.tsx`).
const [prevOpen, setPrevOpen] = useState(paletteOpen);
if (prevOpen !== paletteOpen) {
setPrevOpen(paletteOpen);
if (paletteOpen) {
setQuery('');
setActiveIndex(0);
}
}
const sections = useMemo(
() =>
buildSections({
t,
guilds,
selectedGuildId,
firstChannelId,
dmChannels,
voiceActive: voiceStatus === 'connected' || voiceStatus === 'reconnecting',
micEnabled,
deafened,
navigate: (path) => void navigate(path),
openSettings,
openGuildCreate,
setMicEnabled,
setDeafened,
}),
[
t,
guilds,
selectedGuildId,
firstChannelId,
dmChannels,
voiceStatus,
micEnabled,
deafened,
navigate,
openSettings,
openGuildCreate,
setMicEnabled,
setDeafened,
],
);
const visible = useMemo(() => filterSections(sections, query), [sections, query]);
const flat = useMemo(() => visible.flatMap((section) => section.items), [visible]);
const active = flat[activeIndex] ?? null;
// Активный пункт всегда на виду: список может быть длиннее панели.
useEffect(() => {
if (!paletteOpen || active === null) {
return;
}
const node = listRef.current?.querySelector<HTMLElement>(
`[data-palette-option="${active.id}"]`,
);
// jsdom не умеет прокрутку — в тестах вызов просто пропускаем.
if (node !== null && node !== undefined && typeof node.scrollIntoView === 'function') {
node.scrollIntoView({ block: 'nearest' });
}
}, [paletteOpen, active]);
const runItem = useCallback(
(item: PaletteItem | null): void => {
if (item === null) {
return;
}
closePalette();
item.run();
},
[closePalette],
);
const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (flat.length === 0) {
return;
}
const step = event.key === 'ArrowDown' ? 1 : -1;
setActiveIndex((index) => (index + step + flat.length) % flat.length);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
runItem(active);
}
};
return (
<Modal
open={paletteOpen && enabled}
title={t('palette.title')}
onClose={closePalette}
size="lg"
panelClassName="p-0 overflow-hidden"
bodyClassName="mt-4"
initialFocusRef={inputRef}
>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={active === null ? undefined : optionId(active.id)}
aria-label={t('palette.placeholder')}
placeholder={t('palette.placeholder')}
value={query}
data-testid="palette-search"
className="w-full border-b border-border/60 bg-transparent px-4 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(0);
}}
onKeyDown={onKeyDown}
/>
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={t('palette.title')}
data-testid="palette-list"
className="max-h-80 overflow-y-auto p-2"
>
{flat.length === 0 ? (
<p className="px-2 py-3 text-sm text-fg-muted" data-testid="palette-empty">
{t('palette.empty')}
</p>
) : (
visible.map((section) => (
<div key={section.title} role="group" aria-label={section.title}>
<p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{section.title}
</p>
<ul role="presentation">
{section.items.map((item) => {
const selected = item.id === active?.id;
return (
<li key={item.id} role="presentation">
<div
id={optionId(item.id)}
role="option"
aria-selected={selected}
data-palette-option={item.id}
data-testid={`palette-option-${item.id}`}
className={`flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm ${
selected ? 'bg-surface-3 text-fg' : 'text-fg-muted hover:bg-surface-2'
}`}
onMouseEnter={() => setActiveIndex(flat.indexOf(item))}
onClick={() => runItem(item)}
>
<span aria-hidden="true">{item.icon}</span>
<span className="truncate">{item.label}</span>
{item.hint === undefined ? null : (
<span className="ml-auto truncate text-xs text-fg-muted">
{item.hint}
</span>
)}
</div>
</li>
);
})}
</ul>
</div>
))
)}
</div>
</Modal>
);
}
+18
View File
@@ -1,6 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { unreadCount, useMessagesStore } from '@/stores/messages'; import { unreadCount, useMessagesStore } from '@/stores/messages';
import { useUiStore } from '@/stores/ui';
import { useUnsavedStore } from '@/stores/unsaved';
import { useVoiceConnectionStore } from '@/stores/voiceConnection'; import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { platform, routeFromDeepLink } from '@/lib/platform'; import { platform, routeFromDeepLink } from '@/lib/platform';
import { router } from '@/router'; import { router } from '@/router';
@@ -47,6 +49,22 @@ export function DesktopBridge() {
case 'settings': case 'settings':
// Настройки обёртки клиенту не нужны: он читает только свои. // Настройки обёртки клиенту не нужны: он читает только свои.
break; break;
case 'closeRequested': {
// Обёртка перехватила закрытие окна, потому что клиент сообщил о
// несохранённых правках. Если реестр уже пуст (успели сохранить),
// отпускаем окно — иначе пользователь не закроет приложение.
const unsaved = useUnsavedStore.getState();
if (unsaved.entries.length === 0) {
platform.closeWindow();
} else {
unsaved.requestClose();
}
break;
}
case 'openPalette':
// Глобальный Ctrl/Cmd+K сработал при неактивном окне.
useUiStore.getState().openPalette();
break;
} }
}), }),
[], [],
+125
View File
@@ -0,0 +1,125 @@
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError } from '@/api/client';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { platform } from '@/lib/platform';
import { useReducedMotion } from '@/lib/reducedMotion';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useUnsavedStore } from '@/stores/unsaved';
/**
* Текст ошибки сохранения: серверную ошибку переводим по коду, локальную
* проверку формы (`Error`) показываем её собственным сообщением.
*/
function saveErrorText(error: unknown, describe: (error: unknown) => string): string {
if (error instanceof ApiError) {
return describe(error);
}
return error instanceof Error ? error.message : describe(error);
}
/**
* Перехват закрытия окна при несохранённых изменениях (docs/client-tauri.md §5).
*
* Обёртка перехватывает `onCloseRequested` и присылает `closeRequested` только
* тогда, когда клиент сообщил ей о правках. Здесь на это сообщение (флаг
* `closePrompt` в реестре) отвечают тряской контента и диалогом
* «Сохранить / Выйти без сохранения». Тряска — движение, поэтому при
* `prefers-reduced-motion: reduce` её заменяет подсветка рамки.
*
* Диалог рендерится соседом трясущегося контейнера: `transform` анимации
* превратил бы его в содержащий блок для `position: fixed` подложки, и окно
* уезжало бы вместе с контентом.
*/
export function DirtyCloseGuard({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const describeError = useApiErrorMessage();
const reduced = useReducedMotion();
const promptOpen = useUnsavedStore((state) => state.closePrompt);
const dismissClose = useUnsavedStore((state) => state.dismissClose);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<unknown>(null);
const attention = promptOpen ? (reduced ? 'gl-attention-flash' : 'gl-attention-shake') : '';
const onCancel = (): void => {
// Отмена: окно остаётся открытым, правки — на месте.
dismissClose();
};
const onDiscard = (): void => {
dismissClose();
platform.closeWindow();
};
const onSave = async (): Promise<void> => {
setSaving(true);
setError(null);
try {
await useUnsavedStore.getState().saveAll();
} catch (cause) {
// Диалог остаётся открытым: пользователь видит, что именно не сохранилось,
// и может повторить или выйти без сохранения.
setError(cause);
setSaving(false);
return;
}
setSaving(false);
// Реестр опустел: закрываем диалог. В браузере `window.close()` может не
// сработать, и окно должно вернуться в обычное состояние, а не остаться
// с вопросом «сохранить изменения?».
dismissClose();
platform.closeWindow();
};
return (
<>
<div data-testid="app-content" className={`h-full ${attention}`}>
{children}
</div>
<Modal
open={promptOpen}
title={t('unsaved.title')}
onClose={() => {
if (!saving) {
onCancel();
}
}}
size="sm"
footer={
<>
<Button variant="ghost" disabled={saving} onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={saving}
onClick={onDiscard}
>
{t('unsaved.discard')}
</Button>
<Button
disabled={saving}
onClick={() => {
void onSave();
}}
>
{t(saving ? 'common.saving' : 'common.save')}
</Button>
</>
}
>
<p className="text-sm text-fg-muted">{t('unsaved.body')}</p>
{error === null ? null : (
<p role="alert" data-testid="unsaved-error" className="mt-3 text-sm text-danger">
{saveErrorText(error, describeError)}
</p>
)}
</Modal>
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Outlet } from 'react-router';
import { CommandPalette } from '@/components/CommandPalette';
import { DirtyCloseGuard } from '@/components/DirtyCloseGuard';
/**
* Оболочка корня клиента (docs/client-tauri.md §5).
*
* Тряска контента при закрытии окна с несохранёнными правками и палитра команд
* нужны и в приложении, и в окне настроек, и на экране входа-в-настройки,
* поэтому живут на уровне корневого маршрута, а не внутри `AppLayout`.
*/
export function RootShell() {
return (
<>
<DirtyCloseGuard>
<Outlet />
</DirtyCloseGuard>
{/* Палитра — сосед трясущегося контейнера: её подложка `fixed`. */}
<CommandPalette />
</>
);
}
@@ -11,6 +11,7 @@ import {
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field'; import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives'; import { Button } from '@/components/ui/primitives';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import type { InstanceGuild } from '@/api/types'; import type { InstanceGuild } from '@/api/types';
interface InstanceGuildsPanelProps { interface InstanceGuildsPanelProps {
@@ -65,6 +66,17 @@ export function InstanceGuildsPanel({
onError: (cause: unknown) => setActionError(cause), onError: (cause: unknown) => setActionError(cause),
}); });
// Незаконченное переименование сервера: черновик живёт в поле, пока форма открыта.
useUnsavedChanges({
dirty: renaming !== null && name.trim() !== '' && name.trim() !== renaming.name,
save: async () => {
if (renaming === null || name.trim() === '') {
return;
}
await rename.mutateAsync({ id: renaming.id, name: name.trim() });
},
});
const pending = rename.isPending || remove.isPending; const pending = rename.isPending || remove.isPending;
if (isPending) { if (isPending) {
@@ -6,6 +6,7 @@ import { updateInstanceSettings, type InstanceSettings } from '@/api/instance';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field'; import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives'; import { Button } from '@/components/ui/primitives';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
interface InstanceLimitsPanelProps { interface InstanceLimitsPanelProps {
settings: InstanceSettings; settings: InstanceSettings;
@@ -67,7 +68,11 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr
}, },
}); });
const onSubmit = (): void => { /**
* Разбор формы: либо патч для сервера, либо текст локальной проверки.
* Общий и для кнопки «Сохранить», и для диалога закрытия окна.
*/
const buildPatch = (): { patch: Partial<InstanceSettings> | null; error: string | null } => {
const sizes = { const sizes = {
max_avatar_size: mbToBytes(form.avatar), max_avatar_size: mbToBytes(form.avatar),
max_guild_image_size: mbToBytes(form.guildImage), max_guild_image_size: mbToBytes(form.guildImage),
@@ -76,31 +81,62 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr
max_cosmetic_size: mbToBytes(form.cosmetic), max_cosmetic_size: mbToBytes(form.cosmetic),
}; };
if (Object.values(sizes).some((value) => value === null)) { if (Object.values(sizes).some((value) => value === null)) {
setError(t('settings.instance.limits.invalidSize')); return { patch: null, error: t('settings.instance.limits.invalidSize') };
return;
} }
const guilds = Number(form.guilds); const guilds = Number(form.guilds);
const members = Number(form.members); const members = Number(form.members);
const message = Number(form.message); const message = Number(form.message);
if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) { if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) {
setError(t('settings.instance.limits.invalidNumber')); return { patch: null, error: t('settings.instance.limits.invalidNumber') };
return;
} }
setError(null); return {
// Размеры проверены выше: null уже отсеян, здесь только числа. patch: {
save.mutate({
registration_enabled: form.registration, registration_enabled: form.registration,
allow_guild_creation: form.creation, allow_guild_creation: form.creation,
max_guilds_per_user: guilds, max_guilds_per_user: guilds,
max_members_per_guild: members, max_members_per_guild: members,
max_message_length: message, max_message_length: message,
// Размеры проверены выше: null уже отсеян, здесь только числа.
max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size, max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size,
max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size, max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size,
max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size, max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size,
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size, max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size, max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size,
}); },
error: null,
}; };
};
const onSubmit = (): void => {
const { patch, error: message } = buildPatch();
setError(message);
if (patch !== null) {
save.mutate(patch);
}
};
// Черновик лимитов: сравниваем введённое с тем, что уже сохранено на сервере.
useUnsavedChanges({
dirty:
form.registration !== settings.registration_enabled ||
form.creation !== settings.allow_guild_creation ||
form.guilds !== String(settings.max_guilds_per_user) ||
form.members !== String(settings.max_members_per_guild) ||
form.message !== String(settings.max_message_length) ||
form.avatar !== bytesToMb(settings.max_avatar_size) ||
form.guildImage !== bytesToMb(settings.max_guild_image_size) ||
form.emoji !== bytesToMb(settings.max_emoji_size) ||
form.sound !== bytesToMb(settings.max_sound_size) ||
form.cosmetic !== bytesToMb(settings.max_cosmetic_size),
save: async () => {
const { patch, error: message } = buildPatch();
if (patch === null) {
// Локальная проверка не прошла: диалог покажет текст, окно останется.
throw new Error(message ?? t('settings.instance.limits.invalidNumber'));
}
await save.mutateAsync(patch);
},
});
return ( return (
<form <form
+19 -6
View File
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import {
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefObject,
} from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -28,6 +35,11 @@ interface ModalProps {
* `overflow` и трансформациям родителей. * `overflow` и трансформациям родителей.
*/ */
portal?: boolean; portal?: boolean;
/**
* Куда поставить фокус при открытии. По умолчанию — первое поле панели;
* палитре команд нужен именно поиск, а не крестик в шапке (он тоже кнопка).
*/
initialFocusRef?: RefObject<HTMLElement | null>;
} }
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */ /** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
@@ -63,6 +75,7 @@ export function Modal({
panelStyle, panelStyle,
bodyClassName = '', bodyClassName = '',
portal = false, portal = false,
initialFocusRef,
}: ModalProps) { }: ModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
@@ -115,10 +128,10 @@ export function Modal({
}; };
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const firstField = panelRef.current?.querySelector<HTMLElement>( const target =
'input, textarea, select, button', initialFocusRef?.current ??
); panelRef.current?.querySelector<HTMLElement>('input, textarea, select, button');
firstField?.focus(); target?.focus();
return () => { return () => {
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
const index = openModals.indexOf(token); const index = openModals.indexOf(token);
@@ -128,7 +141,7 @@ export function Modal({
// Возвращаем фокус туда, откуда окно открыли. // Возвращаем фокус туда, откуда окно открыли.
previous?.focus(); previous?.focus();
}; };
}, [open]); }, [open, initialFocusRef]);
if (!mounted) { if (!mounted) {
return null; return null;
+31
View File
@@ -25,6 +25,37 @@
"online": "Server is reachable", "online": "Server is reachable",
"offline": "Server is unreachable" "offline": "Server is unreachable"
}, },
"palette": {
"title": "Command palette",
"placeholder": "Search servers, channels and actions",
"empty": "Nothing found",
"sections": {
"guilds": "Servers",
"channels": "Channels",
"dms": "Direct messages",
"actions": "Actions"
},
"keywords": {
"guild": "server guild",
"channel": "channel room",
"dm": "direct message friend dm"
},
"actions": {
"profile": "Profile settings",
"security": "Security",
"appearance": "Appearance",
"server": "Server settings",
"createGuild": "Create server",
"friends": "Friends",
"toggleMic": "Toggle microphone",
"deafen": "Deafen"
}
},
"unsaved": {
"title": "You have unsaved changes",
"body": "Save your changes before closing?",
"discard": "Close without saving"
},
"errors": { "errors": {
"network.unreachable": "The server is not responding. Check your connection and try again.", "network.unreachable": "The server is not responding. Check your connection and try again.",
"network.aborted": "The request was aborted.", "network.aborted": "The request was aborted.",
+31
View File
@@ -25,6 +25,37 @@
"online": "Сервер доступен", "online": "Сервер доступен",
"offline": "Сервер недоступен" "offline": "Сервер недоступен"
}, },
"palette": {
"title": "Палитра команд",
"placeholder": "Поиск серверов, комнат и действий",
"empty": "Ничего не найдено",
"sections": {
"guilds": "Серверы",
"channels": "Комнаты",
"dms": "Личные беседы",
"actions": "Действия"
},
"keywords": {
"guild": "сервер",
"channel": "комната канал",
"dm": "беседа друг личные сообщения"
},
"actions": {
"profile": "Настройки профиля",
"security": "Безопасность",
"appearance": "Внешний вид",
"server": "Настройки сервера",
"createGuild": "Создать сервер",
"friends": "Друзья",
"toggleMic": "Переключить микрофон",
"deafen": "Не слышу"
}
},
"unsaved": {
"title": "Есть несохранённые изменения",
"body": "Сохранить изменения перед выходом?",
"discard": "Выйти без сохранения"
},
"errors": { "errors": {
"network.unreachable": "Сервер не отвечает. Проверьте соединение и повторите попытку.", "network.unreachable": "Сервер не отвечает. Проверьте соединение и повторите попытку.",
"network.aborted": "Запрос отменён.", "network.aborted": "Запрос отменён.",
+57
View File
@@ -405,6 +405,55 @@ body {
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
/*
* Попытка закрыть окно с несохранёнными изменениями (docs/client-tauri.md §5):
* контент один раз коротко трясётся, затем открывается диалог сохранения.
* Нативная тряска окна — забота обёртки, в веб-клиенте её нет.
*/
@keyframes gl-attention-shake {
0%,
100% {
transform: translateX(0);
}
15% {
transform: translateX(-8px);
}
30% {
transform: translateX(7px);
}
45% {
transform: translateX(-5px);
}
60% {
transform: translateX(3px);
}
80% {
transform: translateX(-2px);
}
}
.gl-attention-shake {
animation: gl-attention-shake 450ms var(--ease-out);
}
/*
* Замена тряски при `prefers-reduced-motion: reduce`: движение убираем, но
* сигнал оставить нужно — пульс рамки акцентным цветом (он же цвет сервера).
*/
@keyframes gl-attention-flash {
0%,
100% {
box-shadow: inset 0 0 0 0 transparent;
}
50% {
box-shadow: inset 0 0 0 3px var(--color-accent);
}
}
.gl-attention-flash {
animation: gl-attention-flash 450ms var(--ease-out);
}
/* /*
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся * Доступность: при `prefers-reduced-motion: reduce` анимации становятся
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка * мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
@@ -421,6 +470,14 @@ body {
transition-delay: 0ms !important; transition-delay: 0ms !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
/*
* Подсветка — не движение, а смена цвета: общее правило выше её не гасит.
* Объявление важное, иначе `!important` из правила для `*` победил бы.
*/
.gl-attention-flash {
animation-duration: 450ms !important;
}
} }
/* /*
+59 -3
View File
@@ -32,7 +32,11 @@ export type DesktopMessage =
| { type: 'toggleDeafen' } | { type: 'toggleDeafen' }
| { type: 'deepLink'; url: string } | { type: 'deepLink'; url: string }
| { type: 'settings'; settings: DesktopSettings } | { type: 'settings'; settings: DesktopSettings }
| { type: 'updateAvailable'; version: string }; | { type: 'updateAvailable'; version: string }
/** Обёртка перехватила закрытие окна: у клиента есть несохранённые правки. */
| { type: 'closeRequested' }
/** Глобальный Ctrl/Cmd+K при неактивном окне: клиент открывает палитру. */
| { type: 'openPalette' };
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */ /** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
export interface DesktopNotification { export interface DesktopNotification {
@@ -54,6 +58,14 @@ export interface Platform {
ready(): void; ready(): void;
/** Открыть страницу настроек обёртки. */ /** Открыть страницу настроек обёртки. */
openSettings(): void; openSettings(): void;
/**
* Сообщить платформе, что в клиенте есть несохранённые изменения
* (docs/client-tauri.md §5). Обёртка перехватывает закрытие окна и присылает
* `closeRequested`; в браузере это штатное подтверждение `beforeunload`.
*/
setDirty(dirty: boolean): void;
/** Закрыть окно приложения: обёртка завершает процесс, браузер — вкладку. */
closeWindow(): void;
/** Подписка на сообщения обёртки; возвращает функцию отписки. */ /** Подписка на сообщения обёртки; возвращает функцию отписки. */
onMessage(handler: (message: DesktopMessage) => void): () => void; onMessage(handler: (message: DesktopMessage) => void): () => void;
} }
@@ -79,6 +91,18 @@ export function isTauri(): boolean {
return tauri() !== null; return tauri() !== null;
} }
/**
* Штатное подтверждение закрытия вкладки: браузер показывает свой диалог,
* текст в нём не настраивается (`event.returnValue` обязателен для Chrome).
*/
function confirmUnload(event: BeforeUnloadEvent): void {
event.preventDefault();
event.returnValue = '';
}
/** Подписан ли браузер на подтверждение закрытия: лишних подписок не плодим. */
let unloadGuard = false;
/** Реализация для браузера: системных возможностей почти нет. */ /** Реализация для браузера: системных возможностей почти нет. */
const webPlatform: Platform = { const webPlatform: Platform = {
kind: 'web', kind: 'web',
@@ -101,6 +125,24 @@ const webPlatform: Platform = {
}, },
ready() {}, ready() {},
openSettings() {}, openSettings() {},
setDirty(dirty) {
// Браузер умеет только штатное подтверждение закрытия вкладки. Обработчик
// один на страницу: повторный `setDirty(true)` не должен вешать второй.
if (dirty === unloadGuard) {
return;
}
unloadGuard = dirty;
if (dirty) {
window.addEventListener('beforeunload', confirmUnload);
return;
}
window.removeEventListener('beforeunload', confirmUnload);
},
closeWindow() {
// Страница, открытая не скриптом, закрыться не даст — это ограничение
// браузера, а не ошибка клиента.
window.close();
},
onMessage() { onMessage() {
return () => {}; return () => {};
}, },
@@ -146,6 +188,13 @@ function tauriPlatform(internals: TauriInternals): Platform {
openSettings() { openSettings() {
call('desktop_open_settings'); call('desktop_open_settings');
}, },
setDirty(dirty) {
// Обёртка по этому флагу решает, перехватывать ли закрытие окна.
call('desktop_set_dirty', { dirty });
},
closeWindow() {
call('desktop_close_window');
},
onMessage(handler) { onMessage(handler) {
handlers.add(handler); handlers.add(handler);
return () => { return () => {
@@ -163,8 +212,9 @@ export const platform: Platform = internals === null ? webPlatform : tauriPlatfo
/** /**
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5). * Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
* *
* Поддерживаются `glchat://invite/<code>` и * Поддерживаются `glchat://invite/<code>`, `glchat://guild/<guildId>/channel/<channelId>`
* `glchat://guild/<guildId>/channel/<channelId>`; неизвестная ссылка даёт `null`. * и `glchat://dm/<channelId>` (клик по уведомлению о личном сообщении: сервера
* у беседы нет); неизвестная ссылка даёт `null`.
*/ */
export function routeFromDeepLink(link: string): string | null { export function routeFromDeepLink(link: string): string | null {
let url: URL; let url: URL;
@@ -183,6 +233,12 @@ export function routeFromDeepLink(link: string): string | null {
const code = rest[0]; const code = rest[0];
return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null; return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null;
} }
if (kind === 'dm') {
const channelId = rest[0];
return rest.length === 1 && channelId !== undefined
? `/app/friends/${encodeURIComponent(channelId)}`
: null;
}
if (kind === 'guild') { if (kind === 'guild') {
const [guildId, marker, channelId] = rest; const [guildId, marker, channelId] = rest;
if ( if (
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useId, useRef } from 'react';
import { useUnsavedStore, type SaveHandler } from '@/stores/unsaved';
export interface UnsavedChangesOptions {
/** Есть ли в форме несохранённый черновик. */
dirty: boolean;
/** Сохранение формы — та же операция, что и у кнопки «Сохранить». */
save: SaveHandler;
}
/**
* Регистрирует форму в реестре несохранённых изменений, пока `dirty === true`
* (docs/client-tauri.md §5). Запись снимается при размонтировании формы и при
* возврате `dirty` в `false` — например, сразу после успешного сохранения.
*
* `save` хранится в ref: формы пересобирают обработчик на каждом рендере
* (он замкнут на текущее состояние полей), а регистрация должна оставаться
* одной — иначе реестр «мигал» бы на каждый введённый символ.
*/
export function useUnsavedChanges({ dirty, save }: UnsavedChangesOptions): void {
// id компонента вместо счётчика: уникален и не зависит от порядка рендера.
const id = useId();
const register = useUnsavedStore((state) => state.register);
const saveRef = useRef(save);
useEffect(() => {
saveRef.current = save;
});
useEffect(() => {
if (!dirty) {
return;
}
return register(id, () => saveRef.current());
}, [dirty, id, register]);
}
@@ -20,6 +20,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
import { stepUp } from '@/api/auth'; import { stepUp } from '@/api/auth';
import { errorCode } from '@/lib/format'; import { errorCode } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks'; import { useCurrentUser } from '@/lib/hooks';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Сколько держится подсказка «ссылка скопирована», мс. */ /** Сколько держится подсказка «ссылка скопирована», мс. */
@@ -131,6 +132,17 @@ export function ChannelWebhooksSection({ channelId, canManage }: ChannelWebhooks
onSuccess: invalidate, onSuccess: invalidate,
}); });
// Незаконченное переименование вебхука (форма открыта в отдельном окне).
useUnsavedChanges({
dirty: renaming !== null && renameValue.trim() !== '' && renameValue.trim() !== renaming.name,
save: async () => {
if (renaming === null || renameValue.trim() === '') {
return;
}
await rename.mutateAsync(renaming);
},
});
if (!canManage) { if (!canManage) {
return null; return null;
} }
@@ -19,6 +19,7 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field'; import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives'; import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton'; import { SkeletonLines } from '@/components/ui/Skeleton';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildCosmeticsSectionProps { interface GuildCosmeticsSectionProps {
@@ -77,6 +78,17 @@ export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSect
onError: (cause: unknown) => setError(cause), onError: (cause: unknown) => setError(cause),
}); });
// Незаконченное переименование: черновик живёт в поле, пока форма открыта.
useUnsavedChanges({
dirty: renaming !== null && name.trim() !== '' && name.trim() !== renaming.name,
save: async () => {
if (renaming === null || name.trim() === '') {
return;
}
await rename.mutateAsync({ id: renaming.id, name: name.trim() });
},
});
if (!canManage) { if (!canManage) {
return null; return null;
} }
@@ -22,6 +22,7 @@ import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton'; import { SkeletonLines } from '@/components/ui/Skeleton';
import { customEmojiShortcode } from '@/lib/customEmoji'; import { customEmojiShortcode } from '@/lib/customEmoji';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -143,6 +144,21 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
pickFile(event.dataTransfer.files[0]); pickFile(event.dataTransfer.files[0]);
}; };
/*
* Незаконченная загрузка (имя и выбранный файл) — единственный черновик
* раздела: переименование и удаление уходят на сервер сразу. В реестр
* попадает только заполненная форма — невалидную всё равно не сохранить.
*/
useUnsavedChanges({
dirty: file !== null && isValidEmojiName(name),
save: async () => {
if (file === null) {
return;
}
await upload.mutateAsync({ name, file });
},
});
return ( return (
<SettingsSection <SettingsSection
title={t('settings.servers.emojis.title')} title={t('settings.servers.emojis.title')}
@@ -8,6 +8,7 @@ import type { Guild } from '@/api/types';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, TextAreaField } from '@/components/ui/Field'; import { Field, TextAreaField } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives'; import { Button } from '@/components/ui/primitives';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -46,6 +47,14 @@ export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps
save.mutate(); save.mutate();
}; };
// Незавершённая правка имени или описания: закрытие окна не должно её терять.
useUnsavedChanges({
dirty: name !== guild.name || description !== guild.description,
save: async () => {
await save.mutateAsync();
},
});
return ( return (
<SettingsSection <SettingsSection
title={t('settings.servers.general.title')} title={t('settings.servers.general.title')}
@@ -7,6 +7,7 @@ import type { GuildMember } from '@/api/types';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field'; import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives'; import { Button } from '@/components/ui/primitives';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -70,6 +71,14 @@ export function GuildNicknameSection({
save.mutate(nickname.trim()); save.mutate(nickname.trim());
}; };
// Поле тронули, но не сохранили (`typed === null` означает «не трогали»).
useUnsavedChanges({
dirty: typed !== null && typed.trim() !== currentNickname,
save: async () => {
await save.mutateAsync(nickname.trim());
},
});
return ( return (
<SettingsSection <SettingsSection
title={t('settings.servers.nickname.title')} title={t('settings.servers.nickname.title')}
@@ -23,6 +23,7 @@ import { Modal } from '@/components/ui/Modal';
import { Badge, Button } from '@/components/ui/primitives'; import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton'; import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -171,6 +172,27 @@ export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) {
pickFile(event_.dataTransfer.files[0]); pickFile(event_.dataTransfer.files[0]);
}; };
/*
* Незаконченная загрузка звука (имя и выбранный файл) — единственный черновик
* раздела: переименование и удаление уходят на сервер сразу. В реестр попадает
* только заполненная форма — невалидную всё равно не сохранить.
*/
useUnsavedChanges({
dirty: file !== null && isValidSoundName(name),
save: async () => {
if (file === null) {
return;
}
await upload.mutateAsync({
name,
file,
kind,
...(kind === 'ui' ? { event } : {}),
...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }),
});
},
});
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */ /** Строка списка: имя, событие, эмодзи, переименование и удаление. */
const renderRow = (sound: GuildSound) => ( const renderRow = (sound: GuildSound) => (
<li <li
+24 -6
View File
@@ -19,6 +19,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks'; import { useCurrentUser } from '@/lib/hooks';
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl'; import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection'; import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -136,18 +137,35 @@ export function ProfileForm({ user }: { user: User }) {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, []);
const onSubmit = (event: FormEvent<HTMLFormElement>): void => { /**
event.preventDefault(); * Черновик полей — то, что потерялось бы при закрытии окна. Аватар сюда не
setSaved(false); * входит: он уходит на сервер сразу после выбора файла.
// Часовой пояс и статус присутствия форма не трогает: у них свои места. */
save.mutate({ const draft: UpdateProfileInput = {
display_name: displayName.trim(), display_name: displayName.trim(),
bio: bio.trim(), bio: bio.trim(),
custom_status: customStatus.trim(), custom_status: customStatus.trim(),
custom_status_emoji: customEmoji.trim(), custom_status_emoji: customEmoji.trim(),
});
}; };
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setSaved(false);
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
save.mutate(draft);
};
useUnsavedChanges({
dirty:
displayName !== user.display_name ||
bio !== user.bio ||
customStatus !== user.custom_status ||
customEmoji !== user.custom_status_emoji,
save: async () => {
await save.mutateAsync(draft);
},
});
const avatarName = displayName === '' ? user.username : displayName; const avatarName = displayName === '' ? user.username : displayName;
const avatarBusy = upload.isPending || removeAvatar.isPending; const avatarBusy = upload.isPending || removeAvatar.isPending;
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы. // Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
+4
View File
@@ -1,6 +1,7 @@
import { createBrowserRouter, Navigate } from 'react-router'; import { createBrowserRouter, Navigate } from 'react-router';
import { AuthGuard } from '@/components/AuthGuard'; import { AuthGuard } from '@/components/AuthGuard';
import { RootShell } from '@/components/RootShell';
import { import {
AccountSettingsLayout, AccountSettingsLayout,
AppearanceSettingsPage, AppearanceSettingsPage,
@@ -25,6 +26,9 @@ import {
export const routes = [ export const routes = [
{ {
path: '/', path: '/',
// Корневая оболочка: тряска при закрытии окна с несохранёнными правками
// и палитра команд доступны на всех экранах внутри роутера (§5).
element: <RootShell />,
children: [ children: [
{ index: true, element: <IndexRedirect /> }, { index: true, element: <IndexRedirect /> },
{ path: 'login', element: <LoginPage /> }, { path: 'login', element: <LoginPage /> },
+11
View File
@@ -50,6 +50,13 @@ interface UiState {
settingsOpen: boolean; settingsOpen: boolean;
settingsSection: SettingsSection; settingsSection: SettingsSection;
settingsProfileTab: ProfileTab; settingsProfileTab: ProfileTab;
/**
* Палитра команд (Ctrl/Cmd+K): открыть её может и обёртка сообщением
* `openPalette`, поэтому состояние живёт в сторе, а не в компоненте.
*/
paletteOpen: boolean;
openPalette: () => void;
closePalette: () => void;
/** /**
* Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а * Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а
* выбор пользователя переживает перезагрузку страницы. * выбор пользователя переживает перезагрузку страницы.
@@ -114,6 +121,10 @@ export const useUiStore = create<UiState>((set, get) => ({
settingsSection: 'profile', settingsSection: 'profile',
settingsProfileTab: 'main', settingsProfileTab: 'main',
paletteOpen: false,
openPalette: () => set({ paletteOpen: true }),
closePalette: () => set({ paletteOpen: false }),
openSettings: (target) => { openSettings: (target) => {
// Выбранный сервер живёт в снапшоте сессии: так его видят и секции // Выбранный сервер живёт в снапшоте сессии: так его видят и секции
// «Опасной зоны», которые сбрасывают выбор после выхода с сервера. // «Опасной зоны», которые сбрасывают выбор после выхода с сервера.
+105
View File
@@ -0,0 +1,105 @@
import { create } from 'zustand';
import { platform } from '@/lib/platform';
/**
* Единый реестр несохранённых изменений (docs/client-tauri.md §5).
*
* Формы с локальным черновиком регистрируют здесь запись, пока в них есть
* правки. Реестр нужен по двум причинам: платформа должна знать, перехватывать
* ли закрытие окна (один флаг на всё приложение), а диалог закрытия — уметь
* сохранить все формы по очереди, не зная о них ничего.
*/
/** Сохранение формы: та же операция, что и кнопка «Сохранить». */
export type SaveHandler = () => Promise<void>;
export interface UnsavedEntry {
id: string;
save: SaveHandler;
}
interface UnsavedState {
entries: UnsavedEntry[];
/** Обёртка попросила закрыть окно, а в реестре есть правки. */
closePrompt: boolean;
/**
* Регистрирует запись и возвращает функцию снятия. Повторная регистрация с
* тем же id заменяет обработчик: форма может пересобрать `save`.
*/
register: (id: string, save: SaveHandler) => () => void;
unregister: (id: string) => void;
/** Показать диалог закрытия (сообщение `closeRequested` от обёртки). */
requestClose: () => void;
/** Закрыть диалог без выхода: пользователь выбрал «Отмена». */
dismissClose: () => void;
/**
* Сохранить все формы по очереди. Первая же ошибка останавливает процесс и
* уходит наверх: диалог покажет её текст, а окно останется открытым.
*/
saveAll: () => Promise<void>;
/** Сброс для тестов и выхода из аккаунта. */
reset: () => void;
}
const defaults = {
entries: [] as UnsavedEntry[],
closePrompt: false,
};
export const useUnsavedStore = create<UnsavedState>((set, get) => ({
...defaults,
register: (id, save) => {
const entries = get().entries;
const index = entries.findIndex((entry) => entry.id === id);
if (index === -1) {
set({ entries: [...entries, { id, save }] });
} else {
const next = [...entries];
next[index] = { id, save };
set({ entries: next });
}
return () => get().unregister(id);
},
unregister: (id) => {
const entries = get().entries;
if (!entries.some((entry) => entry.id === id)) {
return;
}
set({ entries: entries.filter((entry) => entry.id !== id) });
},
requestClose: () => set({ closePrompt: true }),
dismissClose: () => set({ closePrompt: false }),
saveAll: async () => {
// Копия списка: `save` может изменить реестр (например, форма сбросит
// черновик и снимет регистрацию), а обход должен остаться устойчивым.
for (const entry of [...get().entries]) {
await entry.save();
}
},
reset: () => set({ ...defaults }),
}));
/** Есть ли в реестре хотя бы одна несохранённая форма. */
export function hasUnsavedChanges(state: UnsavedState): boolean {
return state.entries.length > 0;
}
/**
* Платформа узнаёт о переходе «есть правки / нет правок» ровно один раз:
* сравниваем агрегат, а не длину списка. Иначе сохранение одной из двух форм
* мигало бы `setDirty(false)` → `setDirty(true)`, и обёртка успела бы решить,
* что закрывать окно можно.
*/
useUnsavedStore.subscribe((state, previous) => {
const dirty = hasUnsavedChanges(state);
if (dirty !== hasUnsavedChanges(previous)) {
platform.setDirty(dirty);
}
});
+301
View File
@@ -0,0 +1,301 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router';
import { CommandPalette } from '@/components/CommandPalette';
import { DesktopBridge } from '@/components/DesktopBridge';
import type { GatewaySnapshot } from '@/api/gateway';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import type { DesktopMessage } from '@/lib/platform';
import { installFetch, json, renderApp } from './helpers';
/**
* Подменная платформа: сообщения обёртки раздаём вручную (`receive`), а вызовы
* клиента только записываем. Настоящую реализацию здесь не проверить — она
* выбирается при импорте модуля по наличию моста Tauri.
*/
const desktop = vi.hoisted(() => {
const handlers = new Set<(message: unknown) => void>();
return {
setDirty: vi.fn(),
closeWindow: vi.fn(),
onMessage: (handler: (message: unknown) => void) => {
handlers.add(handler);
return () => handlers.delete(handler);
},
receive: (message: unknown) => {
for (const handler of handlers) {
handler(message);
}
},
};
});
vi.mock('@/lib/platform', () => ({
platform: {
kind: 'tauri' as const,
notify: () => {},
setBadge: () => {},
ready: () => {},
openSettings: () => {},
setDirty: desktop.setDirty,
closeWindow: desktop.closeWindow,
onMessage: desktop.onMessage,
},
isTauri: () => true,
routeFromDeepLink: () => null,
}));
/** Текущий путь роутера: по нему проверяются переходы палитры. */
function LocationProbe() {
const location = useLocation();
return <span data-testid="location">{location.pathname}</span>;
}
/** Снапшот READY: два сервера, категория с комнатами и одна личная беседа. */
const snapshot: GatewaySnapshot = {
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: [
{
id: 'g-1',
name: 'Основной сервер',
owner_id: 'user-1',
is_main: true,
channels: [
{ id: 'cat-1', name: 'Текстовые', type: 'category', position: 0 },
{ id: 'c-general', name: 'общий', type: 'text', position: 1, parent_id: 'cat-1' },
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 2 },
],
roles: [],
emojis: [],
sounds: [],
voice_states: [],
member_ids: ['user-1'],
my_role_ids: [],
},
{
id: 'g-2',
name: 'Второй сервер',
owner_id: 'user-1',
is_main: false,
channels: [],
roles: [],
emojis: [],
sounds: [],
voice_states: [],
member_ids: ['user-1'],
my_role_ids: [],
},
],
dm_channels: [{ id: 'dm-1', type: 'dm', name: 'Аня', position: 0, recipient_id: 'u-2' }],
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,
};
/** Рендер палитры на заданном маршруте с подготовленным снапшотом. */
function renderPalette(path = '/app/friends') {
useSessionStore.getState().reset();
useSessionStore.getState().applyReady(snapshot);
useSessionStore.getState().selectGuild('g-1');
const router = createMemoryRouter(
[
{
path: '*',
element: (
<>
<CommandPalette />
<LocationProbe />
{/* Поле вне палитры: проверяем, что обычный ввод не перехватывается. */}
<input aria-label="Ввод" />
</>
),
},
],
{ initialEntries: [path] },
);
render(<RouterProvider router={router} />);
return { router };
}
/** Открывает палитру шорткатом Ctrl+K и возвращает поле поиска. */
async function openWithShortcut(): Promise<HTMLElement> {
await userEvent.keyboard('{Control>}k{/Control}');
return screen.findByTestId('palette-search');
}
beforeEach(() => {
desktop.setDirty.mockClear();
desktop.closeWindow.mockClear();
useUiStore.setState({ paletteOpen: false });
});
describe('палитра команд', () => {
it('открывается по Ctrl+K и показывает серверы, комнаты, беседы и действия', async () => {
renderPalette();
const search = await openWithShortcut();
expect(search).toHaveFocus();
expect(screen.getByTestId('palette-option-guild-g-1')).toHaveTextContent('Основной сервер');
expect(screen.getByTestId('palette-option-guild-g-2')).toHaveTextContent('Второй сервер');
expect(screen.getByTestId('palette-option-channel-c-general')).toHaveTextContent('общий');
expect(screen.getByTestId('palette-option-channel-c-voice')).toHaveTextContent('Голосовая');
expect(screen.getByTestId('palette-option-dm-dm-1')).toHaveTextContent('Аня');
expect(screen.getByTestId('palette-option-action-profile')).toHaveTextContent(
'Настройки профиля',
);
expect(screen.getByTestId('palette-option-action-create-guild')).toBeVisible();
// Комнаты сгруппированы по категории сервера.
expect(screen.getByRole('group', { name: 'Текстовые' })).toBeVisible();
});
it('открывается по Cmd+K из поля ввода, не перехватывая обычный ввод', async () => {
renderPalette();
const field = screen.getByLabelText('Ввод');
await userEvent.click(field);
await userEvent.type(field, 'привет');
expect(field).toHaveValue('привет');
expect(screen.queryByTestId('palette-search')).toBeNull();
await userEvent.keyboard('{Meta>}k{/Meta}');
expect(await screen.findByTestId('palette-search')).toBeVisible();
});
it('фильтрует пункты по подстроке без учёта регистра', async () => {
renderPalette();
const search = await openWithShortcut();
await userEvent.type(search, 'ОБЩИЙ');
expect(screen.getByTestId('palette-option-channel-c-general')).toBeVisible();
expect(screen.queryByTestId('palette-option-guild-g-2')).toBeNull();
expect(screen.queryByTestId('palette-option-dm-dm-1')).toBeNull();
});
it('сообщает, что ничего не найдено', async () => {
renderPalette();
const search = await openWithShortcut();
await userEvent.type(search, 'ззз');
expect(screen.getByTestId('palette-empty')).toHaveTextContent('Ничего не найдено');
});
it('переходит на комнату по Enter, стрелки двигают активный пункт', async () => {
renderPalette();
const search = await openWithShortcut();
expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-1');
await userEvent.keyboard('{ArrowDown}');
expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-2');
await userEvent.keyboard('{ArrowUp}');
expect(search).toHaveAttribute('aria-activedescendant', 'palette-option-guild-g-1');
await userEvent.clear(search);
await userEvent.type(search, 'общий');
await userEvent.keyboard('{Enter}');
await waitFor(() => {
expect(screen.getByTestId('location')).toHaveTextContent('/app/g-1/c-general');
});
// После выполнения пункта палитра закрывается.
await waitFor(() => {
expect(screen.queryByTestId('palette-search')).toBeNull();
});
});
it('открывает сервер в его первой комнате', async () => {
renderPalette();
const search = await openWithShortcut();
await userEvent.type(search, 'Второй');
await userEvent.keyboard('{Enter}');
await waitFor(() => {
expect(screen.getByTestId('location')).toHaveTextContent('/app/g-2');
});
});
it('выполняет действия: «Создать сервер» и переход в друзья', async () => {
renderPalette();
let search = await openWithShortcut();
await userEvent.type(search, 'Создать сервер');
await userEvent.keyboard('{Enter}');
expect(useUiStore.getState().guildCreateOpen).toBe(true);
search = await openWithShortcut();
await userEvent.type(search, 'Друзья');
await userEvent.keyboard('{Enter}');
await waitFor(() => {
expect(screen.getByTestId('location')).toHaveTextContent('/app/friends');
});
});
it('закрывается по Escape', async () => {
renderPalette();
await openWithShortcut();
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByTestId('palette-search')).toBeNull();
});
});
it('открывается сообщением openPalette от обёртки', async () => {
renderPalette();
render(<DesktopBridge />);
act(() => {
desktop.receive({ type: 'openPalette' } satisfies DesktopMessage);
});
expect(await screen.findByTestId('palette-search')).toBeVisible();
});
it('доступна на защищённом маршруте приложения', async () => {
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/users/@me',
response: () =>
json({
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
bio: '',
status: 'online',
custom_status: '',
custom_status_emoji: '',
is_instance_admin: false,
badges: [],
locale: 'ru',
onboarding_completed: true,
totp_enabled: false,
},
}),
},
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
]);
// Палитра смонтирована корневой оболочкой роутера, а не тестовым стендом.
renderApp('/app/empty');
await openWithShortcut();
expect(screen.getByTestId('palette-option-action-profile')).toBeVisible();
});
});
+9
View File
@@ -23,6 +23,15 @@ describe('routeFromDeepLink', () => {
expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20'); expect(routeFromDeepLink('glchat://guild/10/channel/20')).toBe('/app/10/20');
}); });
it('разбирает личную беседу', () => {
expect(routeFromDeepLink('glchat://dm/77')).toBe('/app/friends/77');
});
it('не принимает мусор в ссылке на беседу', () => {
expect(routeFromDeepLink('glchat://dm/77/лишнее')).toBeNull();
expect(routeFromDeepLink('glchat://dm')).toBeNull();
});
it('игнорирует чужие схемы и неизвестные пути', () => { it('игнорирует чужие схемы и неизвестные пути', () => {
expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull(); expect(routeFromDeepLink('https://gl.mhspx.su/invite/abc')).toBeNull();
expect(routeFromDeepLink('glchat://unknown/1')).toBeNull(); expect(routeFromDeepLink('glchat://unknown/1')).toBeNull();
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest';
import { platform } from '@/lib/platform';
/** Ставит в window фальшивый мост Tauri v2 и возвращает список вызовов. */
function stubTauri(): Array<{ command: string; args: unknown }> {
const calls: Array<{ command: string; args: unknown }> = [];
vi.stubGlobal('__TAURI_INTERNALS__', {
invoke: (command: string, args?: unknown) => {
calls.push({ command, args });
return Promise.resolve(undefined);
},
});
return calls;
}
/**
* Несохранённые изменения (docs/client-tauri.md §5): платформа должна знать о
* них, чтобы обёртка перехватила закрытие окна, а браузер — показал своё
* подтверждение. Здесь проверяются сами вызовы платформы; поведение клиента —
* в `unsavedChanges.test.tsx`.
*/
describe('setDirty в браузере', () => {
it('включает и снимает штатное подтверждение закрытия вкладки', () => {
const added: unknown[] = [];
const removed: unknown[] = [];
vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => {
if (type === 'beforeunload') {
added.push(handler);
}
});
vi.spyOn(window, 'removeEventListener').mockImplementation((type, handler) => {
if (type === 'beforeunload') {
removed.push(handler);
}
});
platform.setDirty(true);
// Повторный вызов не вешает второй обработчик: подтверждение одно на окно.
platform.setDirty(true);
expect(added).toHaveLength(1);
const event = new Event('beforeunload', { cancelable: true });
(added[0] as (event: Event) => void)(event);
expect(event.defaultPrevented).toBe(true);
platform.setDirty(false);
expect(removed).toEqual(added);
});
});
describe('команды обёртке', () => {
it('передаёт dirty и закрывает окно', async () => {
const calls = stubTauri();
vi.resetModules();
const desktop = await import('@/lib/platform');
expect(desktop.platform.kind).toBe('tauri');
desktop.platform.setDirty(true);
desktop.platform.setDirty(false);
desktop.platform.closeWindow();
expect(calls).toEqual([
{ command: 'desktop_set_dirty', args: { dirty: true } },
{ command: 'desktop_set_dirty', args: { dirty: false } },
{ command: 'desktop_close_window', args: undefined },
]);
});
});
+289
View File
@@ -0,0 +1,289 @@
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DesktopBridge } from '@/components/DesktopBridge';
import { DirtyCloseGuard } from '@/components/DirtyCloseGuard';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import type { DesktopMessage } from '@/lib/platform';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { useUnsavedStore } from '@/stores/unsaved';
import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
/**
* Подменная платформа: так же, как в тестах палитры, сообщения обёртки раздаём
* вручную, а `setDirty`/`closeWindow` только записываем.
*/
const desktop = vi.hoisted(() => {
const handlers = new Set<(message: unknown) => void>();
return {
calls: [] as string[],
setDirty: vi.fn(),
closeWindow: vi.fn(),
onMessage: (handler: (message: unknown) => void) => {
handlers.add(handler);
return () => handlers.delete(handler);
},
receive: (message: unknown) => {
for (const handler of handlers) {
handler(message);
}
},
};
});
vi.mock('@/lib/platform', () => ({
platform: {
kind: 'tauri' as const,
notify: () => {},
setBadge: () => {},
ready: () => {},
openSettings: () => {},
setDirty: desktop.setDirty,
closeWindow: desktop.closeWindow,
onMessage: desktop.onMessage,
},
isTauri: () => true,
routeFromDeepLink: () => null,
}));
const user = makeUser();
/** Форма с черновиком: то же, что делают формы настроек с кнопкой «Сохранить». */
function DraftForm({ save }: { save: () => Promise<void> }) {
const [value, setValue] = useState('');
useUnsavedChanges({ dirty: value !== '', save });
return (
<input aria-label="Черновик" value={value} onChange={(event) => setValue(event.target.value)} />
);
}
/** Приложение с реестром несохранённых изменений и формой-черновиком. */
function renderGuard(save: () => Promise<void>) {
return render(
<>
<DesktopBridge />
<DirtyCloseGuard>
<DraftForm save={save} />
</DirtyCloseGuard>
</>,
);
}
/** Правит форму так, чтобы она стала dirty. */
async function editDraft(): Promise<void> {
await userEvent.type(screen.getByLabelText('Черновик'), 'п');
await waitFor(() => {
expect(desktop.setDirty).toHaveBeenCalledWith(true);
});
}
/** Просит обёртку закрыть окно (сообщение Rust `closeRequested`). */
async function requestClose(): Promise<void> {
// Промис в `act`: сообщение обёртки меняет состояние стора вне React-события.
await act(() => {
desktop.receive({ type: 'closeRequested' } satisfies DesktopMessage);
return Promise.resolve();
});
}
/** Ожидание диалога закрытия. */
function closeDialog(): HTMLElement {
return screen.getByRole('dialog', { name: 'Есть несохранённые изменения' });
}
beforeEach(() => {
desktop.calls.length = 0;
desktop.setDirty.mockClear();
desktop.closeWindow.mockClear();
desktop.closeWindow.mockImplementation(() => {
desktop.calls.push('close');
});
// Между тестами реестр должен быть пуст: записи снимает размонтирование.
useUnsavedStore.getState().reset();
});
describe('реестр несохранённых изменений', () => {
it('сообщает платформе о появлении и исчезновении правок ровно один раз', async () => {
const { unmount } = renderGuard(() => Promise.resolve());
await editDraft();
// Второй ввод не должен дёргать платформу повторно.
await userEvent.type(screen.getByLabelText('Черновик'), 'ривет');
expect(desktop.setDirty).toHaveBeenCalledTimes(1);
unmount();
expect(desktop.setDirty).toHaveBeenLastCalledWith(false);
expect(desktop.setDirty).toHaveBeenCalledTimes(2);
});
it('без правок не считает окно dirty', async () => {
renderGuard(() => Promise.resolve());
await requestClose();
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
// Окно отпускаем: сохранять нечего, иначе приложение не закрыть.
expect(desktop.closeWindow).toHaveBeenCalled();
});
});
describe('закрытие окна при несохранённых изменениях', () => {
it('показывает диалог и трясёт контент', async () => {
renderGuard(() => Promise.resolve());
await editDraft();
await requestClose();
expect(await screen.findByText('Сохранить изменения перед выходом?')).toBeVisible();
expect(closeDialog()).toBeVisible();
expect(screen.getByTestId('app-content')).toHaveClass('gl-attention-shake');
});
it('«Сохранить» сохраняет формы и закрывает окно', async () => {
const save = vi.fn(() => {
desktop.calls.push('save');
return Promise.resolve();
});
renderGuard(save);
await editDraft();
await requestClose();
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(desktop.closeWindow).toHaveBeenCalledTimes(1);
});
expect(save).toHaveBeenCalledTimes(1);
expect(desktop.calls).toEqual(['save', 'close']);
// В браузере `window.close()` может не сработать: диалог закрываем сами.
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
});
});
it('«Выйти без сохранения» закрывает окно, не сохраняя', async () => {
const save = vi.fn(() => Promise.resolve());
renderGuard(save);
await editDraft();
await requestClose();
await userEvent.click(screen.getByRole('button', { name: 'Выйти без сохранения' }));
expect(desktop.closeWindow).toHaveBeenCalledTimes(1);
expect(save).not.toHaveBeenCalled();
expect(desktop.calls).toEqual(['close']);
});
it('«Отмена» закрывает диалог и оставляет приложение открытым', async () => {
const save = vi.fn(() => Promise.resolve());
renderGuard(save);
await editDraft();
await requestClose();
await userEvent.click(screen.getByRole('button', { name: 'Отмена' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
});
expect(desktop.closeWindow).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
// Правки на месте: палитра закрытия не сбрасывает черновик.
expect(screen.getByLabelText('Черновик')).toHaveValue('п');
});
it('Escape работает как «Отмена»', async () => {
renderGuard(() => Promise.resolve());
await editDraft();
await requestClose();
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Есть несохранённые изменения' })).toBeNull();
});
expect(desktop.closeWindow).not.toHaveBeenCalled();
});
it('оставляет диалог открытым и показывает текст ошибки', async () => {
const save = vi.fn(() => Promise.reject(new Error('Сервер отклонил правку')));
renderGuard(save);
await editDraft();
await requestClose();
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByTestId('unsaved-error')).toHaveTextContent('Сервер отклонил правку');
expect(closeDialog()).toBeVisible();
expect(desktop.closeWindow).not.toHaveBeenCalled();
});
it('при prefers-reduced-motion вместо тряски подсвечивает контент', async () => {
// Системная настройка «уменьшить движение»: движение заменяем подсветкой.
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({
matches: true,
addEventListener: () => {},
removeEventListener: () => {},
})),
);
renderGuard(() => Promise.resolve());
await editDraft();
await requestClose();
const content = screen.getByTestId('app-content');
expect(content).toHaveClass('gl-attention-flash');
expect(content).not.toHaveClass('gl-attention-shake');
});
});
describe('формы настроек в реестре', () => {
const appRoutes: FetchRoute[] = [
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
];
it('правка профиля помечает клиент dirty, сохранение снимает флаг', async () => {
installFetch([
...appRoutes,
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, display_name: 'Алиса' } }),
},
]);
renderApp('/app/empty');
// Окно настроек открывается шестерёнкой в панели пользователя.
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
const field = within(dialog).getByLabelText('Отображаемое имя');
await userEvent.clear(field);
await userEvent.type(field, 'Алиса');
await waitFor(() => {
expect(desktop.setDirty).toHaveBeenCalledWith(true);
});
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(useUnsavedStore.getState().entries).toHaveLength(0);
});
await waitFor(() => {
expect(desktop.setDirty).toHaveBeenLastCalledWith(false);
});
});
});
/** Стор сессии и UI между тестами сбрасывает `renderApp`, здесь — только UI. */
beforeEach(() => {
useSessionStore.getState().reset();
useUiStore.setState({ paletteOpen: false, settingsOpen: false });
});