/** * Платформенный адаптер клиента (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 } /** Обёртка перехватила закрытие окна: у клиента есть несохранённые правки. */ | { type: 'closeRequested' } /** Глобальный Ctrl/Cmd+K при неактивном окне: клиент открывает палитру. */ | { type: 'openPalette' }; /** Уведомление для системы. Текст готовит клиент, показывает — платформа. */ 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; /** * Сообщить платформе, что в клиенте есть несохранённые изменения * (docs/client-tauri.md §5). Обёртка перехватывает закрытие окна и присылает * `closeRequested`; в браузере это штатное подтверждение `beforeunload`. */ setDirty(dirty: boolean): void; /** Закрыть окно приложения: обёртка завершает процесс, браузер — вкладку. */ closeWindow(): void; /** Подписка на сообщения обёртки; возвращает функцию отписки. */ onMessage(handler: (message: DesktopMessage) => void): () => void; } /** Мост Tauri v2: этого объекта в браузере нет. */ interface TauriInternals { invoke(command: string, args?: Record): Promise; } 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; } /** * Штатное подтверждение закрытия вкладки: браузер показывает свой диалог, * текст в нём не настраивается (`event.returnValue` обязателен для Chrome). */ function confirmUnload(event: BeforeUnloadEvent): void { event.preventDefault(); event.returnValue = ''; } /** Подписан ли браузер на подтверждение закрытия: лишних подписок не плодим. */ let unloadGuard = false; /** Реализация для браузера: системных возможностей почти нет. */ 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() {}, setDirty(dirty) { // Браузер умеет только штатное подтверждение закрытия вкладки. Обработчик // один на страницу: повторный `setDirty(true)` не должен вешать второй. if (dirty === unloadGuard) { return; } unloadGuard = dirty; if (dirty) { window.addEventListener('beforeunload', confirmUnload); return; } window.removeEventListener('beforeunload', confirmUnload); }, closeWindow() { // Страница, открытая не скриптом, закрыться не даст — это ограничение // браузера, а не ошибка клиента. window.close(); }, onMessage() { 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): 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'); }, setDirty(dirty) { // Обёртка по этому флагу решает, перехватывать ли закрытие окна. call('desktop_set_dirty', { dirty }); }, closeWindow() { call('desktop_close_window'); }, 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/`, `glchat://guild//channel/` * и `glchat://dm/` (клик по уведомлению о личном сообщении: сервера * у беседы нет); неизвестная ссылка даёт `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 === 'dm') { const channelId = rest[0]; return rest.length === 1 && channelId !== undefined ? `/app/friends/${encodeURIComponent(channelId)}` : 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; }