Compare commits
5 Commits
eeececaff8
...
4d39002a1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d39002a1f | |||
| 72a51f083d | |||
| 085454d555 | |||
| 72c5189e8f | |||
| 498201b413 |
@@ -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 \
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`). Публичный ключ уже вшит в сборку и меняется только
|
||||||
|
вместе с ключом: старые сборки отвергнут манифест, подписанный новым ключом.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отправляет сообщение в веб-клиент.
|
/// Отправляет сообщение в веб-клиент.
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
// Отравленный мьютекс не должен ронять приложение: состояние —
|
// Отравленный мьютекс не должен ронять приложение: состояние —
|
||||||
// это только настройки и очередь событий.
|
// это только настройки и очередь событий.
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
"публичный ключ подписи должен быть вшит в сборку"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
"тряска без смещений ничего не показывает"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Запрос отменён.",
|
||||||
|
|||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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) => {
|
||||||
// Выбранный сервер живёт в снапшоте сессии: так его видят и секции
|
// Выбранный сервер живёт в снапшоте сессии: так его видят и секции
|
||||||
// «Опасной зоны», которые сбрасывают выбор после выхода с сервера.
|
// «Опасной зоны», которые сбрасывают выбор после выхода с сервера.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user