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 00000000..97a03532 Binary files /dev/null and b/tools/chatgui-android/app/src/main/res/raw/msg_incoming.mp3 differ 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(); }); }