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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user