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
+165
View File
@@ -0,0 +1,165 @@
// Страница настроек desktop-обёртки.
//
// Локальная страница (tauri://localhost) внутри главного окна: команды обёртки
// вызываются через встроенный IPC Tauri. Ничего, кроме настроек платформы,
// здесь нет — настройки клиента живут в самом клиенте.
/** Вызов команды обёртки: тонкая обёртка над IPC Tauri v2. */
const invoke = (command, args) => window.__TAURI_INTERNALS__.invoke(command, args);
/** Варианты комбинаций: пустая строка — «выключено». */
const OFF = { value: '', label: 'Выключено' };
const PTT_OPTIONS = [
OFF,
{ value: 'CommandOrControl+Shift+Space', label: 'Cmd/Ctrl + Shift + Пробел' },
{ value: 'CommandOrControl+Shift+M', label: 'Cmd/Ctrl + Shift + M' },
{ value: 'CommandOrControl+Shift+V', label: 'Cmd/Ctrl + Shift + V' },
{ value: 'CommandOrControl+Shift+B', label: 'Cmd/Ctrl + Shift + B' },
{ value: 'Control+Shift+Space', label: 'Ctrl + Shift + Пробел' },
];
const MUTE_OPTIONS = [
OFF,
{ value: 'CommandOrControl+Shift+M', label: 'Cmd/Ctrl + Shift + M' },
{ value: 'CommandOrControl+Shift+U', label: 'Cmd/Ctrl + Shift + U' },
{ value: 'Control+Shift+M', label: 'Ctrl + Shift + M' },
];
const DEAFEN_OPTIONS = [
OFF,
{ value: 'CommandOrControl+Shift+D', label: 'Cmd/Ctrl + Shift + D' },
{ value: 'CommandOrControl+Shift+F', label: 'Cmd/Ctrl + Shift + F' },
{ value: 'Control+Shift+D', label: 'Ctrl + Shift + D' },
];
/** Поля формы и соответствующие настройки. */
const fields = {
instanceUrl: document.getElementById('instance-url'),
autostart: document.getElementById('autostart'),
closeToTray: document.getElementById('close-to-tray'),
notifications: document.getElementById('notifications'),
pttShortcut: document.getElementById('ptt-shortcut'),
muteShortcut: document.getElementById('mute-shortcut'),
deafenShortcut: document.getElementById('deafen-shortcut'),
};
const status = document.getElementById('status');
const version = document.getElementById('version');
const updatesHint = document.getElementById('updates-hint');
/** Заполняет выпадающий список комбинаций. */
function fillSelect(select, options, current) {
select.replaceChildren();
for (const option of options) {
const node = document.createElement('option');
node.value = option.value;
node.textContent = option.label;
select.append(node);
}
// Комбинация могла быть задана вручную (например, прежней версией) — не теряем её.
if (current && !options.some((option) => option.value === current)) {
const node = document.createElement('option');
node.value = current;
node.textContent = current;
select.append(node);
}
select.value = current ?? '';
}
/** Показывает состояние сохранения. */
function setStatus(text, isError = false) {
status.textContent = text;
status.classList.toggle('error', isError);
}
/** Переносит настройки в форму. */
function render(settings) {
fields.instanceUrl.value = settings.instanceUrl ?? '';
fields.autostart.checked = Boolean(settings.autostart);
fields.closeToTray.checked = Boolean(settings.closeToTray);
fields.notifications.checked = Boolean(settings.notifications);
fillSelect(fields.pttShortcut, PTT_OPTIONS, settings.pttShortcut);
fillSelect(fields.muteShortcut, MUTE_OPTIONS, settings.muteShortcut);
fillSelect(fields.deafenShortcut, DEAFEN_OPTIONS, settings.deafenShortcut);
}
/** Собирает настройки из формы. */
function collect() {
return {
instanceUrl: fields.instanceUrl.value.trim(),
autostart: fields.autostart.checked,
closeToTray: fields.closeToTray.checked,
notifications: fields.notifications.checked,
pttShortcut: fields.pttShortcut.value,
muteShortcut: fields.muteShortcut.value,
deafenShortcut: fields.deafenShortcut.value,
};
}
/** Сохраняет настройки и, если нужно, открывает клиент. */
async function save({ openClient = false } = {}) {
setStatus('Сохраняем…');
try {
const saved = await invoke('desktop_save_settings', { settings: collect() });
render(saved);
setStatus('Сохранено');
if (openClient) {
await invoke('desktop_open_instance');
}
} catch (error) {
setStatus(String(error), true);
}
}
document.getElementById('save').addEventListener('click', () => {
void save();
});
document.getElementById('open-client').addEventListener('click', () => {
void save({ openClient: true });
});
document.getElementById('reload-client').addEventListener('click', async () => {
await save();
try {
await invoke('desktop_reload');
} catch (error) {
setStatus(String(error), true);
}
});
document.getElementById('check-updates').addEventListener('click', async () => {
updatesHint.textContent = 'Проверяем обновления…';
try {
await invoke('desktop_check_updates');
updatesHint.textContent = 'Проверка завершена: результат показан в системном диалоге.';
} catch (error) {
updatesHint.textContent = String(error);
}
});
// Автосохранение переключателей и списков: отдельная кнопка нужна только для адреса.
for (const node of [
fields.autostart,
fields.closeToTray,
fields.notifications,
fields.pttShortcut,
fields.muteShortcut,
fields.deafenShortcut,
]) {
node.addEventListener('change', () => {
void save();
});
}
async function start() {
try {
render(await invoke('desktop_settings'));
version.textContent = `Версия обёртки: ${await invoke('desktop_version')}`;
} catch (error) {
setStatus(`Не удалось прочитать настройки: ${error}`, true);
}
}
void start();