2026-09-22 21:46:01 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Платформенный адаптер клиента (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 }
|
2026-09-22 23:17:32 +03:00
|
|
|
|
| { type: 'updateAvailable'; version: string }
|
|
|
|
|
|
/** Обёртка перехватила закрытие окна: у клиента есть несохранённые правки. */
|
|
|
|
|
|
| { type: 'closeRequested' }
|
|
|
|
|
|
/** Глобальный Ctrl/Cmd+K при неактивном окне: клиент открывает палитру. */
|
|
|
|
|
|
| { type: 'openPalette' };
|
2026-09-22 21:46:01 +03:00
|
|
|
|
|
|
|
|
|
|
/** Уведомление для системы. Текст готовит клиент, показывает — платформа. */
|
|
|
|
|
|
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;
|
2026-09-22 23:17:32 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Сообщить платформе, что в клиенте есть несохранённые изменения
|
|
|
|
|
|
* (docs/client-tauri.md §5). Обёртка перехватывает закрытие окна и присылает
|
|
|
|
|
|
* `closeRequested`; в браузере это штатное подтверждение `beforeunload`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
setDirty(dirty: boolean): void;
|
|
|
|
|
|
/** Закрыть окно приложения: обёртка завершает процесс, браузер — вкладку. */
|
|
|
|
|
|
closeWindow(): void;
|
2026-09-22 21:46:01 +03:00
|
|
|
|
/** Подписка на сообщения обёртки; возвращает функцию отписки. */
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 23:17:32 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Штатное подтверждение закрытия вкладки: браузер показывает свой диалог,
|
|
|
|
|
|
* текст в нём не настраивается (`event.returnValue` обязателен для Chrome).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function confirmUnload(event: BeforeUnloadEvent): void {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.returnValue = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Подписан ли браузер на подтверждение закрытия: лишних подписок не плодим. */
|
|
|
|
|
|
let unloadGuard = false;
|
|
|
|
|
|
|
2026-09-22 21:46:01 +03:00
|
|
|
|
/** Реализация для браузера: системных возможностей почти нет. */
|
|
|
|
|
|
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() {},
|
2026-09-22 23:17:32 +03:00
|
|
|
|
setDirty(dirty) {
|
|
|
|
|
|
// Браузер умеет только штатное подтверждение закрытия вкладки. Обработчик
|
|
|
|
|
|
// один на страницу: повторный `setDirty(true)` не должен вешать второй.
|
|
|
|
|
|
if (dirty === unloadGuard) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
unloadGuard = dirty;
|
|
|
|
|
|
if (dirty) {
|
|
|
|
|
|
window.addEventListener('beforeunload', confirmUnload);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.removeEventListener('beforeunload', confirmUnload);
|
|
|
|
|
|
},
|
|
|
|
|
|
closeWindow() {
|
|
|
|
|
|
// Страница, открытая не скриптом, закрыться не даст — это ограничение
|
|
|
|
|
|
// браузера, а не ошибка клиента.
|
|
|
|
|
|
window.close();
|
|
|
|
|
|
},
|
2026-09-22 21:46:01 +03:00
|
|
|
|
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');
|
|
|
|
|
|
},
|
2026-09-22 23:17:32 +03:00
|
|
|
|
setDirty(dirty) {
|
|
|
|
|
|
// Обёртка по этому флагу решает, перехватывать ли закрытие окна.
|
|
|
|
|
|
call('desktop_set_dirty', { dirty });
|
|
|
|
|
|
},
|
|
|
|
|
|
closeWindow() {
|
|
|
|
|
|
call('desktop_close_window');
|
|
|
|
|
|
},
|
2026-09-22 21:46:01 +03:00
|
|
|
|
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).
|
|
|
|
|
|
*
|
2026-09-22 23:17:32 +03:00
|
|
|
|
* Поддерживаются `glchat://invite/<code>`, `glchat://guild/<guildId>/channel/<channelId>`
|
|
|
|
|
|
* и `glchat://dm/<channelId>` (клик по уведомлению о личном сообщении: сервера
|
|
|
|
|
|
* у беседы нет); неизвестная ссылка даёт `null`.
|
2026-09-22 21:46:01 +03:00
|
|
|
|
*/
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-22 23:17:32 +03:00
|
|
|
|
if (kind === 'dm') {
|
|
|
|
|
|
const channelId = rest[0];
|
|
|
|
|
|
return rest.length === 1 && channelId !== undefined
|
|
|
|
|
|
? `/app/friends/${encodeURIComponent(channelId)}`
|
|
|
|
|
|
: null;
|
|
|
|
|
|
}
|
2026-09-22 21:46:01 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|