# desktop — desktop-обёртка glchat (Tauri 2) Тот же веб-клиент из `web/`, обёрнутый в Tauri 2 (docs/client-tauri.md). Отдельного UI-кода нет: страница инстанса загружается в системный webview, а обёртка даёт платформенную интеграцию — трей, нативные уведомления, глобальный push-to-talk, автозапуск, deep links и автообновление. ``` desktop/ └── src-tauri/ # Rust-проект обёртки ├── src/ # окно, трей, шорткаты, уведомления, deep links, обновления ├── capabilities/ # allowlist IPC: локальная страница настроек и домен инстанса ├── ui/ # локальная страница настроек обёртки (tauri://localhost) ├── icons/ # иконки приложения (сгенерированы из web/public/icons) └── tauri.conf.json # конфигурация сборки и плагинов ``` ## Сборка и запуск ```bash make desktop-check # cargo fmt --check, clippy, тесты (кэш в .cache/) make desktop-build # бандлы текущей ОС (macOS: .app и .dmg) make desktop-build-app # только .app + артефакты обновления, без .dmg open desktop/src-tauri/target/release/bundle/macos/glchat.app ``` Требуется Rust 1.77+ и Node 22 (Tauri CLI ставится через `npx`). Кэши `cargo` и `npm` держатся в `.cache/` — как у остальных инструментов проекта. `make desktop-build-app` (`tauri build --bundles app`) нужен там, где `.dmg` собрать нельзя: `bundle_dmg.sh` монтирует образ через `hdiutil` и управляет Finder через AppleScript, поэтому из песочницы агента и из headless-окружения он падает (см. «Что не сделано»). `open --args` из песочницы агента аргументы не доставляет (проверено на macOS 27: и на бандле glchat, и на системном TextEdit — процесс запускается без них; из обычного терминала то же самое работает). Поэтому `make desktop-run ARGS='--minimized'` и замер свёрнутого окна в `scripts/desktop-perf.py` надо запускать из обычного терминала, а не из песочницы. Сборка релиза с артефактами автообновления использует ключ подписи: ```bash # ключ и пароль не коммитятся: приватный ключ живёт в CI-секретах, # локально — в build/desktop-keys/ (каталог build/ в .gitignore) npx @tauri-apps/cli@^2 signer generate -w build/desktop-keys/glchat-updater.key -p '…' make desktop-build ``` Публичный ключ уже вписан в `src-tauri/tauri.conf.json` и в `UPDATER_PUBKEY` (`src/updates.rs`). Без локального ключа `make desktop-build` собирает бандл без артефактов обновления, чтобы сборка не падала у тех, у кого ключа нет. ## Аргументы командной строки | Аргумент | Что делает | |---|---| | `--instance ` | адрес инстанса вместо сохранённого (`--instance=https://…` тоже работает) | | `--minimized` | запуск свёрнутым в трей (используется автозапуском) | | `--settings` | сразу открыть страницу настроек обёртки | | `--autostart on\|off` | включить или выключить автозапуск при установке скриптом | | `--update-now` | сразу проверить обновление и поставить его без диалога (тихое обновление) | ## Настройки Хранятся в `settings.json` каталога конфигурации приложения (`~/Library/Application Support/su.mhspx.glchat/` на macOS): | Ключ | Смысл | |---|---| | `instanceUrl` | адрес клиента, по умолчанию `https://gl.mhspx.su` | | `closeToTray` | закрытие окна сворачивает приложение в трей | | `autostart` | запуск при входе в систему (`--minimized`) | | `notifications` | показывать системные уведомления | | `pttShortcut`, `muteShortcut`, `deafenShortcut` | глобальные комбинации (пусто — выключено) | Меняются из меню трея и со страницы настроек обёртки (трей → «Настройки обёртки…»). Сессия и токены живут только в cookie webview: обёртка их не хранит. ## Deep links Схема `glchat://` (регистрируется бандлом): - `glchat://invite/` → страница приглашения; - `glchat://guild//channel/` → сервер и комната. Ссылка, пришедшая до загрузки клиента (холодный старт), ждёт в очереди и доставляется после `desktop_ready`. ## Автообновление Обёртка проверяет обновления сама: фоновая проверка через 30 секунд после старта и вручную — трей → «Проверить обновления…». Адрес манифеста — `GET {instance}/updates/{target}/{arch}/{current_version}` (target: `darwin`/`windows`/`linux`, arch: `aarch64`/`x86_64`; см. `src/updates.rs`). Найденное обновление показывается в интерфейсе, установка спрашивается отдельным диалогом («во время звонка перезапуск без согласия недопустим»). Для управляемых машин есть тихий режим — флаг `--update-now`: ```bash open -a /Applications/glchat.app --args --update-now # из обычного терминала ``` С этим флагом обёртка проверяет обновление сразу при старте (не через 30 секунд) и ставит найденное без вопросов, после чего перезапускается; диалогов нет вовсе, а если обновления нет — приложение просто работает дальше. По умолчанию флаг выключен, поведение обычного запуска не меняется. Подпись артефакта проверяется в обоих режимах: флаг убирает только вопрос пользователю, но не проверку (docs/DECISIONS.md, D-077). В журнале (`~/Library/Logs/su.mhspx.glchat/glchat.log`) видны и версия запущенной сборки, и ход обновления: ``` обёртка запущена: версия=0.1.0, адрес=https://gl.mhspx.su, окно=видимо, автозапуск=true тихое обновление по флагу --update-now: проверка сразу при старте доступно обновление 0.1.1 тихое обновление по флагу: 0.1.0 → 0.1.1 обновление 0.1.1 установлено, перезапуск обёртка запущена: версия=0.1.1, … ``` Манифест отдаёт сам инстанс (`internal/server/updates.go`), отдельная статика в Caddy не нужна: и встроенный Caddy профиля, и внешний прокси стенда проксируют весь домен на приложение, поэтому `/updates/...` доходит до него без правок чужих конфигов. Раскладка каталога (`UPDATES_DIR`; на стенде `/opt/glchat/updates`, в контейнере смонтирован как `/app/updates:ro`): ``` /opt/glchat/updates/ └── darwin/aarch64/ ├── glchat.app.tar.gz # артефакт релиза (создаёт tauri build) ├── glchat.app.tar.gz.sig # подпись ed25519 (формат minisign) ├── 0.1.1.json # манифест текущего релиза └── latest.json # манифест последнего релиза — его и отдаёт инстанс ``` Манифестов прошлых релизов в каталоге быть не должно: обёртка спрашивает `/updates/{target}/{arch}/{своя версия}`, и оставшийся `<старая версия>.json` перекрыл бы новый релиз — клиент со старой версией получил бы «обновлений нет». Публикующий скрипт сам убирает такие файлы (см. ниже). ## Выпуск релиза Версия живёт в одном месте — файл `VERSION` в корне репозитория; из него её берут `Makefile` (версия сервера и образов), `deploy/install.sh` (`IMAGE_TAG`) и desktop-обёртка (`desktop/src-tauri/tauri.conf.json` + `Cargo.toml` — их значения должны совпадать с `VERSION`). 1. Поднять версию: ```bash echo 0.1.2 > VERSION # версия сервера и обёртки # те же значения — в desktop/src-tauri/tauri.conf.json, Cargo.toml, Cargo.lock make web-build && make build # сервер собирается с новой версией ``` 2. Проверки и сборка бандла: ```bash make go-lint && go test -tags sqlite_fts5 -race -count=1 ./internal/... cd web && npm run check && npm run lint && npm test && cd .. make desktop-check # fmt, clippy, cargo test make desktop-build # .app + .dmg + артефакты обновления ``` 3. Зафиксировать и пометить релиз: ```bash git add VERSION Makefile deploy/install.sh desktop/src-tauri scripts/desktop-release.sh git commit -m "release: 0.1.2" git tag -a v0.1.2 -m "glchat 0.1.2" && git push origin main --tags ``` 4. Опубликовать обновление на инстансе: ```bash sudo make desktop-release # локальный инстанс make desktop-release-upload HOST=user@server # по SSH (sudo спросит пароль) make desktop-release-upload HOST=user@server \ GLCHAT_SSH_OPTS="-i ~/.ssh/key -o IdentitiesOnly=yes" # свой ключ, без TTY (CI) make desktop-release ARGS='--dry-run' # посмотреть план ``` 5. Проверить канал и живьём обновиться со старой версии — см. следующий раздел. Скрипт `scripts/desktop-release.sh` берёт версию из `tauri.conf.json`, сам определяет target/arch по имени артефакта, копирует артефакт и `.sig`, собирает манифест (`version`, `notes`, `pub_date`, `platforms.-.signature/url`), кладёт его и как `.json`, и как `latest.json`, а манифесты прошлых релизов убирает. Несколько платформ одной версии (macOS + Windows + Linux) складываются в один манифест: запускайте скрипт по разу на артефакт. Каталог обновлений на инстансе обычно принадлежит root — тогда rsync идёт через sudo; если каталог записываем (стенд), sudo не нужен и загрузка работает без терминала. `latest.json` нужен потому, что обёртка приходит со **своей установленной** версией: «новее ли релиз» решает клиент сравнением semver (`tauri-plugin-updater`), поэтому на запрос любой версии сервер отдаёт последний манифест, а если есть манифест конкретной версии — его. Без этого обновление не получил бы никто, кроме тех, у кого уже стоит последняя версия. Владельцу прокси добавлять ничего не нужно: `/updates/...` уходит в catch-all `handle` и доходит до приложения (так же, как статика клиента). Вариант «раздавать обновления статикой мимо приложения» требует правила `rewrite` на `latest.json` с подстановкой `target`/`arch` в Caddy — лишняя работа без выгоды, поэтому не рекомендуется. Проверка (стенд): ```bash curl -s https://gl.mhspx.su/updates/darwin/aarch64/0.1.0 | jq . # манифест для старой версии curl -sI https://gl.mhspx.su/updates/darwin/aarch64/glchat.app.tar.gz | head -3 # 200 + размер # в логе обёртки (~/Library/Logs/su.mhspx.glchat/glchat.log) — # «обновлений нет» (манифест разобран) вместо # «проверка обновлений не удалась: … 404» ``` Порядок обновления: проверка → уведомление в интерфейсе → скачивание → подтверждение в диалоге (во время звонка перезапуск без согласия не делается) → подпись проверяется до установки, downgrade отклоняется. ### Приёмка релиза на живом инстансе (26.09.2026, 0.1.0 → 0.1.1) Проверено на боевом канале стенда, без локальных заглушек: - манифест опубликован `scripts/desktop-release.sh --remote`, артефакт `glchat.app.tar.gz` (3 204 953 Б) и подпись лежат в `/opt/glchat/updates/darwin/aarch64/`; - обёртка 0.1.0, запущенная с `--update-now`, в журнале (UTC) показала: `доступно обновление 0.1.1` → `тихое обновление по флагу: 0.1.0 → 0.1.1` → `обновление 0.1.1 установлено, перезапуск` → `обёртка запущена: версия=0.1.1` → `обновлений нет` (17:08:23–17:08:25Z); - запрос старой версии `GET /updates/darwin/aarch64/0.1.0` отдаёт манифест **0.1.1** — это и есть та точка, где раньше мешал оставшийся `0.1.0.json`; - `/Applications/glchat.app` после установки — 0.1.1, ярлык на рабочем столе (`~/Desktop/glchat.app`) указывает на него. **Важно про место установки.** `tauri-plugin-updater` распаковывает обновление во временный каталог и переносит бандл `rename(2)`. Если приложение лежит на другом томе, чем временный каталог (например, на внешнем диске `/Volumes/…`, а `TMPDIR` — на загрузочном), установка падает с `установка обновления не удалась: Cross-device link (os error 18)`, хотя скачивание и проверка подписи проходят. Поэтому приложение для автообновления надо держать на загрузочном томе — так делает `.dmg` (ставит в `/Applications`); ярлык на рабочем столе лучше вести на `/Applications/glchat.app`, а не на сборку в репозитории. ### Локальная проверка «поверх старой версии» (без стенда) Установка обновления проверена локально на настоящем сервере инстанса: сборка 0.1.0 → манифест 0.1.1 → скачивание → проверка подписи → установка → перезапуск на 0.1.1. Порядок действий (все данные — в каталоге репозитория, стенд не затрагивается): ```bash # 1. артефакт текущей версии и приложение 0.1.0 (с ключом подписи) make desktop-build-app # 2. версия 0.1.1 в desktop/src-tauri/tauri.conf.json и Cargo.toml, затем: make desktop-build-app # 3. раскладка манифеста в свой каталог, ссылки — на локальный сервер bash scripts/desktop-release.sh --artifact \ desktop/src-tauri/target/release/bundle/macos/glchat.app.tar.gz \ --version 0.1.1 --base-url http://127.0.0.1:8100 --dir /tmp/updates # 4. настоящий сервер инстанса со своим каталогом обновлений и клиентом LISTEN_ADDR=127.0.0.1:8099 DATA_DIR=/tmp/glchat-e2e/data WEB_ROOT=$PWD/web/dist \ UPDATES_DIR=/tmp/updates DOMAIN=127.0.0.1:8099 TLS_ENABLED=false \ APP_VERSION=0.1.0-local SESSION_PEPPER=… MASTER_KEY=… TOTP_ENCRYPTION_KEY=… \ ./build/glchat # 5. запуск 0.1.0 с тихим обновлением (адрес инстанса — локальный) /Applications/glchat.app/Contents/MacOS/glchat-desktop \ --instance http://127.0.0.1:8099 --update-now ``` Что нужно знать, повторяя это: - **локальный стенд — http, а `tauri-plugin-updater` по умолчанию требует `https`** («The configured updater endpoint must use a secure protocol»), для проверки сборку делают с временным флагом `tauri build --config '{"plugins":{"updater":{"dangerousInsecureTransportProtocol":true}}}'`. В релизной сборке этого флага быть не должно: боевой адрес — `https://`; - **приложение должно лежать на пути без символических ссылок**: macOS-проверка `tauri-utils` отвергает `current_exe()` с symlink-компонентом, поэтому `/tmp/glchat-e2e/…` не годится (`/tmp` — ссылка на `/private/tmp`), а `/private/tmp/glchat-e2e/…` годится. Тот же смысл у требования ставить приложение в `/Applications`, а не запускать через ссылку; - **`--update-now` обязателен**: без него установку спрашивают диалогом, а кликнуть в headless-прогоне некому; - диагностика в логе: `GET /updates/darwin/aarch64/0.1.0` в журнале сервера, затем `доступно обновление 0.1.1` → `тихое обновление по флагу: 0.1.0 → 0.1.1` → `обновление 0.1.1 установлено, перезапуск` → строка запуска с `версия=0.1.1` и запрос манифеста уже для `0.1.1`; - проверка подписи проверяется и «от обратного»: если испортить байт в артефакте, установка обязана упасть с `The signature verification failed`, а версия на диске — остаться прежней. ## Уведомления и переход в канал Решение «показывать ли уведомление» принимает веб-клиент (упоминания и личные сообщения, окно неактивно или комната не открыта), обёртка показывает системное уведомление и обновляет счётчик. Клик по уведомлению: у `tauri-plugin-notification` на desktop нет колбэка действия, поэтому реализовано лучшее доступное поведение — уведомление, показанное при неактивном окне, запоминается, и когда приложение активируется в течение 10 секунд (окно получило фокус или macOS прислала `Reopen`), окно поднимается и открывается комната из уведомления (`glchat://guild//channel/`, для личной беседы `glchat://dm/`). Ограничение (честно): ОС не сообщает, что активация вызвана именно кликом по уведомлению, поэтому переход сработает и при обычном возврате в приложение в течение этих 10 секунд. Клик по уведомлению старше 10 секунд только поднимает окно. Полноценный колбэк требует своего моста к `UNUserNotificationCenter` (`UNUserNotificationCenterDelegate`) — отдельная задача. ## Ресурсы и холодный старт Замеры собраны скриптом `scripts/desktop-perf.py` (macOS arm64, Mac на M-серии, инстанс `gl.mhspx.su` через внешний прокси; подробности — `docs/PERF.md`): | Метрика | Значение | Бюджет (docs/client-tauri.md §1) | |---|---|---| | Холодный старт (`open` → процесс → строка «загружена страница») | 773–898 мс (три прогона) | ≤ 2 с | | Из них: запуск процесса | 74–78 мс | — | | Из них: загрузка страницы клиента | 696–821 мс | — | | RAM, клиент открыт и простаивает (RSS: обёртка + 4 процесса WebKit) | 254 МБ (117 + 137) | ≤ 350 МБ | | RAM, окно свёрнуто в трей (`--minimized`) | 256 МБ | ≤ 350 МБ | | Физический след (Activity Monitor) | 106 МБ | — | Наблюдения: - свёрнутое в трей окно памяти не освобождает: webview с загруженным клиентом продолжает жить (так работают WKWebView и трей-режим) — экономия была бы только при выгрузке страницы, а это потеря мгновенного возврата в клиент; - первый запуск сразу после сборки бандла (холодный кэш файловой системы и страницы) дал 1,26 с — тоже в бюджете; - почти вся память — WebKit (страница клиента), сама обёртка занимает ~117 МБ. Повторить замер: ```bash make desktop-build-app # свежий .app (+ артефакты обновления) python3 scripts/desktop-perf.py --runs 3 # 3 холодных старта + простой в трее ``` ## Что не сделано (открытые пункты Фазы 6) - **Автообновление проверено локально «поверх старой версии»** (2026-09-26): 0.1.0 → манифест 0.1.1 → скачивание → проверка подписи → установка → перезапуск на 0.1.1, подробности и команды — «Локальная проверка» выше. Открытым остаётся публикация реального релиза на стенде (номер версии поднимает оператор) и то, что локальная проверка шла по http с временным флагом `dangerousInsecureTransportProtocol` — на стенде канал https. - **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения. - **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных экранов-инструкций у обёртки нет. - **Подпись и нотаризация macOS — сознательно не делаются** (решение владельца 26.09.2026): приложение распространяется неподписанным `.dmg`, карантин снимает конечный пользователь (инструкция ниже). EV/Trusted Signing для Windows — тем же решением не делаем. - **`.dmg`**: собирается из обычного терминала (Finder/AppleScript для раскладки окна); в песочнице агента `hdiutil create` и AppleScript запрещены, но образ **без оформления окна** собирается флагом `--sandbox-safe` к `bundle_dmg.sh` (проверено 2026-09-26, см. ниже). - **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и проверялись только macOS-arm64. Сборки под другие ОС **отложены**: репозиторий живёт в Gitea на мини-ПК, а `windows-latest`/`ubuntu-24.04`-раннеры есть у GitHub Actions, поэтому владелец зеркалит проект в GitHub позже и запускает сборку там. Локальная часть готова и проверена: `make desktop-check`, `scripts/desktop-release.sh --artifact … --target windows|linux` (манифест обновления для каждой платформы складывается в общий файл версии). ## Что осталось оператору (точные команды) ### macOS: `.dmg` `bundle_dmg.sh` создаёт образ через `hdiutil create` и раскладывает иконки через AppleScript/Finder. Из песочницы агента и из headless-сессии обе операции запрещены (`hdiutil create` → «Операция не разрешена», AppleScript → -10004). Из обычного терминала на macOS: ```bash cd /Volumes/Samsung/projects/glchat make desktop-build # .app + .dmg + артефакты обновления ls -lh desktop/src-tauri/target/release/bundle/dmg/ ``` Если терминала с графической сессией нет (CI, headless), тот же образ собирается без оформления окна — тем же скриптом Tauri с флагом `--sandbox-safe` (он пропускает AppleScript и `hdiutil create`): ```bash cd /Volumes/Samsung/projects/glchat make desktop-build-app # .app и артефакты обновления rm -rf /tmp/dmg-stage && mkdir -p /tmp/dmg-stage cp -R desktop/src-tauri/target/release/bundle/macos/glchat.app /tmp/dmg-stage/ cd desktop/src-tauri/target/release/bundle/dmg bash bundle_dmg.sh --volname glchat --volicon icon.icns \ --icon glchat.app 180 170 --app-drop-link 480 170 --hide-extension glchat.app \ --sandbox-safe glchat_0.1.0_aarch64.dmg /tmp/dmg-stage hdiutil verify glchat_0.1.0_aarch64.dmg ``` Такой `.dmg` монтируется и содержит `glchat.app` и ссылку `Applications`, но без красивой раскладки иконок — для локальных проверок и CI этого достаточно, для публикации лучше собрать обычным `make desktop-build`. Артефакты без `.dmg` (для автообновления) собираются где угодно: `make desktop-build-app`. ### macOS: подпись Developer ID и нотаризация — сознательно не делаем **Решение владельца (2026-09-26): подпись Developer ID и нотаризацию не делаем** — это платный аккаунт Apple Developer, а приложение распространяется внутри своего круга. Распространяется **неподписанный** `.dmg`; действия при первом запуске выполняет конечный пользователь (ниже). Подпись артефакта обновления (ed25519, ключ инстанса) от этого не зависит и проверяется всегда — её отсутствие или порча артефакта установку отменяют. Что делает конечный пользователь (короткая инструкция, её же можно передать вместе с файлом): 1. Открыть `.dmg` и перетащить `glchat` в `Applications`. 2. Первый запуск — **правый клик по приложению → «Открыть» → «Открыть»** в диалоге «разработчик не может быть проверен». Дальше приложение запускается обычным двойным кликом. 3. Если macOS не показывает кнопку «Открыть» (карантин): в терминале `xattr -dr com.apple.quarantine /Applications/glchat.app`, либо «Системные настройки → Конфиденциальность и безопасность → Всё равно открыть». Текущий установщик релиза 0.1.1: `desktop/src-tauri/target/release/bundle/dmg/glchat_0.1.1_aarch64.dmg` (3,1 МБ, sha256 `7f93609164dc5fdfe6255b88eaf909c02ba819a7e4fb79951cc50b142da825c0`). Собирается `make desktop-build` (или `bundle_dmg.sh --sandbox-safe` в headless). Если однажды понадобится подпись, команды остаются прежними (переменные читает сам Tauri, в репозиторий они не попадают): ```bash security find-identity -v -p codesigning # проверить наличие сертификата export APPLE_SIGNING_IDENTITY="Developer ID Application: … (TEAMID)" export APPLE_ID="…@example.com" export APPLE_PASSWORD="app-specific-password" export APPLE_TEAM_ID="TEAMID" make desktop-build # Tauri подпишет и отправит на нотаризацию xcrun stapler validate desktop/src-tauri/target/release/bundle/macos/glchat.app spctl --assess --type execute --verbose=4 desktop/src-tauri/target/release/bundle/macos/glchat.app ``` Проверка «не подписано»: `codesign -dv --verbose=4 ` покажет `Signature=adhoc`. ### Windows (x64): MSI/NSIS + подпись Сборка идёт на Windows-машине или в CI (`windows-latest`), Rust + Node 22: ```powershell npx @tauri-apps/cli@^2 build --bundles nsis,msi # подпись (EV/Trusted Signing или signtool с сертификатом): signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` /f cert.pfx /p $env:CERT_PASSWORD ` desktop\src-tauri\target\release\bundle\nsis\glchat_0.1.0_x64-setup.exe ``` Артефакты обновления (`*.nsis.zip`/`*.msi` + `.sig`) публикуются тем же скриптом: `bash scripts/desktop-release.sh --artifact <файл> --target windows --arch x86_64`. ### Linux (AppImage + deb) Сборка на Ubuntu 24.04/26.04 (нужны `libwebkit2gtk-4.1-dev`, `librsvg2-dev`, `patchelf`, `libssl-dev`, `build-essential`): ```bash sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev patchelf libssl-dev build-essential npx @tauri-apps/cli@^2 build --bundles appimage,deb bash scripts/desktop-release.sh --artifact desktop/src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \ --target linux --arch x86_64 ``` На Wayland нативная тряска окна при несохранённых изменениях не работает (композитор запрещает `set_position`) — остаётся CSS-тряска контента; это ожидаемое поведение, а не дефект. ### Ключ подписи обновлений Приватный ключ — только в CI-секретах (`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`); локально — `build/desktop-keys/` (каталог в `.gitignore`). Публичный ключ уже вшит в сборку и меняется только вместе с ключом: старые сборки отвергнут манифест, подписанный новым ключом.