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
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>glchat — настройки приложения</title>
<link rel="stylesheet" href="settings.css" />
</head>
<body>
<main class="page">
<header class="head">
<h1>Настройки приложения</h1>
<p class="muted">
Это настройки desktop-обёртки. Настройки аккаунта, серверов и голоса живут внутри самого
клиента.
</p>
</header>
<section class="card">
<h2>Инстанс</h2>
<label class="field">
<span>Адрес клиента</span>
<input id="instance-url" type="text" spellcheck="false" placeholder="https://gl.mhspx.su" />
</label>
<p class="hint">
Тот же адрес, что открывается в браузере. Сторонние ссылки уходят в системный браузер, а
не открываются внутри окна.
</p>
<div class="row">
<button id="open-client" class="primary" type="button">Сохранить и открыть клиент</button>
<button id="reload-client" type="button">Перезагрузить клиент</button>
</div>
</section>
<section class="card">
<h2>Поведение</h2>
<label class="check">
<input id="autostart" type="checkbox" />
<span>Запускать при входе в систему (свёрнутым в трей)</span>
</label>
<label class="check">
<input id="close-to-tray" type="checkbox" />
<span>Закрытие окна сворачивает приложение в трей</span>
</label>
<label class="check">
<input id="notifications" type="checkbox" />
<span>Показывать системные уведомления об упоминаниях и личных сообщениях</span>
</label>
</section>
<section class="card">
<h2>Глобальные горячие клавиши</h2>
<p class="hint">
Работают, когда окно неактивно. Push-to-talk включает микрофон, пока клавиша удерживается:
в клиенте должен быть выбран режим микрофона «Push-to-talk» (Настройки → Голос).
</p>
<label class="field">
<span>Push-to-talk</span>
<select id="ptt-shortcut"></select>
</label>
<label class="field">
<span>Микрофон вкл/выкл</span>
<select id="mute-shortcut"></select>
</label>
<label class="field">
<span>Не слышу (deafen)</span>
<select id="deafen-shortcut"></select>
</label>
</section>
<section class="card">
<h2>Обновления</h2>
<p class="hint" id="updates-hint">
Обновления проверяются при запуске и вручную. Манифест отдаёт сам инстанс.
</p>
<div class="row">
<button id="check-updates" type="button">Проверить обновления</button>
<span class="muted" id="version"></span>
</div>
</section>
<footer class="foot">
<button id="save" class="primary" type="button">Сохранить</button>
<span id="status" class="status" role="status"></span>
</footer>
</main>
<script type="module" src="settings.js"></script>
</body>
</html>
+154
View File
@@ -0,0 +1,154 @@
:root {
--bg: #1a1b1e;
--panel: #232428;
--border: #33353b;
--fg: #e6e7ea;
--muted: #9a9ca3;
--accent: #4c5bd4;
--accent-fg: #ffffff;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #dcdee4;
--fg: #1c1d21;
--muted: #5f626b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font:
14px/1.5 -apple-system,
"Segoe UI",
Roboto,
sans-serif;
}
.page {
max-width: 640px;
margin: 0 auto;
padding: 32px 24px 48px;
display: flex;
flex-direction: column;
gap: 20px;
}
.head h1 {
margin: 0 0 6px;
font-size: 22px;
}
.muted {
color: var(--muted);
}
.hint {
margin: 8px 0 0;
color: var(--muted);
font-size: 12.5px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px 20px;
}
.card h2 {
margin: 0 0 12px;
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.field > span {
font-weight: 500;
}
.check {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 10px;
}
input[type='text'],
select {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 11px;
font: inherit;
}
input[type='text']:focus,
select:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 12px;
}
button {
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 14px;
font: inherit;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.foot {
display: flex;
align-items: center;
gap: 14px;
position: sticky;
bottom: 0;
padding: 14px 0 0;
background: linear-gradient(transparent, var(--bg) 40%);
}
.status {
color: var(--muted);
font-size: 13px;
}
.status.error {
color: #e06c75;
}
+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();