From 665625ecd8abd2c551382e38ec625f2bf9e13f26 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Sun, 14 Jun 2026 21:42:23 +1000 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20sync=20kalinamall=20(README=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/00-always-read-rules.mdc | 28 ++++++ .cursor/rules/01-context-control.mdc | 24 +++++ .cursor/rules/agent-monitors-version-bump.mdc | 18 ++++ .cursor/rules/csharp-spec.mdc | 12 +++ .cursor/rules/gitea-home-api.mdc | 37 ++++++++ .cursor/rules/repo-policy.mdc | 18 ++++ .cursorignore | 52 +++++++++++ scripts/check-github-clean.sh | 52 +++++++++++ scripts/push-remotes.sh | 65 ++++++++++---- scripts/readme/github.md | 87 ------------------- scripts/readme/home.md | 87 ------------------- 11 files changed, 288 insertions(+), 192 deletions(-) create mode 100644 .cursor/rules/00-always-read-rules.mdc create mode 100644 .cursor/rules/01-context-control.mdc create mode 100644 .cursor/rules/agent-monitors-version-bump.mdc create mode 100644 .cursor/rules/csharp-spec.mdc create mode 100644 .cursor/rules/gitea-home-api.mdc create mode 100644 .cursor/rules/repo-policy.mdc create mode 100644 .cursorignore create mode 100755 scripts/check-github-clean.sh delete mode 100644 scripts/readme/github.md delete mode 100644 scripts/readme/home.md diff --git a/.cursor/rules/00-always-read-rules.mdc b/.cursor/rules/00-always-read-rules.mdc new file mode 100644 index 0000000..35460ae --- /dev/null +++ b/.cursor/rules/00-always-read-rules.mdc @@ -0,0 +1,28 @@ +--- +description: Перед работой — прочитай все применимые rules +alwaysApply: true +--- + +# Всегда читай правила + +Перед **любой** задачей (код, git, push, релиз, ответ пользователю): + +1. **Прочитай все применимые rules** — user rules, workspace `.cursor/rules/`, особенно с `alwaysApply: true`. +2. **Не начинай работу**, пока не понял ограничения из rules (git, Cursor в репо, version-bump, язык ответа и т.д.). +3. **При конфликте** запроса пользователя и rule — остановись, объясни конфликт, спроси подтверждение. + +## Обязательные проверки по типу задачи + +| Задача | Перед действием прочитай / проверь | +|--------|-------------------------------------| +| `git commit` / `push` | `no-cursor-in-repositories` — нет `.cursor/` в staged; `.gitignore` содержит `.cursor/` | +| Push в `home` | `gitea-home-api` — сначала API, потом SSH push | +| Bump версии | `version-bump` (+ `version-bump-rdp` / `version-bump-ssh` для агентских мониторов) | +| Коммит | `git log -1 --format=fuller` — нет `cursoragent`, `Co-authored-by: Cursor` | + +## Если rule нарушен + +- **Не push**, не «доделывай быстрее». +- Исправь (amend / filter / commit-tree), затем снова проверь лог. + +Правила важнее скорости выполнения задачи. \ No newline at end of file diff --git a/.cursor/rules/01-context-control.mdc b/.cursor/rules/01-context-control.mdc new file mode 100644 index 0000000..b1f0ebc --- /dev/null +++ b/.cursor/rules/01-context-control.mdc @@ -0,0 +1,24 @@ +--- +description: Git, контекст файлов и формат ответов +alwaysApply: true +--- + +# Контекст и дисциплина + +## Git + +- **Не** `git commit` / **не** `git push` без явной команды пользователя. +- Перед commit: покажи `git diff --stat`, дождись подтверждения. +- Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`. + +## Контекст + +- Файлы: `@`, открытые вкладки, явные пути — не сканируй весь репо без запроса. +- Не грузи файлы >500 строк без необходимости. +- Локальные копии; не качай пакеты/URL без ошибки сборки или явного запроса. + +## Ответы + +- Язык: **русский** (если пользователь не переключился). +- По коду — diff/фрагменты, не дублируй неизменённые блоки. +- Без воды, но достаточно полно для понимания (не обрезай сложные ответы до одной строки). diff --git a/.cursor/rules/agent-monitors-version-bump.mdc b/.cursor/rules/agent-monitors-version-bump.mdc new file mode 100644 index 0000000..1e69d89 --- /dev/null +++ b/.cursor/rules/agent-monitors-version-bump.mdc @@ -0,0 +1,18 @@ +--- +description: Bump версии RDP/SSH агентов — иначе ПК не обновятся +globs: **/Login_Monitor.ps1,**/Deploy-LoginMonitor.ps1,**/Sac-Client.ps1,**/ssh-monitor,**/sac-client.sh,**/update_ssh_monitor.sh +alwaysApply: false +--- + +# Версии агентских мониторов + +Любая **существенная правка** без bump **не доедет** до компьютеров. + +| Стек | Файлы | Поле / файл версии | +|------|-------|-------------------| +| RDP | `Login_Monitor.ps1`, `version.txt` | `$ScriptVersion` = `version.txt` | +| SSH | `ssh-monitor`, `version.txt` | `SSH_MONITOR_VERSION` = `version.txt` | + +Подробности: `version-bump-rdp.mdc`, `version-bump-ssh.mdc`. + +**SAC backend:** `backend/app/version.py` (`APP_VERSION`) — отдельный релиз, на агенты ПК не влияет. diff --git a/.cursor/rules/csharp-spec.mdc b/.cursor/rules/csharp-spec.mdc new file mode 100644 index 0000000..f49a451 --- /dev/null +++ b/.cursor/rules/csharp-spec.mdc @@ -0,0 +1,12 @@ +--- +description: Краткий C# без лишних токенов в выводе +globs: **/*.cs +alwaysApply: false +--- + +# C# — компактный вывод + +- File-scoped namespaces, pattern matching, target-typed `new()`, primary constructors где уместно. +- Без XML-комментариев, `#region`, лишних `using`. +- Без `Console.WriteLine` / debug-log, если задача не про логирование. +- Expression-bodied members, где читаемо. diff --git a/.cursor/rules/gitea-home-api.mdc b/.cursor/rules/gitea-home-api.mdc new file mode 100644 index 0000000..f2e74f1 --- /dev/null +++ b/.cursor/rules/gitea-home-api.mdc @@ -0,0 +1,37 @@ +--- +description: Gitea home — репо через API, не вручную в вебе +alwaysApply: true +--- + +# Gitea home (papatramp) + +При push в **`home`**, настройке remotes или первом заливе — **через Gitea API**. Не проси создать репо вручную при `Push to create is not enabled` / `Cannot find repository`. + +## Пути (локально, не в git-репо) + +| Назначение | Путь | +|------------|------| +| Конфиг API (секреты) | `$HOME\CursorRules\local\papatramp-gitea.conf` | +| Remotes | `$HOME\CursorRules\scripts\Set-GitRemotes.ps1` | +| Создать репо на home | `$HOME\CursorRules\scripts\Ensure-GiteaHomeRepo.ps1` | + +`init-cursor` копирует в репо **только `*.mdc`**. + +SSH **`home`**: `ssh://git@git.papatramp.lan:2222/PapaTramp/.git` + +## Алгоритм + +1. `GET {GiteaApiUrl}/api/v1/repos/PapaTramp/{RepoName}` — Basic Auth из конфига. +2. Если **404** — `POST {GiteaApiUrl}/api/v1/user/repos` (или `.../orgs/PapaTramp/repos`), тело: `{ "name": "", "private": true, "auto_init": false }`. + Или: `Ensure-GiteaHomeRepo.ps1 -RepoName `. +3. `git push home ` (SSH). + +## Запрещено + +- «Создайте репо в Gitea сами» без попытки API. +- Коммитить `papatramp-gitea.conf`, пароли, временные скрипты с credentials. +- HTTPS/`origin` для home вместо remote **`home`**. + +## API недоступен + +Сообщи, что LAN/API (`192.168.128.48:3000`) недоступен — VPN/сеть. Не отправляй в веб-интерфейс. diff --git a/.cursor/rules/repo-policy.mdc b/.cursor/rules/repo-policy.mdc new file mode 100644 index 0000000..cfeca78 --- /dev/null +++ b/.cursor/rules/repo-policy.mdc @@ -0,0 +1,18 @@ +--- +description: Политика remotes и push — kalinamall/home/github +alwaysApply: true +--- + +# Git remotes (kalinamall.ru) + +- **kalinamall** — основной Gitea, полный код и секреты. `git push kalinamall main`. +- **home** — домашний Gitea, полная копия. +- **github** — только резерв **без секретов**. Перед push: `check-no-secrets.sh`. +- Remotes только **SSH** (`ssh://`, `git@`). HTTPS и `origin` не использовать. +- Не коммитить в GitHub: `.env`, ключи, пароли, токены, `google-services.json`, prod credentials. +- См. `docs/repo-policy.md` в reverse-proxy. + +# Cursor + +- Отвечать на русском, если пользователь пишет по-русски. +- Коммиты только по явной просьбе пользователя. diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..80d8e9c --- /dev/null +++ b/.cursorignore @@ -0,0 +1,52 @@ +node_modules/ +bower_components/ +jspm_packages/ +.npm/ +vendor/ +.pnpm-store/ +.venv/ +venv/ +env/ +ENV/ +.env/ +dist/ +build/ +out/ +.next/ +.nuxt/ +.docusaurus/ +.cache/ +.sass-cache/ +.turbo/ +target/ +bin/ +obj/ +.git/ +.svn/ +.idea/ +.vscode/ +.cursor/ +*.log +*.png +*.jpg +*.jpeg +*.gif +*.svg +*.ico +*.mp4 +*.mp3 +*.pdf +*.zip +*.tar.gz +*.rar +*.exe +*.dll +*.woff +*.woff2 +*.ttf +*.eot +package-lock.json +yarn.lock +pnpm-lock.yaml +poetry.lock + diff --git a/scripts/check-github-clean.sh b/scripts/check-github-clean.sh new file mode 100755 index 0000000..bf94d7f --- /dev/null +++ b/scripts/check-github-clean.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Перед push на github: нет internal URL и файлов других зеркал. +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +PATTERNS=( + 'git\.kalinamall\.ru' + 'git\.papatramp\.ru' + 'papatramp\.lan' + '192\.168\.' + 'kalinamall/main' + 'push.*kalinamall' +) + +scan_files() { + local hits=0 + local files + files=$(git ls-files) + while read -r f; do + [[ -z $f ]] && continue + [[ -f $f ]] || continue + case $f in + scripts/check-github-clean.sh | scripts/push-remotes.sh) continue ;; + esac + for pat in "${PATTERNS[@]}"; do + if grep -qE "$pat" "$f" 2>/dev/null; then + echo " INTERNAL? $f (pattern: $pat)" + hits=$((hits + 1)) + fi + done + done <<<"$files" + return $hits +} + +echo "[check-github-clean] сканирование tracked-файлов" +n=0 +scan_files || n=$? +if [[ $n -gt 0 ]]; then + echo "[check-github-clean] найдено: $n" + exit 1 +fi + +for f in scripts/readme/kalinamall.md scripts/readme/home.md; do + if [[ -f $f ]]; then + echo " FORBIDDEN FILE: $f" + exit 1 + fi +done + +echo "[check-github-clean] OK" diff --git a/scripts/push-remotes.sh b/scripts/push-remotes.sh index 88ba407..6fae790 100755 --- a/scripts/push-remotes.sh +++ b/scripts/push-remotes.sh @@ -1,46 +1,75 @@ #!/usr/bin/env bash -# Push README.md с правильным содержимым на каждый remote (ветки README расходятся). +# Синхронизация main на kalinamall / home / github: свой README и только свои readme-шаблоны. set -Eeuo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" CHECK_SECRETS="${CHECK_SECRETS:-$HOME/Documents/Cursor/Projects/Answer.and.other.shit/guides/init.new.comps/scripts/check-no-secrets.sh}" +CHECK_GITHUB="${CHECK_GITHUB:-$ROOT/scripts/check-github-clean.sh}" -push_readme() { +sync_remote() { local remote=$1 - local template=$2 - local message=$3 - local branch="push-${remote}-readme" + local readme_template=$2 + local branch="sync-${remote}" + shift 2 + local -a remove=( "$@" ) git fetch "$remote" git checkout -B "$branch" "${remote}/main" - cp "$template" README.md - git add README.md + git checkout main -- . + cp "$readme_template" README.md + + for f in "${remove[@]}"; do + if [[ -e $f ]]; then + rm -rf "$f" + fi + done + + git add -A if git diff --cached --quiet; then - echo "[push-readme] $remote: README без изменений" + echo "[push-remotes] $remote: без изменений" else - git commit -m "$message" + git commit -m "chore: sync $remote (README и файлы зеркала)" fi - if [[ $remote == github ]] && [[ -f $CHECK_SECRETS ]]; then - bash "$CHECK_SECRETS" github + + if [[ $remote == github ]]; then + if [[ -f $CHECK_GITHUB ]]; then + bash "$CHECK_GITHUB" + fi + if [[ -f $CHECK_SECRETS ]]; then + bash "$CHECK_SECRETS" github + fi fi + git push "$remote" "${branch}:main" git checkout main git branch -D "$branch" } -echo "[push-readme] kalinamall: push текущего main" -git push kalinamall main +echo "[push-remotes] kalinamall" +sync_remote kalinamall "$ROOT/scripts/readme/kalinamall.md" \ + scripts/readme/github.md \ + scripts/readme/home.md -push_readme home "$ROOT/scripts/readme/home.md" "docs: README для home (git.papatramp.ru)" -push_readme github "$ROOT/scripts/readme/github.md" "docs: README для GitHub (без internal URL)" +echo "[push-remotes] home" +sync_remote home "$ROOT/scripts/readme/home.md" \ + scripts/readme/github.md \ + scripts/readme/kalinamall.md \ + scripts/push-remotes.sh \ + scripts/check-github-clean.sh + +echo "[push-remotes] github" +sync_remote github "$ROOT/scripts/readme/github.md" \ + scripts/readme/kalinamall.md \ + scripts/readme/home.md \ + scripts/push-remotes.sh \ + scripts/check-github-clean.sh cp "$ROOT/scripts/readme/kalinamall.md" README.md git add README.md if ! git diff --cached --quiet; then - git commit -m "docs: README для kalinamall (рабочая копия)" + git commit -m "docs: README kalinamall (рабочая копия main)" fi -git push kalinamall main -echo "[push-readme] готово." +echo "[push-remotes] готово." diff --git a/scripts/readme/github.md b/scripts/readme/github.md deleted file mode 100644 index 6c205a7..0000000 --- a/scripts/readme/github.md +++ /dev/null @@ -1,87 +0,0 @@ -# Seaca - -**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android. - -Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). - -## Связанные репозитории - -| Репозиторий | Роль | -|-------------|------| -| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | -| ssh-monitor | Агент Linux | -| RDP-login-monitor | Агент Windows | - -| Репозиторий | URL | -|-------------|-----| -| security-alert-center | https://github.com/PTah/security-alert-center | -| ssh-monitor | https://github.com/PTah/ssh-monitor | -| RDP-login-monitor | https://github.com/PTah/RDP-login-monitor | -## Стек - -| Слой | Технология | -|------|------------| -| UI | Kotlin, Jetpack Compose, Material 3 | -| Сеть | Retrofit / OkHttp, Kotlin Serialization | -| Хранение | DataStore + Android Keystore (токены, URL сервера) | -| Push | **Firebase Cloud Messaging (FCM)** | -| Минимум SDK | Android 8.0 (API 26) | - -## Что есть в приложении - -- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) -- Pull-to-refresh на обзоре и в списках -- Фильтры событий и проблем (severity, status) -- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль) -- Ack / Resolve для роли `operator` и выше -- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено -- Выбор звука уведомления: системный или свой (ringtone picker) -- Биометрическая блокировка при **запуске** и при возврате в приложение -- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход -- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC - -## Чего нет в приложении (только веб) - -- Управление пользователями SAC -- Настройки каналов (Telegram, SMTP, webhook), severity, policy -- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере - -## Привязка телефона к серверу - -1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств». -2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований). -3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии). -4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере. -5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать. - -Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md). - -## Статус - -**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты) - -## Документация - -| Документ | Назначение | -|----------|------------| -| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора | -| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки | - -## Сборка - -Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). - -```bash -# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM -./gradlew assembleDebug -# Windows: -gradlew.bat assembleDebug -``` - -APK: `app/build/outputs/apk/debug/app-debug.apk` - -Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). - -## Лицензия - -Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). diff --git a/scripts/readme/home.md b/scripts/readme/home.md deleted file mode 100644 index 2a7e0fe..0000000 --- a/scripts/readme/home.md +++ /dev/null @@ -1,87 +0,0 @@ -# Seaca - -**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android. - -Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). - -## Связанные репозитории - -| Репозиторий | Роль | -|-------------|------| -| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | -| ssh-monitor | Агент Linux | -| RDP-login-monitor | Агент Windows | - -| Репозиторий | URL | -|-------------|-----| -| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center | -| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor | -| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/RDP-login-monitor | -## Стек - -| Слой | Технология | -|------|------------| -| UI | Kotlin, Jetpack Compose, Material 3 | -| Сеть | Retrofit / OkHttp, Kotlin Serialization | -| Хранение | DataStore + Android Keystore (токены, URL сервера) | -| Push | **Firebase Cloud Messaging (FCM)** | -| Минимум SDK | Android 8.0 (API 26) | - -## Что есть в приложении - -- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) -- Pull-to-refresh на обзоре и в списках -- Фильтры событий и проблем (severity, status) -- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль) -- Ack / Resolve для роли `operator` и выше -- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено -- Выбор звука уведомления: системный или свой (ringtone picker) -- Биометрическая блокировка при **запуске** и при возврате в приложение -- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход -- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC - -## Чего нет в приложении (только веб) - -- Управление пользователями SAC -- Настройки каналов (Telegram, SMTP, webhook), severity, policy -- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере - -## Привязка телефона к серверу - -1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств». -2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований). -3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии). -4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере. -5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать. - -Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md). - -## Статус - -**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты) - -## Документация - -| Документ | Назначение | -|----------|------------| -| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора | -| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки | - -## Сборка - -Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). - -```bash -# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM -./gradlew assembleDebug -# Windows: -gradlew.bat assembleDebug -``` - -APK: `app/build/outputs/apk/debug/app-debug.apk` - -Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). - -## Лицензия - -Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). From 9966c9f0301630da22f4cf7c7acc2f1c58fe1d68 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Sun, 14 Jun 2026 21:42:45 +1000 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20sync=20kalinamall=20(README=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/00-always-read-rules.mdc | 28 ---------- .cursor/rules/01-context-control.mdc | 24 --------- .cursor/rules/agent-monitors-version-bump.mdc | 18 ------- .cursor/rules/csharp-spec.mdc | 12 ----- .cursor/rules/gitea-home-api.mdc | 37 ------------- .cursor/rules/repo-policy.mdc | 18 ------- .cursorignore | 52 ------------------- .gitignore | 6 ++- scripts/push-remotes.sh | 5 +- 9 files changed, 9 insertions(+), 191 deletions(-) delete mode 100644 .cursor/rules/00-always-read-rules.mdc delete mode 100644 .cursor/rules/01-context-control.mdc delete mode 100644 .cursor/rules/agent-monitors-version-bump.mdc delete mode 100644 .cursor/rules/csharp-spec.mdc delete mode 100644 .cursor/rules/gitea-home-api.mdc delete mode 100644 .cursor/rules/repo-policy.mdc delete mode 100644 .cursorignore diff --git a/.cursor/rules/00-always-read-rules.mdc b/.cursor/rules/00-always-read-rules.mdc deleted file mode 100644 index 35460ae..0000000 --- a/.cursor/rules/00-always-read-rules.mdc +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Перед работой — прочитай все применимые rules -alwaysApply: true ---- - -# Всегда читай правила - -Перед **любой** задачей (код, git, push, релиз, ответ пользователю): - -1. **Прочитай все применимые rules** — user rules, workspace `.cursor/rules/`, особенно с `alwaysApply: true`. -2. **Не начинай работу**, пока не понял ограничения из rules (git, Cursor в репо, version-bump, язык ответа и т.д.). -3. **При конфликте** запроса пользователя и rule — остановись, объясни конфликт, спроси подтверждение. - -## Обязательные проверки по типу задачи - -| Задача | Перед действием прочитай / проверь | -|--------|-------------------------------------| -| `git commit` / `push` | `no-cursor-in-repositories` — нет `.cursor/` в staged; `.gitignore` содержит `.cursor/` | -| Push в `home` | `gitea-home-api` — сначала API, потом SSH push | -| Bump версии | `version-bump` (+ `version-bump-rdp` / `version-bump-ssh` для агентских мониторов) | -| Коммит | `git log -1 --format=fuller` — нет `cursoragent`, `Co-authored-by: Cursor` | - -## Если rule нарушен - -- **Не push**, не «доделывай быстрее». -- Исправь (amend / filter / commit-tree), затем снова проверь лог. - -Правила важнее скорости выполнения задачи. \ No newline at end of file diff --git a/.cursor/rules/01-context-control.mdc b/.cursor/rules/01-context-control.mdc deleted file mode 100644 index b1f0ebc..0000000 --- a/.cursor/rules/01-context-control.mdc +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Git, контекст файлов и формат ответов -alwaysApply: true ---- - -# Контекст и дисциплина - -## Git - -- **Не** `git commit` / **не** `git push` без явной команды пользователя. -- Перед commit: покажи `git diff --stat`, дождись подтверждения. -- Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`. - -## Контекст - -- Файлы: `@`, открытые вкладки, явные пути — не сканируй весь репо без запроса. -- Не грузи файлы >500 строк без необходимости. -- Локальные копии; не качай пакеты/URL без ошибки сборки или явного запроса. - -## Ответы - -- Язык: **русский** (если пользователь не переключился). -- По коду — diff/фрагменты, не дублируй неизменённые блоки. -- Без воды, но достаточно полно для понимания (не обрезай сложные ответы до одной строки). diff --git a/.cursor/rules/agent-monitors-version-bump.mdc b/.cursor/rules/agent-monitors-version-bump.mdc deleted file mode 100644 index 1e69d89..0000000 --- a/.cursor/rules/agent-monitors-version-bump.mdc +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Bump версии RDP/SSH агентов — иначе ПК не обновятся -globs: **/Login_Monitor.ps1,**/Deploy-LoginMonitor.ps1,**/Sac-Client.ps1,**/ssh-monitor,**/sac-client.sh,**/update_ssh_monitor.sh -alwaysApply: false ---- - -# Версии агентских мониторов - -Любая **существенная правка** без bump **не доедет** до компьютеров. - -| Стек | Файлы | Поле / файл версии | -|------|-------|-------------------| -| RDP | `Login_Monitor.ps1`, `version.txt` | `$ScriptVersion` = `version.txt` | -| SSH | `ssh-monitor`, `version.txt` | `SSH_MONITOR_VERSION` = `version.txt` | - -Подробности: `version-bump-rdp.mdc`, `version-bump-ssh.mdc`. - -**SAC backend:** `backend/app/version.py` (`APP_VERSION`) — отдельный релиз, на агенты ПК не влияет. diff --git a/.cursor/rules/csharp-spec.mdc b/.cursor/rules/csharp-spec.mdc deleted file mode 100644 index f49a451..0000000 --- a/.cursor/rules/csharp-spec.mdc +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Краткий C# без лишних токенов в выводе -globs: **/*.cs -alwaysApply: false ---- - -# C# — компактный вывод - -- File-scoped namespaces, pattern matching, target-typed `new()`, primary constructors где уместно. -- Без XML-комментариев, `#region`, лишних `using`. -- Без `Console.WriteLine` / debug-log, если задача не про логирование. -- Expression-bodied members, где читаемо. diff --git a/.cursor/rules/gitea-home-api.mdc b/.cursor/rules/gitea-home-api.mdc deleted file mode 100644 index f2e74f1..0000000 --- a/.cursor/rules/gitea-home-api.mdc +++ /dev/null @@ -1,37 +0,0 @@ ---- -description: Gitea home — репо через API, не вручную в вебе -alwaysApply: true ---- - -# Gitea home (papatramp) - -При push в **`home`**, настройке remotes или первом заливе — **через Gitea API**. Не проси создать репо вручную при `Push to create is not enabled` / `Cannot find repository`. - -## Пути (локально, не в git-репо) - -| Назначение | Путь | -|------------|------| -| Конфиг API (секреты) | `$HOME\CursorRules\local\papatramp-gitea.conf` | -| Remotes | `$HOME\CursorRules\scripts\Set-GitRemotes.ps1` | -| Создать репо на home | `$HOME\CursorRules\scripts\Ensure-GiteaHomeRepo.ps1` | - -`init-cursor` копирует в репо **только `*.mdc`**. - -SSH **`home`**: `ssh://git@git.papatramp.lan:2222/PapaTramp/.git` - -## Алгоритм - -1. `GET {GiteaApiUrl}/api/v1/repos/PapaTramp/{RepoName}` — Basic Auth из конфига. -2. Если **404** — `POST {GiteaApiUrl}/api/v1/user/repos` (или `.../orgs/PapaTramp/repos`), тело: `{ "name": "", "private": true, "auto_init": false }`. - Или: `Ensure-GiteaHomeRepo.ps1 -RepoName `. -3. `git push home ` (SSH). - -## Запрещено - -- «Создайте репо в Gitea сами» без попытки API. -- Коммитить `papatramp-gitea.conf`, пароли, временные скрипты с credentials. -- HTTPS/`origin` для home вместо remote **`home`**. - -## API недоступен - -Сообщи, что LAN/API (`192.168.128.48:3000`) недоступен — VPN/сеть. Не отправляй в веб-интерфейс. diff --git a/.cursor/rules/repo-policy.mdc b/.cursor/rules/repo-policy.mdc deleted file mode 100644 index cfeca78..0000000 --- a/.cursor/rules/repo-policy.mdc +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Политика remotes и push — kalinamall/home/github -alwaysApply: true ---- - -# Git remotes (kalinamall.ru) - -- **kalinamall** — основной Gitea, полный код и секреты. `git push kalinamall main`. -- **home** — домашний Gitea, полная копия. -- **github** — только резерв **без секретов**. Перед push: `check-no-secrets.sh`. -- Remotes только **SSH** (`ssh://`, `git@`). HTTPS и `origin` не использовать. -- Не коммитить в GitHub: `.env`, ключи, пароли, токены, `google-services.json`, prod credentials. -- См. `docs/repo-policy.md` в reverse-proxy. - -# Cursor - -- Отвечать на русском, если пользователь пишет по-русски. -- Коммиты только по явной просьбе пользователя. diff --git a/.cursorignore b/.cursorignore deleted file mode 100644 index 80d8e9c..0000000 --- a/.cursorignore +++ /dev/null @@ -1,52 +0,0 @@ -node_modules/ -bower_components/ -jspm_packages/ -.npm/ -vendor/ -.pnpm-store/ -.venv/ -venv/ -env/ -ENV/ -.env/ -dist/ -build/ -out/ -.next/ -.nuxt/ -.docusaurus/ -.cache/ -.sass-cache/ -.turbo/ -target/ -bin/ -obj/ -.git/ -.svn/ -.idea/ -.vscode/ -.cursor/ -*.log -*.png -*.jpg -*.jpeg -*.gif -*.svg -*.ico -*.mp4 -*.mp3 -*.pdf -*.zip -*.tar.gz -*.rar -*.exe -*.dll -*.woff -*.woff2 -*.ttf -*.eot -package-lock.json -yarn.lock -pnpm-lock.yaml -poetry.lock - diff --git a/.gitignore b/.gitignore index c82221a..3fbf6e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Android / Gradle +# Android / Gradle *.iml .gradle/ /local.properties @@ -27,3 +27,7 @@ app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt # Env .env .env.* + +# Cursor (не в git — см. repo-policy) +.cursor/ +.cursorignore diff --git a/scripts/push-remotes.sh b/scripts/push-remotes.sh index 6fae790..06cfb1c 100755 --- a/scripts/push-remotes.sh +++ b/scripts/push-remotes.sh @@ -26,7 +26,10 @@ sync_remote() { fi done - git add -A + rm -rf .cursor .cursorignore + + git add -u . + git add README.md if git diff --cached --quiet; then echo "[push-remotes] $remote: без изменений" else From 823ac4df5799ada60fcbff82a1909aa42e3ea00c Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 16 Jun 2026 11:57:04 +1000 Subject: [PATCH 3/8] chore(docs): GitHub links and sac-api.example.com placeholders Co-authored-by: Cursor --- .gitignore | 2 +- README.md | 15 ++++++--------- docs/ROADMAP.md | 2 +- docs/operator-guide.md | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 3fbf6e3..a13d5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,6 @@ app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt .env .env.* -# Cursor (не в git — см. repo-policy) +# Cursor (не в git) .cursor/ .cursorignore diff --git a/README.md b/README.md index 001c3e6..7831b48 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Seaca -**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android. +**Seaca** (Security Event Alert Center App) — нативный мобильный клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center) для Android. Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). @@ -8,15 +8,10 @@ | Репозиторий | Роль | |-------------|------| -| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | -| ssh-monitor | Агент Linux | -| RDP-login-monitor | Агент Windows | +| [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | +| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Агент Linux | +| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Агент Windows | -| Репозиторий | URL | -|-------------|-----| -| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center | -| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor | -| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/RDP-login-monitor | ## Стек | Слой | Технология | @@ -66,6 +61,8 @@ |----------|------------| | [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора | | [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки | +| [SAC: seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) | Админ: коды, устройства, сессия | +| [SAC: seaca-fcm.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-fcm.md) | FCM на сервере | ## Сборка diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index baa4a09..ed4a2be 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap — Seaca -Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в репозитории **security-alert-center**, фаза **v0.6 — Mobile**. +Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center), фаза **v0.6 — Mobile**. --- diff --git a/docs/operator-guide.md b/docs/operator-guide.md index b30bbd2..dfbf151 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -43,4 +43,4 @@ Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC. -Подробности для админов — в репозитории **security-alert-center**, файл `docs/seaca-mobile.md`. +Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC). From 4f9a241ceab1efe4abf74b1762ab00abe6ae54bb Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:02:59 +1000 Subject: [PATCH 4/8] feat: RDG flap qwinsta/logoff via SAC API (0.5.8) Mobile triggers POST events/.../actions/qwinsta and logoff; SAC runs WinRM on the client PC. List and event detail show RDG flap marker. --- README.md | 85 +++--------- app/build.gradle.kts | 4 +- .../java/ru/kalinamall/seaca/data/SacApi.kt | 9 ++ .../ru/kalinamall/seaca/data/SacModels.kt | 24 ++++ .../ru/kalinamall/seaca/data/SacRepository.kt | 2 +- .../seaca/ui/screens/DetailScreens.kt | 127 ++++++++++++++++++ .../seaca/ui/screens/ListScreens.kt | 7 +- .../ru/kalinamall/seaca/ui/util/RdgQwinsta.kt | 71 ++++++++++ docs/ROADMAP.md | 2 +- docs/operator-guide.md | 51 +++---- 10 files changed, 284 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt diff --git a/README.md b/README.md index 7831b48..a809bfe 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,43 @@ # Seaca -**Seaca** (Security Event Alert Center App) — нативный мобильный клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center) для Android. +Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center). -Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). +**Версия:** `0.5.8` (versionCode 22) -## Связанные репозитории +## Возможности -| Репозиторий | Роль | -|-------------|------| -| [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | -| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Агент Linux | -| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Агент Windows | +- Обзор, события, проблемы, хосты, отчёты (API веб-SAC) +- Push (FCM), режимы звука, выбор ringtone +- Ack / Resolve проблем (monitor+) +- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК +- Биометрия при запуске и возврате в приложение +- Формат даты, экран «Моё устройство» -## Стек +## Нет в приложении (только веб-SAC) -| Слой | Технология | -|------|------------| -| UI | Kotlin, Jetpack Compose, Material 3 | -| Сеть | Retrofit / OkHttp, Kotlin Serialization | -| Хранение | DataStore + Android Keystore (токены, URL сервера) | -| Push | **Firebase Cloud Messaging (FCM)** | -| Минимум SDK | Android 8.0 (API 26) | +Пользователи, каналы оповещений, WinRM domain admin, коды регистрации. -## Что есть в приложении +## Привязка -- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) -- Pull-to-refresh на обзоре и в списках -- Фильтры событий и проблем (severity, status) -- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль) -- Ack / Resolve для роли `operator` и выше -- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено -- Выбор звука уведомления: системный или свой (ringtone picker) -- Биометрическая блокировка при **запуске** и при возврате в приложение -- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход -- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC +1. Админ: мобильные устройства + код `sacmob_…` +2. URL API SAC + код + логин/пароль в Seaca +3. FCM-токен регистрируется автоматически -## Чего нет в приложении (только веб) - -- Управление пользователями SAC -- Настройки каналов (Telegram, SMTP, webhook), severity, policy -- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере - -## Привязка телефона к серверу - -1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств». -2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований). -3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии). -4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере. -5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать. - -Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md). - -## Статус - -**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты) - -## Документация - -| Документ | Назначение | -|----------|------------| -| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора | -| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки | -| [SAC: seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) | Админ: коды, устройства, сессия | -| [SAC: seaca-fcm.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-fcm.md) | FCM на сервере | +[docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) ## Сборка -Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). +JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM. ```bash -# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM ./gradlew assembleDebug -# Windows: -gradlew.bat assembleDebug ``` APK: `app/build/outputs/apk/debug/app-debug.apk` -Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). +## Репозитории -## Лицензия - -Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). +| Репозиторий | Роль | +|-------------|------| +| security-alert-center | Сервер API | +| ssh-monitor / RDP-login-monitor | Агенты | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b9121c..5060684 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 21 - versionName = "0.5.7" + versionCode = 22 + versionName = "0.5.8" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt index 871f9d7..3b43364 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt @@ -36,6 +36,15 @@ interface SacApi { @GET("api/v1/events/{id}") suspend fun getEvent(@Path("id") id: Long): EventDetail + @POST("api/v1/events/{id}/actions/qwinsta") + suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse + + @POST("api/v1/events/{id}/actions/logoff") + suspend fun postEventLogoff( + @Path("id") id: Long, + @Body body: LogoffActionRequest, + ): AgentCommandResponse + @GET("api/v1/problems") suspend fun listProblems( @Query("page") page: Int = 1, diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt index 793a3f0..4d8b68c 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt @@ -58,6 +58,9 @@ data class EventSummary( val title: String, val summary: String, @SerialName("actor_user") val actorUser: String? = null, + @SerialName("rdg_flap") val rdgFlap: Boolean = false, + @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null, + @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null, ) @Serializable @@ -80,6 +83,10 @@ data class EventDetail( val severity: String, val title: String, val summary: String, + @SerialName("actor_user") val actorUser: String? = null, + @SerialName("rdg_flap") val rdgFlap: Boolean = false, + @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null, + @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null, val details: JsonElement? = null, val raw: JsonElement? = null, val payload: JsonElement? = null, @@ -192,6 +199,23 @@ data class ProblemActionResponse( val status: String, ) +@Serializable +data class LogoffActionRequest( + @SerialName("session_id") val sessionId: Int, +) + +@Serializable +data class AgentCommandResponse( + @SerialName("command_uuid") val commandUuid: String, + @SerialName("command_type") val commandType: String, + val status: String, + @SerialName("result_stdout") val resultStdout: String? = null, + @SerialName("result_stderr") val resultStderr: String? = null, + val target: String? = null, + @SerialName("client_hostname") val clientHostname: String? = null, + @SerialName("internal_ip") val internalIp: String? = null, +) + @Serializable data class ApiErrorBody( val detail: String? = null, diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt index 2a4347a..a36244b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt @@ -117,7 +117,7 @@ class SacRepository(context: Context) { } val okhttp = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) .sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll)) .hostnameVerifier(TlsTrust.hostnameVerifier(trustAll)) .addInterceptor(authInterceptor) diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index 358f089..b92d161 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -17,7 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -55,7 +58,13 @@ import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.NotificationSoundSettings import ru.kalinamall.seaca.data.NotificationSoundSource import ru.kalinamall.seaca.data.ProblemDetail +import ru.kalinamall.seaca.data.LogoffActionRequest import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.util.QwinstaSessionRow +import ru.kalinamall.seaca.ui.util.agentCommandError +import ru.kalinamall.seaca.ui.util.hasRdgFlapUi +import ru.kalinamall.seaca.ui.util.parseQwinstaSessions +import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId import ru.kalinamall.seaca.push.NotificationHelper import ru.kalinamall.seaca.push.NotificationSoundResolver import ru.kalinamall.seaca.ui.RingtonePicker @@ -78,6 +87,14 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } + var qwinstaOpen by remember { mutableStateOf(false) } + var qwinstaLoading by remember { mutableStateOf(false) } + var qwinstaError by remember { mutableStateOf(null) } + var qwinstaStdout by remember { mutableStateOf(null) } + var qwinstaSessions by remember { mutableStateOf>(emptyList()) } + var qwinstaMeta by remember { mutableStateOf(null) } + var logoffSessionId by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -93,8 +110,49 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } + fun runQwinsta(targetEventId: Long, actorUser: String?) { + scope.launch { + qwinstaLoading = true + qwinstaError = null + qwinstaStdout = null + qwinstaSessions = emptyList() + try { + val cmd = repo.withAuth { it.postEventQwinsta(targetEventId) } + qwinstaMeta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target) + .joinToString(" · ") + .ifBlank { null } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + qwinstaError = cmdError + } else { + val out = cmd.resultStdout.orEmpty() + qwinstaStdout = out.ifBlank { null } + qwinstaSessions = parseQwinstaSessions(out, actorUser) + } + } catch (e: Exception) { + qwinstaError = if (e is retrofit2.HttpException) SacRepository.parseError(e) else e.message + } finally { + qwinstaLoading = false + } + } + } + DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) { val d = data ?: return@DetailFrame + if (hasRdgFlapUi(d)) { + Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя") + d.rdgFlapPairEventId?.let { Field("Пара", "#$it") } + rdgFlapQwinstaEventId(d)?.let { targetId -> + Button( + enabled = !qwinstaLoading, + onClick = { + qwinstaOpen = true + runQwinsta(targetId, d.actorUser) + }, + modifier = Modifier.padding(vertical = 4.dp), + ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } + } + } Field("Severity", d.severity) Field( "Тип", @@ -110,6 +168,75 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { EventDetailsSection(d.type, d.details, d.payload) } } + + if (qwinstaOpen) { + AlertDialog( + onDismissRequest = { if (!qwinstaLoading && logoffSessionId == null) qwinstaOpen = false }, + title = { Text("qwinsta") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + qwinstaMeta?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + when { + qwinstaLoading -> CircularProgressIndicator() + qwinstaError != null -> Text(qwinstaError!!, color = MaterialTheme.colorScheme.error) + qwinstaStdout != null -> Text( + qwinstaStdout!!, + style = MaterialTheme.typography.bodySmall, + ) + else -> Text("Нет вывода") + } + qwinstaSessions.forEach { s -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${s.sessionName} ${s.userName} ${s.id} ${s.state}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + OutlinedButton( + enabled = logoffSessionId == null && !qwinstaLoading, + onClick = { + val targetId = rdgFlapQwinstaEventId(data ?: return@OutlinedButton) ?: return@OutlinedButton + scope.launch { + logoffSessionId = s.id + qwinstaError = null + try { + val cmd = repo.withAuth { + it.postEventLogoff(targetId, LogoffActionRequest(s.id)) + } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + qwinstaError = cmdError + } else { + runQwinsta(targetId, data?.actorUser) + } + } catch (e: Exception) { + qwinstaError = if (e is retrofit2.HttpException) { + SacRepository.parseError(e) + } else { + e.message + } + } finally { + logoffSessionId = null + } + } + }, + ) { Text("logoff") } + } + } + } + }, + confirmButton = { + TextButton( + enabled = !qwinstaLoading && logoffSessionId == null, + onClick = { qwinstaOpen = false }, + ) { Text("Закрыть") } + }, + ) + } } @Composable diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index 6e07855..869b92b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -47,7 +47,7 @@ import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.ForwardArrowButton -import ru.kalinamall.seaca.ui.util.SacDateTimes +import ru.kalinamall.seaca.ui.util.hasRdgFlapUi private val REPORT_TYPE_OPTIONS = listOf( "report.daily.ssh" to "Отчёты ssh клиентов", @@ -189,7 +189,10 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { res.items to res.total }, itemKey = EventSummary::id, - itemLabel = { "${it.severity} · ${it.title}" }, + itemLabel = { + val prefix = if (hasRdgFlapUi(it)) "RDG flap · " else "" + "$prefix${it.severity} · ${it.title}" + }, itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt new file mode 100644 index 0000000..854dcc2 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt @@ -0,0 +1,71 @@ +package ru.kalinamall.seaca.ui.util + +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.EventSummary + +data class QwinstaSessionRow( + val sessionName: String, + val userName: String, + val id: Int, + val state: String, +) + +fun hasRdgFlapUi(event: EventSummary): Boolean = + event.rdgFlap || event.rdgFlapQwinstaEventId != null + +fun rdgFlapQwinstaEventId(event: EventSummary): Long? = + event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null + +fun hasRdgFlapUi(event: EventDetail): Boolean = + event.rdgFlap || event.rdgFlapQwinstaEventId != null + +fun rdgFlapQwinstaEventId(event: EventDetail): Long? = + event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null + +fun parseQwinstaSessions(stdout: String, actorUser: String?): List { + val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() } + val rows = mutableListOf() + for (line in lines) { + if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) continue + val id = parts[2].toIntOrNull() ?: continue + val userName = parts[1] + if (!actorUser.isNullOrBlank()) { + val norm: (String) -> String = { it.replace(Regex("^B26\\\\", RegexOption.IGNORE_CASE), "").lowercase() } + if (!norm(userName).contains(norm(actorUser))) continue + } + rows.add( + QwinstaSessionRow( + sessionName = parts[0], + userName = userName, + id = id, + state = parts.drop(3).joinToString(" "), + ), + ) + } + if (rows.isEmpty() && stdout.isNotBlank()) { + for (line in lines) { + if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) continue + val id = parts[2].toIntOrNull() ?: continue + rows.add( + QwinstaSessionRow( + sessionName = parts[0], + userName = parts[1], + id = id, + state = parts.drop(3).joinToString(" "), + ), + ) + } + } + return rows +} + +fun agentCommandError(cmd: ru.kalinamall.seaca.data.AgentCommandResponse): String? { + if (cmd.status != "failed") return null + return cmd.resultStderr?.takeIf { it.isNotBlank() } + ?: cmd.resultStdout?.takeIf { it.isNotBlank() } + ?: "Команда завершилась с ошибкой" +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ed4a2be..2cc9dc9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,7 +59,7 @@ - [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm` - [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…` - [x] Notification channels по severity (critical / high / default) -- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK** +- [x] RDG flap: qwinsta/logoff через SAC API (карточка события) - [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC **Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK) diff --git a/docs/operator-guide.md b/docs/operator-guide.md index dfbf151..4a46c0d 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -1,46 +1,39 @@ -# Seaca — руководство оператора +# Seaca — оператор -## Первая настройка +## Подключение -1. Получите от администратора: - - **URL API SAC** для мобильного приложения (HTTPS, путь `/api/v1/…`); - - код регистрации `sacmob_…`; - - при необходимости — логин и пароль SAC. -2. Установите APK Seaca. -3. На экране подключения введите данные → **Подключиться**. -4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть). - -После успеха код больше не нужен — приложение запоминает сессию. +URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату. ## Разделы | Раздел | Содержание | |--------|------------| -| Обзор | Сводка за 24 ч, открытые проблемы; потяните вниз для обновления | -| События | Лента с фильтром по severity | -| Проблемы | Инциденты; для operator и выше — подтвердить / закрыть | -| Хосты | Агенты ssh-monitor / RDP-login-monitor | -| Отчёты | Суточные отчёты SSH и RDP | -| Моё устройство | Сервер, пользователь, режим push, звук, формат даты/времени, выход | +| Обзор | Сводка 24 ч; pull-to-refresh | +| События | Лента; **RDG flap** в заголовке | +| Проблемы | Ack / Resolve (monitor+) | +| Хосты | Агенты, heartbeat | +| Отчёты | `report.daily.ssh` / `report.daily.rdp` | +| Моё устройство | Push, звук, дата, выход | -## Push-уведомления +## RDG flap — qwinsta / logoff -Настраиваются на экране **Моё устройство**: +Пара RD Gateway **302→303** за несколько секунд: -| Режим | Поведение | -|-------|-----------| -| Push и звук | Уведомление со звуком (системным или выбранным) | -| Push без звука | Только banner, без звука | -| Без push и звука | Данные в приложении по запросу | +1. Событие **302** или **303** (метка **RDG flap**). +2. **qwinsta / logoff** → `POST /api/v1/events/{id}/actions/qwinsta` на SAC. +3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`). +4. В диалоге — сессии, **logoff** по ID. -Нажатие на уведомление открывает карточку события или проблемы. +Нужен **Windows domain admin** в настройках SAC (WinRM). + +## Push + +Режимы на экране «Моё устройство». Тап — карточка события или problem. ## Блокировка -При **запуске** приложения и при возврате из фона может запрашиваться отпечаток / PIN устройства (если включено в системе). +Биометрия/PIN при запуске и возврате в приложение. ## Потеря доступа -Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC. - -Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC). +Новый код или снятие отзыва устройства в веб-SAC. From 23d5cd271b199b7c7d798490816eae2ac2dad165 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:09:14 +1000 Subject: [PATCH 5/8] fix: restore SacDateTimes import in ListScreens --- app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index 869b92b..d9588d2 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -47,6 +47,7 @@ import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.ForwardArrowButton +import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.hasRdgFlapUi private val REPORT_TYPE_OPTIONS = listOf( From c040b7a6c611c74c80de991005e80af60e0155d4 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:13:58 +1000 Subject: [PATCH 6/8] feat: yellow RDG flap badge on dashboard and events (0.5.9) Match SAC styling for RDG session flap events on the home screen and events list. --- README.md | 2 +- app/build.gradle.kts | 4 +- .../seaca/ui/components/RdgFlapBadge.kt | 43 +++++++++++++++++ .../seaca/ui/screens/ListScreens.kt | 47 ++++++++++++++----- 4 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt diff --git a/README.md b/README.md index a809bfe..08328ff 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center). -**Версия:** `0.5.8` (versionCode 22) +**Версия:** `0.5.9` (versionCode 23) ## Возможности diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5060684..36fa822 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 22 - versionName = "0.5.8" + versionCode = 23 + versionName = "0.5.9" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt new file mode 100644 index 0000000..a92e416 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt @@ -0,0 +1,43 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val RdgFlapText = Color(0xFFF0C674) +private val RdgFlapBackground = Color(0x1FF0C674) +private val RdgFlapBorder = Color(0x73F0C674) + +@Composable +fun RdgFlapBadge( + modifier: Modifier = Modifier, + pairEventId: Long? = null, +) { + val label = buildString { + append("RDG FLAP") + if (pairEventId != null) append(" #$pairEventId") + } + Surface( + modifier = modifier + .border(1.dp, RdgFlapBorder, RoundedCornerShape(4.dp)), + shape = RoundedCornerShape(4.dp), + color = RdgFlapBackground, + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = RdgFlapText, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.3.sp, + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index d9588d2..bf4948d 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -46,6 +46,7 @@ import ru.kalinamall.seaca.data.HostSummary import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.ui.components.BackArrowButton +import ru.kalinamall.seaca.ui.components.RdgFlapBadge import ru.kalinamall.seaca.ui.components.ForwardArrowButton import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.hasRdgFlapUi @@ -122,12 +123,16 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, ) { - Text( - dashboardRecentEventLine(e), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (hasRdgFlapUi(e)) { + RdgFlapBadge(pairEventId = e.rdgFlapPairEventId) + } + Text( + dashboardRecentEventLine(e), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } Text( SacDateTimes.format(e.occurredAt, dateMode), style = MaterialTheme.typography.bodySmall, @@ -190,11 +195,10 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { res.items to res.total }, itemKey = EventSummary::id, - itemLabel = { - val prefix = if (hasRdgFlapUi(it)) "RDG flap · " else "" - "$prefix${it.severity} · ${it.title}" - }, + itemLabel = { "${it.severity} · ${it.title}" }, itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, + itemRdgFlap = { hasRdgFlapUi(it) }, + itemRdgFlapPairId = { it.rdgFlapPairEventId }, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), hostnameFilter = hostname, @@ -331,6 +335,8 @@ private fun PagedListScreen( itemSub: (T) -> String, onOpen: (Long) -> Unit, severities: List, + itemRdgFlap: ((T) -> Boolean)? = null, + itemRdgFlapPairId: ((T) -> Long?)? = null, filterLabel: String = "Severity", hostnameFilter: String = "", headerContent: @Composable () -> Unit = {}, @@ -389,7 +395,13 @@ private fun PagedListScreen( ) { LazyColumn { items(items, key = { itemKey(it) }) { item -> - ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) } + ListRow( + title = itemLabel(item), + subtitle = itemSub(item), + onClick = { onOpen(itemKey(item)) }, + rdgFlap = itemRdgFlap?.invoke(item) == true, + rdgFlapPairId = itemRdgFlapPairId?.invoke(item), + ) } } } @@ -398,7 +410,13 @@ private fun PagedListScreen( } @Composable -private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) { +private fun ListRow( + title: String, + subtitle: String, + onClick: () -> Unit, + rdgFlap: Boolean = false, + rdgFlapPairId: Long? = null, +) { Card( Modifier .fillMaxWidth() @@ -409,7 +427,10 @@ private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) { contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Column(Modifier.padding(12.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (rdgFlap) { + RdgFlapBadge(pairEventId = rdgFlapPairId) + } Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } From 363471c5631b8e2c6b890b6351561e8e681770ca Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:19:09 +1000 Subject: [PATCH 7/8] fix: visible logoff buttons in RDG qwinsta dialog (0.5.10) Scrollable session list with red logoff actions, manual session ID fallback, and qwinsta from events/dashboard. --- README.md | 2 +- app/build.gradle.kts | 4 +- .../seaca/ui/components/RdgQwinstaDialog.kt | 334 ++++++++++++++++++ .../seaca/ui/screens/DetailScreens.kt | 123 +------ .../seaca/ui/screens/ListScreens.kt | 52 ++- .../ru/kalinamall/seaca/ui/util/RdgQwinsta.kt | 4 +- 6 files changed, 395 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt diff --git a/README.md b/README.md index 08328ff..e1558a8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center). -**Версия:** `0.5.9` (versionCode 23) +**Версия:** `0.5.10` (versionCode 24) ## Возможности diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36fa822..471795c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 23 - versionName = "0.5.9" + versionCode = 24 + versionName = "0.5.10" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt new file mode 100644 index 0000000..7023e6c --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt @@ -0,0 +1,334 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.EventSummary +import ru.kalinamall.seaca.data.LogoffActionRequest +import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.util.QwinstaSessionRow +import ru.kalinamall.seaca.ui.util.agentCommandError +import ru.kalinamall.seaca.ui.util.parseQwinstaSessions +import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId + +class RdgQwinstaController(private val repo: SacRepository) { + var open by mutableStateOf(false) + private set + var loading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var meta by mutableStateOf(null) + private set + var stdout by mutableStateOf(null) + private set + var sessions by mutableStateOf>(emptyList()) + private set + var logoffSessionId by mutableStateOf(null) + private set + var targetEventId by mutableStateOf(null) + private set + private var actorUser: String? = null + + fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) { + val targetId = rdgFlapQwinstaEventId(event) ?: return + targetEventId = targetId + actorUser = event.actorUser + open = true + runQwinsta(scope) + } + + fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) { + val targetId = rdgFlapQwinstaEventId(event) ?: return + targetEventId = targetId + actorUser = event.actorUser + open = true + runQwinsta(scope) + } + + fun dismiss() { + if (!loading && logoffSessionId == null) { + open = false + } + } + + fun runQwinsta(scope: kotlinx.coroutines.CoroutineScope) { + val targetId = targetEventId ?: return + scope.launch { + loading = true + error = null + stdout = null + sessions = emptyList() + try { + val cmd = repo.withAuth { it.postEventQwinsta(targetId) } + meta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target) + .joinToString(" · ") + .ifBlank { null } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + error = cmdError + } else { + val out = cmd.resultStdout.orEmpty() + stdout = out.ifBlank { null } + sessions = parseQwinstaSessions(out, actorUser) + } + } catch (e: Exception) { + error = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + loading = false + } + } + } + + fun logoff(sessionId: Int, scope: kotlinx.coroutines.CoroutineScope, onDone: () -> Unit = {}) { + val targetId = targetEventId ?: return + scope.launch { + logoffSessionId = sessionId + error = null + try { + val cmd = repo.withAuth { + it.postEventLogoff(targetId, LogoffActionRequest(sessionId)) + } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + error = cmdError + } else { + runQwinsta(scope) + } + } catch (e: Exception) { + error = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + logoffSessionId = null + onDone() + } + } + } +} + +@Composable +fun rememberRdgQwinstaController(repo: SacRepository): RdgQwinstaController = + remember(repo) { RdgQwinstaController(repo) } + +@Composable +fun RdgQwinstaDialog( + controller: RdgQwinstaController, + modifier: Modifier = Modifier, +) { + if (!controller.open) return + val scope = rememberCoroutineScope() + var confirmSessionId by remember { mutableIntStateOf(-1) } + var manualSessionId by remember { mutableStateOf("") } + + if (confirmSessionId >= 0) { + AlertDialog( + onDismissRequest = { confirmSessionId = -1 }, + title = { Text("Завершить сеанс?") }, + text = { Text("Отправить logoff для сеанса ID $confirmSessionId на клиентском ПК?") }, + confirmButton = { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + val id = confirmSessionId + confirmSessionId = -1 + controller.logoff(id, scope) + }, + ) { Text("logoff") } + }, + dismissButton = { + TextButton(onClick = { confirmSessionId = -1 }) { Text("Отмена") } + }, + ) + } + + Dialog( + onDismissRequest = { controller.dismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + ) { + Column( + Modifier + .padding(16.dp) + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge) + controller.meta?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + when { + controller.loading -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator() + Text("Ожидание ответа…") + } + } + controller.error != null -> { + Text(controller.error!!, color = MaterialTheme.colorScheme.error) + } + } + + if (controller.sessions.isNotEmpty()) { + Text( + "Сеансы — нажмите logoff, чтобы оборвать зависшую сессию:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + controller.sessions.forEach { s -> + SessionLogoffRow( + session = s, + logoffEnabled = !controller.loading && controller.logoffSessionId == null, + logoffInProgress = controller.logoffSessionId == s.id, + onLogoff = { confirmSessionId = s.id }, + ) + } + } else if (!controller.loading && controller.stdout != null) { + Text( + "Сеансы не распознаны автоматически. Введите ID из вывода ниже:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = manualSessionId, + onValueChange = { manualSessionId = it.filter { ch -> ch.isDigit() } }, + label = { Text("ID сеанса") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Button( + enabled = manualSessionId.isNotBlank() && !controller.loading && controller.logoffSessionId == null, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + manualSessionId.toIntOrNull()?.let { confirmSessionId = it } + }, + ) { Text("logoff") } + } + } + + controller.stdout?.let { raw -> + Text("Вывод qwinsta", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text(raw, style = MaterialTheme.typography.bodySmall) + } + if (controller.stdout == null && !controller.loading && controller.error == null) { + Text("Нет вывода", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + enabled = !controller.loading && controller.logoffSessionId == null, + onClick = { controller.dismiss() }, + ) { Text("Закрыть") } + } + } + } + } +} + +@Composable +private fun SessionLogoffRow( + session: QwinstaSessionRow, + logoffEnabled: Boolean, + logoffInProgress: Boolean, + onLogoff: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(session.sessionName, style = MaterialTheme.typography.bodyMedium) + Text( + "${session.userName} · ID ${session.id} · ${session.state}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (logoffInProgress) { + CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp)) + } else { + Button( + enabled = logoffEnabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = onLogoff, + ) { Text("logoff") } + } + } + } +} + +@Composable +fun RdgQwinstaButton( + controller: RdgQwinstaController, + enabled: Boolean = true, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + OutlinedButton( + enabled = enabled && !controller.loading, + onClick = onClick, + modifier = modifier, + ) { + Text(if (controller.loading) "qwinsta…" else "qwinsta") + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index b92d161..03e9454 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -60,10 +60,10 @@ import ru.kalinamall.seaca.data.NotificationSoundSource import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.LogoffActionRequest import ru.kalinamall.seaca.data.SacRepository -import ru.kalinamall.seaca.ui.util.QwinstaSessionRow -import ru.kalinamall.seaca.ui.util.agentCommandError +import ru.kalinamall.seaca.ui.components.RdgQwinstaButton +import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog +import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController import ru.kalinamall.seaca.ui.util.hasRdgFlapUi -import ru.kalinamall.seaca.ui.util.parseQwinstaSessions import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId import ru.kalinamall.seaca.push.NotificationHelper import ru.kalinamall.seaca.push.NotificationSoundResolver @@ -87,14 +87,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } - var qwinstaOpen by remember { mutableStateOf(false) } - var qwinstaLoading by remember { mutableStateOf(false) } - var qwinstaError by remember { mutableStateOf(null) } - var qwinstaStdout by remember { mutableStateOf(null) } - var qwinstaSessions by remember { mutableStateOf>(emptyList()) } - var qwinstaMeta by remember { mutableStateOf(null) } - var logoffSessionId by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -110,47 +104,17 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } - fun runQwinsta(targetEventId: Long, actorUser: String?) { - scope.launch { - qwinstaLoading = true - qwinstaError = null - qwinstaStdout = null - qwinstaSessions = emptyList() - try { - val cmd = repo.withAuth { it.postEventQwinsta(targetEventId) } - qwinstaMeta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target) - .joinToString(" · ") - .ifBlank { null } - val cmdError = agentCommandError(cmd) - if (cmdError != null) { - qwinstaError = cmdError - } else { - val out = cmd.resultStdout.orEmpty() - qwinstaStdout = out.ifBlank { null } - qwinstaSessions = parseQwinstaSessions(out, actorUser) - } - } catch (e: Exception) { - qwinstaError = if (e is retrofit2.HttpException) SacRepository.parseError(e) else e.message - } finally { - qwinstaLoading = false - } - } - } - DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) { val d = data ?: return@DetailFrame if (hasRdgFlapUi(d)) { Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя") d.rdgFlapPairEventId?.let { Field("Пара", "#$it") } - rdgFlapQwinstaEventId(d)?.let { targetId -> - Button( - enabled = !qwinstaLoading, - onClick = { - qwinstaOpen = true - runQwinsta(targetId, d.actorUser) - }, + rdgFlapQwinstaEventId(d)?.let { + RdgQwinstaButton( + controller = qwinsta, + onClick = { qwinsta.openFor(d, scope) }, modifier = Modifier.padding(vertical = 4.dp), - ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } + ) } } Field("Severity", d.severity) @@ -169,74 +133,7 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } - if (qwinstaOpen) { - AlertDialog( - onDismissRequest = { if (!qwinstaLoading && logoffSessionId == null) qwinstaOpen = false }, - title = { Text("qwinsta") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - qwinstaMeta?.let { Text(it, style = MaterialTheme.typography.bodySmall) } - when { - qwinstaLoading -> CircularProgressIndicator() - qwinstaError != null -> Text(qwinstaError!!, color = MaterialTheme.colorScheme.error) - qwinstaStdout != null -> Text( - qwinstaStdout!!, - style = MaterialTheme.typography.bodySmall, - ) - else -> Text("Нет вывода") - } - qwinstaSessions.forEach { s -> - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "${s.sessionName} ${s.userName} ${s.id} ${s.state}", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - ) - OutlinedButton( - enabled = logoffSessionId == null && !qwinstaLoading, - onClick = { - val targetId = rdgFlapQwinstaEventId(data ?: return@OutlinedButton) ?: return@OutlinedButton - scope.launch { - logoffSessionId = s.id - qwinstaError = null - try { - val cmd = repo.withAuth { - it.postEventLogoff(targetId, LogoffActionRequest(s.id)) - } - val cmdError = agentCommandError(cmd) - if (cmdError != null) { - qwinstaError = cmdError - } else { - runQwinsta(targetId, data?.actorUser) - } - } catch (e: Exception) { - qwinstaError = if (e is retrofit2.HttpException) { - SacRepository.parseError(e) - } else { - e.message - } - } finally { - logoffSessionId = null - } - } - }, - ) { Text("logoff") } - } - } - } - }, - confirmButton = { - TextButton( - enabled = !qwinstaLoading && logoffSessionId == null, - onClick = { qwinstaOpen = false }, - ) { Text("Закрыть") } - }, - ) - } + RdgQwinstaDialog(qwinsta) } @Composable diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index bf4948d..9e82beb 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -47,9 +48,13 @@ import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.RdgFlapBadge +import ru.kalinamall.seaca.ui.components.RdgQwinstaButton +import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog +import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController import ru.kalinamall.seaca.ui.components.ForwardArrowButton import ru.kalinamall.seaca.ui.util.SacDateTimes import ru.kalinamall.seaca.ui.util.hasRdgFlapUi +import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId private val REPORT_TYPE_OPTIONS = listOf( "report.daily.ssh" to "Отчёты ssh клиентов", @@ -64,6 +69,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { var loading by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -132,6 +138,12 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) + if (rdgFlapQwinstaEventId(e) != null) { + RdgQwinstaButton( + controller = qwinsta, + onClick = { qwinsta.openFor(e, scope) }, + ) + } } Text( SacDateTimes.format(e.occurredAt, dateMode), @@ -146,6 +158,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { } } } + RdgQwinstaDialog(qwinsta) } private fun dashboardRecentEventLine(event: EventSummary): String { @@ -177,6 +190,8 @@ private fun StatCard(label: String, value: String) { @Composable fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { var hostname by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -199,6 +214,9 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, itemRdgFlap = { hasRdgFlapUi(it) }, itemRdgFlapPairId = { it.rdgFlapPairEventId }, + itemQwinsta = { rdgFlapQwinstaEventId(it) != null }, + onQwinsta = { qwinsta.openFor(it, scope) }, + qwinstaLoading = qwinsta.loading, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), hostnameFilter = hostname, @@ -213,6 +231,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { ) }, ) + RdgQwinstaDialog(qwinsta) } @OptIn(ExperimentalMaterial3Api::class) @@ -337,6 +356,9 @@ private fun PagedListScreen( severities: List, itemRdgFlap: ((T) -> Boolean)? = null, itemRdgFlapPairId: ((T) -> Long?)? = null, + itemQwinsta: ((T) -> Boolean)? = null, + onQwinsta: ((T) -> Unit)? = null, + qwinstaLoading: Boolean = false, filterLabel: String = "Severity", hostnameFilter: String = "", headerContent: @Composable () -> Unit = {}, @@ -401,6 +423,9 @@ private fun PagedListScreen( onClick = { onOpen(itemKey(item)) }, rdgFlap = itemRdgFlap?.invoke(item) == true, rdgFlapPairId = itemRdgFlapPairId?.invoke(item), + showQwinsta = itemQwinsta?.invoke(item) == true, + onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } }, + qwinstaLoading = qwinstaLoading, ) } } @@ -416,23 +441,38 @@ private fun ListRow( onClick: () -> Unit, rdgFlap: Boolean = false, rdgFlapPairId: Long? = null, + showQwinsta: Boolean = false, + onQwinstaClick: (() -> Unit)? = null, + qwinstaLoading: Boolean = false, ) { Card( Modifier .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable(onClick = onClick), + .padding(vertical = 4.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurface, ), ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (rdgFlap) { - RdgFlapBadge(pairEventId = rdgFlapPairId) + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (rdgFlap) { + RdgFlapBadge(pairEventId = rdgFlapPairId) + } + Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (showQwinsta && onQwinstaClick != null) { + OutlinedButton( + enabled = !qwinstaLoading, + onClick = onQwinstaClick, + ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } } - Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt index 854dcc2..a85d1d3 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt @@ -37,7 +37,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List").trim(), userName = userName, id = id, state = parts.drop(3).joinToString(" "), @@ -52,7 +52,7 @@ fun parseQwinstaSessions(stdout: String, actorUser: String?): List").trim(), userName = parts[1], id = id, state = parts.drop(3).joinToString(" "), From 6a0758ffadcdd313ab08683b97b9957334bb429e Mon Sep 17 00:00:00 2001 From: PTah Date: Sun, 21 Jun 2026 10:46:18 +1000 Subject: [PATCH 8/8] fix: persist custom push sound for FCM cold start (0.5.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Копирование ringtone во внутреннее хранилище, FileProvider, каналы с hash и миграция URI. --- README.md | 2 +- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 10 +++ .../ru/kalinamall/seaca/SeacaApplication.kt | 12 ++- .../seaca/push/NotificationHelper.kt | 43 +++++++---- .../seaca/push/NotificationSoundResolver.kt | 73 ++++++++++++++++++- .../seaca/push/SeacaMessagingService.kt | 4 +- .../seaca/ui/screens/DetailScreens.kt | 15 +++- app/src/main/res/xml/file_paths.xml | 4 + 9 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/README.md b/README.md index e1558a8..b62c3d7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center). -**Версия:** `0.5.10` (versionCode 24) +**Версия:** `0.5.11` (versionCode 25) ## Возможности diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 471795c..9b0da79 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 24 - versionName = "0.5.10" + versionCode = 25 + versionName = "0.5.11" } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20b3375..7a175bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,5 +38,15 @@ + + + + diff --git a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt index 004456c..8f57ddd 100644 --- a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt +++ b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt @@ -1,13 +1,21 @@ package ru.kalinamall.seaca import android.app.Application +import kotlinx.coroutines.runBlocking +import ru.kalinamall.seaca.data.SessionStore import ru.kalinamall.seaca.push.NotificationHelper - import ru.kalinamall.seaca.push.NotificationSoundResolver class SeacaApplication : Application() { override fun onCreate() { super.onCreate() - NotificationHelper.ensureChannels(this, NotificationSoundResolver.defaultNotificationUri()) + val store = SessionStore(this) + val settings = runBlocking { + NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store) + } + NotificationHelper.ensureChannels( + this, + NotificationSoundResolver.resolve(this, settings), + ) } } diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt index 23d7262..703dfe3 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt @@ -13,36 +13,47 @@ import ru.kalinamall.seaca.MainActivity import ru.kalinamall.seaca.R object NotificationHelper { - const val CHANNEL_DEFAULT = "sac_default" - const val CHANNEL_HIGH = "sac_high" - const val CHANNEL_CRITICAL = "sac_critical" - const val CHANNEL_DEFAULT_SILENT = "sac_default_silent" - const val CHANNEL_HIGH_SILENT = "sac_high_silent" - const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent" + private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent" + private const val CHANNEL_HIGH_SILENT = "sac_high_silent" + private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent" + + private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical") - private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_CRITICAL) private var appliedSoundKey: String? = null + private var soundChannelSuffix: String = "sys" + private var channelDefault = "sac_default_sys" + private var channelHigh = "sac_high_sys" + private var channelCritical = "sac_critical_sys" fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val soundKey = soundUri.toString() val mgr = context.getSystemService(NotificationManager::class.java) if (soundKey != appliedSoundKey) { - soundChannelIds.forEach { mgr.deleteNotificationChannel(it) } + legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) } + if (appliedSoundKey != null) { + mgr.deleteNotificationChannel(channelDefault) + mgr.deleteNotificationChannel(channelHigh) + mgr.deleteNotificationChannel(channelCritical) + } appliedSoundKey = soundKey + soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8) + channelDefault = "sac_default_$soundChannelSuffix" + channelHigh = "sac_high_$soundChannelSuffix" + channelCritical = "sac_critical_$soundChannelSuffix" } val audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() mgr.createNotificationChannel( - soundChannel(context, CHANNEL_DEFAULT, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes), + soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes), ) mgr.createNotificationChannel( - soundChannel(context, CHANNEL_HIGH, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes), + soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes), ) mgr.createNotificationChannel( - soundChannel(context, CHANNEL_CRITICAL, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes), + soundChannel(context, channelCritical, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes), ) mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent)) mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent)) @@ -106,9 +117,9 @@ object NotificationHelper { .setPriority( when { silent -> NotificationCompat.PRIORITY_DEFAULT - channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT -> + channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT -> NotificationCompat.PRIORITY_MAX - channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT -> + channel == channelHigh || channel == CHANNEL_HIGH_SILENT -> NotificationCompat.PRIORITY_HIGH else -> NotificationCompat.PRIORITY_DEFAULT }, @@ -132,9 +143,9 @@ object NotificationHelper { } } return when (severity?.lowercase()) { - "critical" -> CHANNEL_CRITICAL - "high" -> CHANNEL_HIGH - else -> CHANNEL_DEFAULT + "critical" -> channelCritical + "high" -> channelHigh + else -> channelDefault } } } diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt index cdae605..5cfa8b1 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt @@ -5,19 +5,37 @@ import android.content.Context import android.content.Intent import android.media.RingtoneManager import android.net.Uri +import androidx.core.content.FileProvider import ru.kalinamall.seaca.data.NotificationSoundSettings import ru.kalinamall.seaca.data.NotificationSoundSource +import ru.kalinamall.seaca.data.SessionStore +import java.io.File object NotificationSoundResolver { + private const val CUSTOM_SOUND_FILE = "custom_notification_sound" + fun resolve(context: Context, settings: NotificationSoundSettings): Uri { return when (settings.source) { NotificationSoundSource.SYSTEM -> defaultNotificationUri() NotificationSoundSource.CUSTOM -> { - settings.customUri?.let { Uri.parse(it) } ?: defaultNotificationUri() + playableCustomUri(context, settings.customUri) ?: defaultNotificationUri() } } } + suspend fun normalizeStoredSettings(context: Context, store: SessionStore): NotificationSoundSettings { + val settings = store.getNotificationSoundSettings() + if (settings.source != NotificationSoundSource.CUSTOM || settings.customUri.isNullOrBlank()) { + return settings + } + if (isInternalSoundUri(context, settings.customUri)) { + return settings + } + val imported = importCustomSound(context, Uri.parse(settings.customUri)) ?: return settings + store.setNotificationSound(NotificationSoundSource.CUSTOM, imported) + return store.getNotificationSoundSettings() + } + fun defaultNotificationUri(): Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) @@ -30,15 +48,62 @@ object NotificationSoundResolver { } } - fun persistCustomUri(context: Context, uri: Uri?) { - if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return + /** + * Copies picker/system URI into app storage so FCM in a fresh process can read it. + * Returns stored URI string (FileProvider or android.resource). + */ + fun importCustomSound(context: Context, source: Uri): String? { + if (ContentResolver.SCHEME_ANDROID_RESOURCE == source.scheme) { + return source.toString() + } + if (defaultNotificationUri().toString() == source.toString()) { + return source.toString() + } + persistCustomUri(context, source) + val dest = File(context.filesDir, CUSTOM_SOUND_FILE) + return try { + context.contentResolver.openInputStream(source)?.use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } ?: return null + internalFileUri(context, dest).toString() + } catch (_: Exception) { + null + } + } + + private fun playableCustomUri(context: Context, stored: String?): Uri? { + if (stored.isNullOrBlank()) return null + val parsed = Uri.parse(stored) + if (isInternalSoundUri(context, stored)) { + val file = File(context.filesDir, CUSTOM_SOUND_FILE) + return if (file.isFile) internalFileUri(context, file) else null + } + if (ContentResolver.SCHEME_ANDROID_RESOURCE == parsed.scheme) { + return parsed + } + return importCustomSound(context, parsed)?.let { Uri.parse(it) } + } + + private fun isInternalSoundUri(context: Context, stored: String): Boolean { + val parsed = Uri.parse(stored) + return parsed.authority == fileProviderAuthority(context) + } + + private fun internalFileUri(context: Context, file: File): Uri = + FileProvider.getUriForFile(context, fileProviderAuthority(context), file) + + private fun fileProviderAuthority(context: Context): String = + "${context.packageName}.fileprovider" + + private fun persistCustomUri(context: Context, uri: Uri) { + if (uri.scheme != ContentResolver.SCHEME_CONTENT) return try { context.contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION, ) } catch (_: SecurityException) { - // Picker may not grant persistable permission for some URIs. + // Best-effort; internal copy is the reliable path. } } } diff --git a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt index fc5f636..9db72a1 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt @@ -30,7 +30,9 @@ class SeacaMessagingService : FirebaseMessagingService() { val soundUri = if (silent) { NotificationSoundResolver.defaultNotificationUri() } else { - val settings = runBlocking { repo.session.getNotificationSoundSettings() } + val settings = runBlocking { + NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session) + } NotificationSoundResolver.resolve(this, settings) } NotificationHelper.show( diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index 03e9454..43ba56f 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -472,9 +472,12 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { ) } - LaunchedEffect(soundSettings) { + LaunchedEffect(soundSettings, notificationMode) { if (notificationMode == NotificationMode.PUSH_SOUND) { - applySoundChannels(soundSettings) + val normalized = repo.session.let { + NotificationSoundResolver.normalizeStoredSettings(context, it) + } + applySoundChannels(normalized) } } @@ -486,11 +489,15 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings) try { RingtonePicker.launch(activity, currentUri) { uri -> - NotificationSoundResolver.persistCustomUri(context, uri) if (uri == null) { repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null) } else { - repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, uri.toString()) + val stored = NotificationSoundResolver.importCustomSound(context, uri) + if (stored == null) { + Toast.makeText(context, "Не удалось сохранить звук", Toast.LENGTH_LONG).show() + return@launch + } + repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, stored) } applySoundChannels(repo.session.getNotificationSoundSettings()) } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..4698997 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + +