feat(desktop): обёртка Tauri 2 вокруг веб-клиента

Первый инкремент Фазы 6 (docs/client-tauri.md): окно с тем же веб-клиентом
(по умолчанию https://gl.mhspx.su, адрес настраивается), трей с меню и
индикатором непрочитанных, нативные уведомления, глобальный push-to-talk и
переключение микрофона/deafen, автозапуск, single-instance, сохранение
геометрии окна, deep links glchat://…, автообновление по подписанному
манифесту.

Связь страницы с обёрткой — через платформенный адаптер
(web/src/lib/platform.ts) и минимальный типизированный набор команд;
в веб-клиенте появились системные уведомления об упоминаниях и личных
сообщениях и переключатель в настройках.

В web/src/stores/gateway.ts вместе с вызовом уведомления лежат правки по
дефектам realtime (READY и сессии) из параллельной волны: файл коммитится
целиком по договорённости с координатором.
This commit is contained in:
2026-09-22 21:46:01 +03:00
parent 594a5c0af5
commit cffcec72b8
52 changed files with 8659 additions and 16 deletions
+114
View File
@@ -0,0 +1,114 @@
/**
* Нативные уведомления desktop-обёртки (docs/client-tauri.md §5).
*
* Решение «показывать или нет» принимает клиент: только упоминания и личные
* сообщения, только когда окно неактивно или комната не открыта, и только если
* пользователь не выключил уведомления в настройках. Текст уведомления готовит
* этот модуль, показывает — платформенный адаптер.
*/
import type { Message } from '@/api/messages';
import type { DesktopNotification } from '@/lib/platform';
/** Ключ предпочтения в localStorage (настройка устройства, не аккаунта). */
const STORAGE_KEY = 'glchat.desktop-notifications';
/** Сколько символов сообщения показываем в уведомлении. */
const PREVIEW_LIMIT = 140;
/** Включены ли уведомления рабочего стола (по умолчанию — да). */
export function notificationsEnabled(): boolean {
try {
return window.localStorage.getItem(STORAGE_KEY) !== 'off';
} catch {
// Приватный режим браузера может запретить localStorage — считаем включёнными.
return true;
}
}
/** Переключает уведомления рабочего стола. */
export function setNotificationsEnabled(enabled: boolean): void {
try {
window.localStorage.setItem(STORAGE_KEY, enabled ? 'on' : 'off');
} catch {
// Без localStorage предпочтение живёт до перезагрузки страницы.
}
}
/** Данные, от которых зависит решение об уведомлении. */
export interface NotificationInput {
message: Pick<Message, 'type' | 'content' | 'mentions' | 'channel_id' | 'author_id'>;
/** Мой идентификатор: свои сообщения не уведомляют. */
meId: string | null;
/** Комната — личная беседа. */
isDirect: boolean;
/** Имя комнаты для заголовка. */
channelName: string | null;
/** Сервер комнаты (в личной беседе — null). */
guildId: string | null;
/** Комната открыта в клиенте. */
isChannelOpen: boolean;
/** Окно клиента в фокусе. */
windowFocused: boolean;
/** Уведомления включены пользователем. */
enabled: boolean;
}
/** Убирает Markdown-разметку и лишние переводы строк из текста сообщения. */
export function previewText(content: string): string {
const plain = content
.replace(/```[\s\S]*?```/g, ' [код] ')
.replace(/`([^`]*)`/g, '$1')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_~>#]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (plain.length <= PREVIEW_LIMIT) {
return plain;
}
return `${plain.slice(0, PREVIEW_LIMIT - 1).trimEnd()}…`;
}
/**
* Текст уведомления или `null`, если показывать не нужно.
*
* Чистая функция: её поведение проверяется тестами без DOM и без обёртки.
*/
export function notificationFor(input: NotificationInput): DesktopNotification | null {
if (!input.enabled) {
return null;
}
const { message } = input;
// Системные сообщения (входы, баны) уведомлений не требуют.
if (message.type === 'system') {
return null;
}
if (input.meId !== null && message.author_id === input.meId) {
return null;
}
const mentionsMe = input.meId !== null && message.mentions.includes(input.meId);
if (!mentionsMe && !input.isDirect) {
return null;
}
// Комната открыта и окно активно — пользователь и так видит сообщение.
if (input.isChannelOpen && input.windowFocused) {
return null;
}
const body = previewText(message.content);
if (body === '' && message.mentions.length === 0) {
return null;
}
const title = input.isDirect
? input.channelName === null || input.channelName === ''
? 'Личное сообщение'
: `Личное сообщение — ${input.channelName}`
: input.channelName === null || input.channelName === ''
? 'Упоминание'
: `Упоминание в #${input.channelName}`;
return {
title,
body: body === '' ? 'Вложение' : body,
guildId: input.guildId,
channelId: message.channel_id,
};
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Платформенный адаптер клиента (docs/client-tauri.md §2).
*
* Один и тот же веб-код работает и в браузере, и внутри desktop-обёртки Tauri:
* реализация выбирается при старте по наличию моста Tauri, а вызовы платформы
* идут только через этот модуль. В браузере доступно не всё (нативные
* уведомления — по разрешению браузера, трея и бейджа нет), поэтому у каждой
* реализации свои возможности, а не заглушки «на будущее».
*/
/** Настройки обёртки: поля совпадают с Rust-структурой `Settings`. */
export interface DesktopSettings {
/** Адрес инстанса, загруженного в окно. */
instanceUrl: string;
/** Закрытие окна сворачивает приложение в трей. */
closeToTray: boolean;
/** Автозапуск при входе в систему. */
autostart: boolean;
/** Вопрос об автозапуске уже задавали. */
autostartPrompted: boolean;
pttShortcut: string;
muteShortcut: string;
deafenShortcut: string;
/** Нативные уведомления включены. */
notifications: boolean;
}
/** Сообщение из обёртки (см. `WebMessage` в desktop/src-tauri/src/bridge.rs). */
export type DesktopMessage =
| { type: 'ptt'; active: boolean }
| { type: 'toggleMic' }
| { type: 'toggleDeafen' }
| { type: 'deepLink'; url: string }
| { type: 'settings'; settings: DesktopSettings }
| { type: 'updateAvailable'; version: string };
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
export interface DesktopNotification {
title: string;
body: string;
guildId?: string | null;
channelId?: string | null;
}
export type PlatformKind = 'web' | 'tauri';
export interface Platform {
readonly kind: PlatformKind;
/** Показать системное уведомление (если платформа это умеет). */
notify(notification: DesktopNotification): void;
/** Число непрочитанных для значка приложения и трея. */
setBadge(count: number): void;
/** Сообщить обёртке, что клиент загрузился (deep links ждут этого). */
ready(): void;
/** Открыть страницу настроек обёртки. */
openSettings(): void;
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
onMessage(handler: (message: DesktopMessage) => void): () => void;
}
/** Мост Tauri v2: этого объекта в браузере нет. */
interface TauriInternals {
invoke(command: string, args?: Record<string, unknown>): Promise<unknown>;
}
interface DesktopBridgeGlobal {
__TAURI_INTERNALS__?: TauriInternals;
__glchatDesktop?: { receive: (message: DesktopMessage) => void };
}
function tauri(): TauriInternals | null {
const bridge = window as unknown as DesktopBridgeGlobal;
const internals = bridge.__TAURI_INTERNALS__;
return internals !== undefined && typeof internals.invoke === 'function' ? internals : null;
}
/** Открыт ли клиент внутри desktop-обёртки. */
export function isTauri(): boolean {
return tauri() !== null;
}
/** Реализация для браузера: системных возможностей почти нет. */
const webPlatform: Platform = {
kind: 'web',
notify(notification) {
// В браузере показываем только при уже выданном разрешении: запрашивать
// его без действия пользователя нельзя, а веб-пуш — отдельная фаза.
if (!('Notification' in window) || Notification.permission !== 'granted') {
return;
}
try {
new Notification(notification.title, { body: notification.body });
} catch {
// Браузер может запретить уведомление (например, в фоновой вкладке) —
// это не ошибка клиента.
}
},
setBadge(count) {
// В браузере счётчик виден в заголовке вкладки: это делает сам клиент.
void count;
},
ready() {},
openSettings() {},
onMessage() {
return () => {};
},
};
/** Реализация для desktop-обёртки: тонкие вызовы команд Rust. */
function tauriPlatform(internals: TauriInternals): Platform {
const handlers = new Set<(message: DesktopMessage) => void>();
const bridge = window as unknown as DesktopBridgeGlobal;
bridge.__glchatDesktop = {
receive(message) {
for (const handler of handlers) {
handler(message);
}
},
};
/** Команды обёртки не должны ронять клиент: ошибку только логируем. */
const call = (command: string, args?: Record<string, unknown>): void => {
void internals.invoke(command, args).catch((error: unknown) => {
console.warn(`[desktop] команда ${command} не выполнена`, error);
});
};
return {
kind: 'tauri',
notify(notification) {
call('desktop_notify', {
payload: {
title: notification.title,
body: notification.body,
guildId: notification.guildId ?? null,
channelId: notification.channelId ?? null,
},
});
},
setBadge(count) {
call('desktop_set_badge', { count });
},
ready() {
call('desktop_ready');
},
openSettings() {
call('desktop_open_settings');
},
onMessage(handler) {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
},
};
}
const internals = tauri();
/** Платформа текущего запуска: браузер или desktop-обёртка. */
export const platform: Platform = internals === null ? webPlatform : tauriPlatform(internals);
/**
* Маршрут клиента для deep link обёртки (docs/client-tauri.md §5).
*
* Поддерживаются `glchat://invite/<code>` и
* `glchat://guild/<guildId>/channel/<channelId>`; неизвестная ссылка даёт `null`.
*/
export function routeFromDeepLink(link: string): string | null {
let url: URL;
try {
url = new URL(link);
} catch {
return null;
}
if (url.protocol !== 'glchat:') {
return null;
}
const segments = url.pathname.split('/').filter((segment) => segment !== '');
// В `glchat://invite/ABC` хост — это первый сегмент пути.
const [kind, ...rest] = [url.hostname, ...segments];
if (kind === 'invite') {
const code = rest[0];
return rest.length === 1 && code !== undefined ? `/invite/${encodeURIComponent(code)}` : null;
}
if (kind === 'guild') {
const [guildId, marker, channelId] = rest;
if (
rest.length !== 3 ||
marker !== 'channel' ||
guildId === undefined ||
channelId === undefined
) {
return null;
}
return `/app/${encodeURIComponent(guildId)}/${encodeURIComponent(channelId)}`;
}
return null;
}