From 0f308fdbd7ef750cfc97b5387264009816577f6b Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 10 Sep 2026 00:23:14 +0300 Subject: [PATCH] =?UTF-8?q?chatgui/chatgui-android:=20=D1=83=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B2=D1=83=D0=BA=D0=BE=D0=BC,=20=D1=84=D0=BB=D0=B0?= =?UTF-8?q?=D0=B3=D0=B8=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=87=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/utun/chat/ChatApplication.kt | 5 + .../java/com/utun/chat/data/ConfigProvider.kt | 24 ++++ .../com/utun/chat/data/NotificationSound.kt | 38 +++++++ .../utun/chat/ui/screens/ChannelListScreen.kt | 58 +++++++++- .../utun/chat/ui/screens/SettingsScreen.kt | 17 +++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 5 + .../app/src/main/res/raw/msg_incoming.mp3 | Bin 0 -> 34480 bytes tools/chatgui/resources/chatgui.qrc | 1 + tools/chatgui/src/accountdelegate.cpp | 6 +- tools/chatgui/src/flagpainter.cpp | 20 ++++ tools/chatgui/src/flagpainter.h | 1 + tools/chatgui/src/main.cpp | 9 +- tools/chatgui/src/mainwindow.cpp | 103 +++++++++++++++--- tools/chatgui/src/mainwindow.h | 7 ++ tools/chatgui/src/memberlistmodel.cpp | 2 + tools/chatgui/src/memberlistmodel.h | 1 + tools/chatgui/src/memberpropsdialog.cpp | 6 +- tools/chatgui/src/sound_manager.cpp | 8 +- tools/chatgui/transport/node_config.cpp | 1 + tools/chatgui/transport/utun_node.cpp | 12 -- 20 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/NotificationSound.kt create mode 100644 tools/chatgui-android/app/src/main/res/raw/msg_incoming.mp3 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 4bd6da21..07fa39fa 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -5,6 +5,7 @@ import android.util.Log import com.utun.chat.data.LogManager import com.utun.chat.data.ConfigProvider import com.utun.chat.data.NativeLib +import com.utun.chat.data.NotificationSound import com.utun.chat.data.ChatEventCallback import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -29,6 +30,10 @@ class ChatApplication : Application() { logManager = LogManager configProvider = ConfigProvider(this) + /* Notification sound state: global toggle + per-group mute */ + NotificationSound.enabled = configProvider.getSoundEnabled() + NotificationSound.mutedChannels = configProvider.getMutedChannels() + /* One-time event callback registration — survives ViewModel recreation */ NativeLib.setEventCallback(AppEventHandler) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 136c2ec1..164d6c33 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -48,6 +49,8 @@ class ConfigProvider(private val context: Context) { private val firstLaunchDone = booleanPreferencesKey("first_launch_done") private val connectionModeKey = stringPreferencesKey("connection_mode") private val serviceEnabledKey = booleanPreferencesKey("service_enabled") + private val soundEnabledKey = booleanPreferencesKey("sound_enabled") + private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private fun keysFilePath(): String { val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath @@ -220,6 +223,27 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled } } + /* ── Notification sound (global toggle + per-group mute) ── */ + + fun getSoundEnabled(): Boolean = runBlocking { + context.dataStore.data.first()[soundEnabledKey] ?: true + } + + suspend fun setSoundEnabled(enabled: Boolean) { + context.dataStore.edit { prefs -> prefs[soundEnabledKey] = enabled } + } + + fun getMutedChannels(): Set = runBlocking { + context.dataStore.data.first()[mutedChannelsKey] ?: emptySet() + } + + suspend fun setChannelMuted(channelId: String, muted: Boolean) { + context.dataStore.edit { prefs -> + val cur = prefs[mutedChannelsKey] ?: emptySet() + prefs[mutedChannelsKey] = if (muted) cur + channelId else cur - channelId + } + } + /* ── Server entries ── */ suspend fun detectInterfaces(saved: List = emptyList()): List { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NotificationSound.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NotificationSound.kt new file mode 100644 index 00000000..0dac84dd --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NotificationSound.kt @@ -0,0 +1,38 @@ +package com.utun.chat.data + +import android.media.AudioAttributes +import android.media.MediaPlayer +import com.utun.chat.ChatApplication +import com.utun.chat.R + +/* Уведомительный звук входящих сообщений (msg_incoming.mp3). + Глобальный тумблер (enabled) + per-group mute (mutedChannels). */ +object NotificationSound { + @Volatile var enabled: Boolean = true + @Volatile var mutedChannels: Set = emptySet() + + fun isMuted(channelId: String): Boolean = channelId in mutedChannels + + fun setMuted(channelId: String, muted: Boolean) { + mutedChannels = if (muted) mutedChannels + channelId else mutedChannels - channelId + } + + fun play(channelId: String) { + if (!enabled || isMuted(channelId)) return + try { + val mp = MediaPlayer.create(ChatApplication.instance, R.raw.msg_incoming) + ?: return + mp.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + mp.setOnCompletionListener { it.release() } + mp.start() + LogManager.addLog("DEBUG", "Sound", "notification played ch=$channelId") + } catch (e: Exception) { + LogManager.addLog("WARN", "Sound", "play failed: ${e.message}") + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index 3207076f..0059f0aa 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -23,6 +23,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.data.Channel +import com.utun.chat.data.NotificationSound +import com.utun.chat.ChatApplication +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -39,6 +42,8 @@ fun ChannelListScreen( onRefresh: () -> Unit = {} ) { var showCreateDialog by remember { mutableStateOf(false) } + var menuChannelId by remember { mutableStateOf(null) } + var optionsChannel by remember { mutableStateOf(null) } LaunchedEffect(Unit) { onRefresh() } @@ -88,9 +93,22 @@ fun ChannelListScreen( Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) Row(Modifier.fillMaxWidth().padding(12.dp)) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { - IconButton(onClick = { onInviteClick(ch) }, modifier = Modifier.size(44.dp)) { + IconButton(onClick = { menuChannelId = ch.id }, modifier = Modifier.size(44.dp)) { Text("#", fontSize = 22.sp) } + DropdownMenu( + expanded = menuChannelId == ch.id, + onDismissRequest = { menuChannelId = null } + ) { + DropdownMenuItem( + text = { Text("Invite link") }, + onClick = { menuChannelId = null; onInviteClick(ch) } + ) + DropdownMenuItem( + text = { Text("Options") }, + onClick = { menuChannelId = null; optionsChannel = ch } + ) + } if (badgeText.isNotEmpty()) { val badgeSize = 18.dp Box( @@ -137,6 +155,44 @@ fun ChannelListScreen( } ) } + + optionsChannel?.let { ch -> + ChannelOptionsDialog( + channel = ch, + onDismiss = { optionsChannel = null } + ) + } +} + +@Composable +fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + var soundOn by remember(channel.id) { mutableStateOf(!NotificationSound.isMuted(channel.id)) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(channel.name) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Sound", modifier = Modifier.weight(1f)) + Switch( + checked = soundOn, + onCheckedChange = { + soundOn = it + NotificationSound.setMuted(channel.id, !it) + scope.launch { + ChatApplication.instance.configProvider.setChannelMuted(channel.id, !it) + } + } + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) } @Composable diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index a5a2aa96..3196302d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.utun.chat.ChatApplication import com.utun.chat.data.NativeLib +import com.utun.chat.data.NotificationSound import com.utun.chat.data.ServerEntry import com.utun.chat.data.LocalSocketState import com.utun.chat.headless.HeadlessService @@ -447,6 +448,9 @@ private fun AudioSettingsTab(vm: ChatViewModel) { var micTesting by remember { mutableStateOf(false) } var micLevel by remember { mutableStateOf(0f) } + /* Notification sound */ + var soundEnabled by remember { mutableStateOf(provider.getSoundEnabled()) } + LaunchedEffect(micTesting) { if (micTesting) { while (true) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) } @@ -486,6 +490,19 @@ private fun AudioSettingsTab(vm: ChatViewModel) { modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + /* ── Notification sound ── */ + Text("Notification sound", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Play sound on incoming message", modifier = Modifier.weight(1f)) + Switch(checked = soundEnabled, onCheckedChange = { + soundEnabled = it + NotificationSound.enabled = it + scope.launch { provider.setSoundEnabled(it) } + }) + } + + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + /* ── Microphone test ── */ Text("Microphone test", style = MaterialTheme.typography.titleMedium) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 96ca2da6..072a138c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -203,6 +203,11 @@ class ChatViewModel : ViewModel() { val chLen = data[0].toInt() and 0xFF if (data.size >= 1 + chLen) { val chId = String(data, 1, chLen) + if (data.size >= 1 + chLen + 8) { + val author = java.nio.ByteBuffer.wrap(data, 1 + chLen, 8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).long + if (author != NativeLib.getMyNodeId()) NotificationSound.play(chId) + } val cur = _currentChannel.value if (cur != null && cur.id == chId) refreshMessages(chId) refreshChannels() diff --git a/tools/chatgui-android/app/src/main/res/raw/msg_incoming.mp3 b/tools/chatgui-android/app/src/main/res/raw/msg_incoming.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..97a035320c074b41b125e99457ec68a8401432db GIT binary patch literal 34480 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 JX(Kv=0RUwLV{-ri literal 0 HcmV?d00001 diff --git a/tools/chatgui/resources/chatgui.qrc b/tools/chatgui/resources/chatgui.qrc index dcda5edc..aaa4fa7a 100644 --- a/tools/chatgui/resources/chatgui.qrc +++ b/tools/chatgui/resources/chatgui.qrc @@ -9,6 +9,7 @@ icons/emoji.png icons/mic.png icons/send.png + sounds/msg_incoming.mp3 bg.jpg diff --git a/tools/chatgui/src/accountdelegate.cpp b/tools/chatgui/src/accountdelegate.cpp index e95fce41..2b2c3841 100644 --- a/tools/chatgui/src/accountdelegate.cpp +++ b/tools/chatgui/src/accountdelegate.cpp @@ -49,6 +49,10 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio int n = __builtin_popcount(visualFlags); flagX += nameFont.pixelSize() * n + (n - 1) * 2; } + if (index.data(Qt::UserRole + 4).toBool()) { + FlagPainter::drawStorage(painter, flagX, option.rect.top() + (option.rect.height() - nameFont.pixelSize()) / 2, nameFont.pixelSize()); + flagX += nameFont.pixelSize() + 2; + } QString name = index.data(Qt::DisplayRole).toString(); painter->drawText(QRect(flagX, option.rect.top() + 2, w - (flagX - x), yMid - option.rect.top() - 2), @@ -62,7 +66,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio QString sub = index.data(Qt::UserRole + 1).toString(); if (!sub.isEmpty()) - painter->drawText(QRect(x, yMid + 2, w, option.rect.bottom() - yMid - 2), + painter->drawText(QRect(flagX, yMid + 2, w - (flagX - x), option.rect.bottom() - yMid - 2), Qt::AlignLeft | Qt::AlignTop, sub); painter->restore(); diff --git a/tools/chatgui/src/flagpainter.cpp b/tools/chatgui/src/flagpainter.cpp index f8fe3f04..48d44c7a 100644 --- a/tools/chatgui/src/flagpainter.cpp +++ b/tools/chatgui/src/flagpainter.cpp @@ -67,3 +67,23 @@ void FlagPainter::drawShield(QPainter* p, int x, int y, int sz) { p->drawPath(path); p->restore(); } + +void FlagPainter::drawStorage(QPainter* p, int x, int y, int sz) { + p->save(); + p->setRenderHint(QPainter::Antialiasing); + qreal cx = x + sz / 2.0, t = y + 1.0, b = y + sz - 1.0; + qreal w = sz * 0.36, gap = sz * 0.20; + QPainterPath path; + path.moveTo(cx - w, t); + path.lineTo(cx + w, t); + path.lineTo(cx + w, b); + path.lineTo(cx - w, b); + path.closeSubpath(); + p->setPen(QPen(QColor("#37474F"), 0.8)); + p->setBrush(QColor("#78909C")); + p->drawPath(path); + p->setBrush(QColor("#ECEFF1")); + p->drawEllipse(QPointF(cx, t), w, gap); + p->drawEllipse(QPointF(cx, b), w, gap); + p->restore(); +} diff --git a/tools/chatgui/src/flagpainter.h b/tools/chatgui/src/flagpainter.h index e52ea2f5..afb14cf5 100644 --- a/tools/chatgui/src/flagpainter.h +++ b/tools/chatgui/src/flagpainter.h @@ -13,6 +13,7 @@ class FlagPainter { public: static void draw(QPainter* p, uint8_t flags, int x, int y, int sz); + static void drawStorage(QPainter* p, int x, int y, int sz); private: static void drawLightning(QPainter* p, int x, int y, int sz); diff --git a/tools/chatgui/src/main.cpp b/tools/chatgui/src/main.cpp index 841e6668..89a56826 100644 --- a/tools/chatgui/src/main.cpp +++ b/tools/chatgui/src/main.cpp @@ -18,6 +18,7 @@ #ifdef _WIN32 static UtunNode* g_ctrl_node = nullptr; +static MainWindow* g_mainWindow = nullptr; static DWORD count_threads(void) { DWORD pid = GetCurrentProcessId(), count = 0; @@ -34,7 +35,12 @@ static DWORD count_threads(void) { static BOOL WINAPI console_ctrl_handler(DWORD dwCtrlType) { if (dwCtrlType == CTRL_C_EVENT || dwCtrlType == CTRL_BREAK_EVENT) { - if (g_ctrl_node) g_ctrl_node->requestStop(); + if (g_mainWindow) + QMetaObject::invokeMethod(g_mainWindow, [g_mainWindow] { + g_mainWindow->requestShutdown(); + }, Qt::QueuedConnection); + else if (g_ctrl_node) + g_ctrl_node->requestStop(); return TRUE; } return FALSE; @@ -149,6 +155,7 @@ int main(int argc, char *argv[]) { #ifdef _WIN32 g_ctrl_node = w.utunNode(); + g_mainWindow = &w; SetConsoleCtrlHandler(console_ctrl_handler, TRUE); #endif diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index c878b9c4..be47d509 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -28,6 +28,7 @@ extern "C" { #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ extern "C" { #include #include #include +#include extern "C" { #include "chat/chat_core.h" @@ -49,12 +51,16 @@ void chat_core_save_ui_state_trampoline(void* arg); static MainWindow* s_mainWindow = nullptr; -/* Сохранение last_channel_id в БД (ui_state), как все остальные UI-настройки. - Пишется из utun-потока через trampoline, чтобы не трогать sqlite из GUI-потока. */ -static void saveLastChannelId(const QString& channelId) { - const char key[] = "last_channel_id"; - QByteArray val = channelId.toUtf8(); - size_t klen = sizeof(key) - 1, vlen = (size_t)val.size(), total = klen + 1 + vlen + 1; +#ifndef _WIN32 +static volatile std::sig_atomic_t g_signalFlag = 0; +static void unixSignalHandler(int) { g_signalFlag = 1; } +#endif + +/* Сохранение key/value в БД (ui_state). Пишется из utun-потока через trampoline, + чтобы не трогать sqlite из GUI-потока. Буфер формата key\0value\0, ≤256 байт. */ +static void postUiStateKv(const char* key, const QString& value) { + QByteArray val = value.toUtf8(); + size_t klen = strlen(key), vlen = (size_t)val.size(), total = klen + 1 + vlen + 1; if (total > 256) return; void* arg = u_malloc(total); memcpy(arg, key, klen + 1); @@ -62,6 +68,10 @@ static void saveLastChannelId(const QString& channelId) { gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); } +static void saveLastChannelId(const QString& channelId) { + postUiStateKv("last_channel_id", channelId); +} + static void onMsgReceivedCallback(const char* ch_id, int ch_id_len, uint64_t author) { if (s_mainWindow) s_mainWindow->onMessageReceived(ch_id, ch_id_len, author); @@ -136,6 +146,14 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, setupConnects(); } connect(m_autoPlayTimer, &QTimer::timeout, this, &MainWindow::autoPlayTimerTick); + +#ifndef _WIN32 + std::signal(SIGINT, unixSignalHandler); + std::signal(SIGTERM, unixSignalHandler); + m_shutdownTimer = new QTimer(this); + connect(m_shutdownTimer, &QTimer::timeout, this, &MainWindow::onShutdownCheck); + m_shutdownTimer->start(200); +#endif } MainWindow::~MainWindow() { @@ -167,6 +185,7 @@ MainWindow::~MainWindow() { void MainWindow::setupUi() { auto *splitter = new QSplitter(Qt::Horizontal, this); + m_splitter = splitter; m_channelList = new ChannelList(m_db, splitter); m_recorder = new AudioRecorder(this); @@ -186,6 +205,49 @@ void MainWindow::setupUi() { setCentralWidget(splitter); } +void MainWindow::saveWindowGeometry() { + QRect g = normalGeometry(); + if (g.isEmpty()) g = geometry(); + postUiStateKv("window_geometry", + QStringLiteral("%1,%2,%3,%4").arg(g.width()).arg(g.height()).arg(g.x()).arg(g.y())); + postUiStateKv("window_maximized", isMaximized() ? QStringLiteral("1") : QStringLiteral("0")); + + if (m_splitter) { + const QList sizes = m_splitter->sizes(); + if (sizes.size() == 3) + postUiStateKv("window_splitter", + QStringLiteral("%1,%2,%3").arg(sizes[0]).arg(sizes[1]).arg(sizes[2])); + } +} + +void MainWindow::restoreWindowGeometry() { + if (!m_db) return; + const QStringList p = m_db->getUiState("window_geometry").split(','); + if (p.size() != 4) return; + int w = p[0].toInt(), h = p[1].toInt(), x = p[2].toInt(), y = p[3].toInt(); + if (w <= 0 || h <= 0) return; + + QRect target(x, y, w, h); + bool visible = false; + for (const QScreen* s : QGuiApplication::screens()) { + if (s->availableGeometry().intersects(target)) { visible = true; break; } + } + if (!visible) target.moveTo(0, 0); + + setGeometry(target); + if (m_db->getUiStateInt("window_maximized", 0)) + showMaximized(); + + if (m_splitter) { + const QStringList sp = m_db->getUiState("window_splitter").split(','); + if (sp.size() == 3) { + int s0 = sp[0].toInt(), s1 = sp[1].toInt(), s2 = sp[2].toInt(); + if (s0 > 0 && s1 > 0 && s2 > 0) + m_splitter->setSizes({s0, s1, s2}); + } + } +} + void MainWindow::setupNode() { m_node = new UtunNode(this); connect(m_node, &UtunNode::error, this, [](const QString& msg) { @@ -247,12 +309,14 @@ void MainWindow::setupSoundFromConfig() { QString baseDir = QDir::isAbsolutePath(soundDir) ? soundDir : binDir.absoluteFilePath(soundDir); auto resolvePath = [&baseDir](const QString& f) { return f.isEmpty() ? QString() : QDir::cleanPath(baseDir + "/" + f); }; - struct { const char* name; QString file; } events[] = { - {"msg_incoming", msgInFile}, {"msg_outgoing", msgOutFile}, - {"connect", connectFile}, {"disconnect", disconnectFile}, - }; - for (auto& e : events) { if (!e.file.isEmpty()) sm->loadSound(e.name, resolvePath(e.file)); } + if (msgInFile.isEmpty()) + sm->loadSound("msg_incoming", ":/sounds/msg_incoming.mp3"); + else + sm->loadSound("msg_incoming", resolvePath(msgInFile)); + if (!msgOutFile.isEmpty()) sm->loadSound("msg_outgoing", resolvePath(msgOutFile)); + if (!connectFile.isEmpty()) sm->loadSound("connect", resolvePath(connectFile)); + if (!disconnectFile.isEmpty()) sm->loadSound("disconnect", resolvePath(disconnectFile)); if (m_db && m_db->isOpen()) { sm->setEnabled(m_db->getUiStateInt("sound_enabled", 1)); sm->setVolume(m_db->getUiStateInt("sound_volume", 80) / 100.0f); @@ -307,6 +371,7 @@ void MainWindow::setupBridgeCallbacks() { if (!s_mainWindow || !s_mainWindow->m_db) return; s_mainWindow->m_db->setDb(chat_core_get_db()); s_mainWindow->m_channelList->loadChannels(); + s_mainWindow->restoreWindowGeometry(); QString lastCid = s_mainWindow->m_db->getUiState("last_channel_id"); if (!lastCid.isEmpty()) { /* восстанавливаем канал только если он реально существует в БД — @@ -402,9 +467,7 @@ void MainWindow::setupTray() { }); QAction *quitAction = m_trayMenu->addAction("Quit"); - connect(quitAction, &QAction::triggered, this, [this]() { - if (m_node) m_node->requestStop(); - }); + connect(quitAction, &QAction::triggered, this, &MainWindow::requestShutdown); m_trayIcon->setContextMenu(m_trayMenu); @@ -416,12 +479,24 @@ void MainWindow::setupTray() { void MainWindow::closeEvent(QCloseEvent *event) { DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MainWindow: closeEvent — hiding to tray"); + saveWindowGeometry(); hide(); m_trayIcon->showMessage("vibechat", "Application minimized to tray.", QSystemTrayIcon::Information, 2000); event->ignore(); } +void MainWindow::requestShutdown() { + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MainWindow: requestShutdown — saving geometry and stopping node"); + saveWindowGeometry(); + if (m_node) m_node->requestStop(); +} + +void MainWindow::onShutdownCheck() { + if (g_signalFlag) + requestShutdown(); +} + void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) { if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) { diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h index 0396bd05..e638fa3c 100644 --- a/tools/chatgui/src/mainwindow.h +++ b/tools/chatgui/src/mainwindow.h @@ -11,6 +11,7 @@ class ChannelList; class MessageList; class AccountList; class DbManager; +class QSplitter; class UtunNode; class AudioRecorder; class ConnMonitorWindow; @@ -59,22 +60,27 @@ public: void onChannelPeersOnline(const char* ch_id, int ch_id_len, uint16_t online); void reloadChannels(); void onAttachmentDownloadedForAutoPlay(const QString& chId, int64_t msgId); + void requestShutdown(); UtunNode* utunNode() const { return m_node; } private: void setupUi(); + void saveWindowGeometry(); + void restoreWindowGeometry(); void setupTray(); void setupNode(); void setupMessaging(); void setupSoundFromConfig(); void setupBridgeCallbacks(); void setupConnects(); + void onShutdownCheck(); void autoPlayCheckNewMessage(const QString& channelId); QString autoPlayMediaDir() const; ChannelList *m_channelList; MessageList *m_messageList; AccountList *m_accountList; + QSplitter *m_splitter = nullptr; QSystemTrayIcon *m_trayIcon; QMenu *m_trayMenu; DbManager *m_db; @@ -93,5 +99,6 @@ private: QQueue m_autoPlayQueue; QTimer* m_autoPlayTimer = nullptr; + QTimer* m_shutdownTimer = nullptr; QSet m_autoPlayDownloading; /* msgIds with download in progress */ }; diff --git a/tools/chatgui/src/memberlistmodel.cpp b/tools/chatgui/src/memberlistmodel.cpp index 4c68d011..9321e8cf 100644 --- a/tools/chatgui/src/memberlistmodel.cpp +++ b/tools/chatgui/src/memberlistmodel.cpp @@ -51,6 +51,7 @@ QVariant MemberListModel::data(const QModelIndex& index, int role) const { QString("%1").arg(it->nodeId, 16, 16, QChar('0')); if (role == Qt::UserRole + 2) return it->isSelf; if (role == Qt::UserRole + 3) return it->flags; + if (role == Qt::UserRole + 4) return (bool)it->storage; return {}; } return {}; @@ -94,6 +95,7 @@ static void deserialize_member(const uint8_t* src, MemberListItem& item) { item.rtt = (rtt == 0xFFFF) ? -1 : (int)rtt; int nat = (int)src[13]; item.natType = (nat == 0xFF) ? -1 : nat; + item.storage = src[14]; item.online = (online != 0); char name[64]; diff --git a/tools/chatgui/src/memberlistmodel.h b/tools/chatgui/src/memberlistmodel.h index 501b4aaa..ca69f797 100644 --- a/tools/chatgui/src/memberlistmodel.h +++ b/tools/chatgui/src/memberlistmodel.h @@ -19,6 +19,7 @@ struct MemberListItem { int rtt = -1; int natType = -1; uint8_t flags = 0; + uint8_t storage = 0; }; class MemberListModel : public QAbstractListModel { diff --git a/tools/chatgui/src/memberpropsdialog.cpp b/tools/chatgui/src/memberpropsdialog.cpp index 474b0afe..79665c65 100644 --- a/tools/chatgui/src/memberpropsdialog.cpp +++ b/tools/chatgui/src/memberpropsdialog.cpp @@ -47,6 +47,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, QString curNick; bool curVerified = false; bool curDeleted = false; + bool curStorage = false; m_currentVer = 0; if (m_db->m_db) { QString tbl = m_db->peersTableName(channelId); @@ -65,6 +66,8 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, if (vr) { vr += 12; curVerified = (strncmp(vr, "yes", 3) == 0); } const char* dl = strstr(tags, "\"deleted\":\""); if (dl) { dl += 11; curDeleted = (strncmp(dl, "yes", 3) == 0); } + const char* sg = strstr(tags, "\"storage\":\""); + if (sg) { sg += 11; curStorage = (strncmp(sg, "yes", 3) == 0); } } curNick = DbManager::colText(st, 1); } @@ -100,6 +103,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId, layout->addWidget(m_superCb); m_storageCb = new QCheckBox("Storage (priority attachment keeper)", this); + m_storageCb->setChecked(curStorage); m_storageCb->setEnabled(haveKey); layout->addWidget(m_storageCb); @@ -180,7 +184,7 @@ void MemberPropsDialog::onSave() { /* signed fields — через chat_member_tags_commit_trampoline */ if (m_edPrivkey.size() != 32) { accept(); return; } - int ver = m_currentVer + 1; + int ver = m_currentVer; QString json; json += QString("{\"ver\":\"%1\"").arg(ver); json += QString(",\"admin\":\"%1\"").arg(m_adminCb->isChecked() ? "yes" : "no"); diff --git a/tools/chatgui/src/sound_manager.cpp b/tools/chatgui/src/sound_manager.cpp index 0668aa43..cb6c14f0 100644 --- a/tools/chatgui/src/sound_manager.cpp +++ b/tools/chatgui/src/sound_manager.cpp @@ -131,13 +131,17 @@ bool SoundManager::loadSound(const QString& name, const QString& filePath) { } void SoundManager::play(const QString& name) { - if (!m_enabled || !m_initialized) return; + if (!m_enabled) return; + if (!m_initialized) { DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "SoundManager::play: not initialized"); return; } EventSettings es = m_eventSettings.value(name); if (!es.enabled) return; auto it = m_sounds.find(name); - if (it == m_sounds.end()) return; + if (it == m_sounds.end()) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "SoundManager::play: sound '%s' not loaded", qPrintable(name)); + return; + } ma_sound* s = it->sound; float effectiveVolume = m_volume * es.volume; diff --git a/tools/chatgui/transport/node_config.cpp b/tools/chatgui/transport/node_config.cpp index 76637069..7511e76f 100644 --- a/tools/chatgui/transport/node_config.cpp +++ b/tools/chatgui/transport/node_config.cpp @@ -125,6 +125,7 @@ static bool isSectionValid(const QString& section) { if (section == "tcp_proxy_client") return true; if (section == "tcp_proxy_server") return true; if (section == "ntp") return true; + if (section == "chat" || section == "chatserver") return true; return false; } diff --git a/tools/chatgui/transport/utun_node.cpp b/tools/chatgui/transport/utun_node.cpp index 443b8fcd..d4977d3e 100644 --- a/tools/chatgui/transport/utun_node.cpp +++ b/tools/chatgui/transport/utun_node.cpp @@ -2,7 +2,6 @@ #include "utun_node.h" #include #include -#include #include "../../lib/socket_compat.h" // === C headers === @@ -34,11 +33,6 @@ extern "C" { static UtunNode* g_currentNode = nullptr; -static void signal_handler(int sig) { - (void)sig; - if (g_currentNode) g_currentNode->requestStop(); -} - UtunNode::UtunNode(QObject* parent) : QObject(parent) {} UtunNode::~UtunNode() { @@ -272,9 +266,6 @@ void UtunNode::runLoop() { DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "chat_core + chat_sync initialized"); DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "utun_node: entering poll loop"); - signal(SIGINT, signal_handler); - signal(SIGTERM, signal_handler); - while (!m_stop) { uasync_poll(ua, 100); } @@ -287,9 +278,6 @@ void UtunNode::runLoop() { m_instance = nullptr; } - signal(SIGINT, SIG_DFL); - signal(SIGTERM, SIG_DFL); - DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "runLoop: emitting stopped"); QMetaObject::invokeMethod(this, [this] { emit stopped(); }); }