You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
455 lines
17 KiB
455 lines
17 KiB
#include "mainwindow.h" |
|
#include "channellist.h" |
|
#include "messagelist.h" |
|
#include "accountlist.h" |
|
#include "invitedialog.h" |
|
#include "joindialog.h" |
|
#include "settingsdialog.h" |
|
#include "creategroupdialog.h" |
|
#include "invite_link.h" |
|
#include "sound_manager.h" |
|
#include "audiorecorder.h" |
|
#include "audiocompressor.h" |
|
#include "voicemessageencoder.h" |
|
#include "animtimer.h" |
|
#include "../db/db_manager.h" |
|
#include "../transport/utun_node.h" |
|
#include "../transport/gui_bridge.h" |
|
#include <QSplitter> |
|
#include <QCloseEvent> |
|
#include <QApplication> |
|
#include <QClipboard> |
|
#include <QStyle> |
|
#include <QMessageBox> |
|
#include <QDebug> |
|
#include <QSettings> |
|
#include <QFile> |
|
#include <QTextStream> |
|
#include <QDir> |
|
#include <cmath> |
|
|
|
extern "C" { |
|
#include "../transport/chat_core.h" |
|
#include "../../lib/mem.h" |
|
#include "../../lib/debug_config.h" |
|
} |
|
|
|
static MainWindow* s_mainWindow = nullptr; |
|
|
|
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); |
|
} |
|
|
|
static void onChannelUpdatedCallback(const char* ch_id, int ch_id_len) { |
|
Q_UNUSED(ch_id_len); |
|
Q_UNUSED(ch_id); |
|
if (s_mainWindow) |
|
s_mainWindow->reloadChannels(); |
|
} |
|
|
|
static void onMembersChangedCallback(const char* ch_id, int ch_id_len) { |
|
Q_UNUSED(ch_id_len); |
|
if (s_mainWindow) |
|
s_mainWindow->onMembersChanged(ch_id); |
|
} |
|
|
|
static void onAutoConnectStatusCallback(uint8_t status, uint16_t total_tried, |
|
uint16_t node_count, uint16_t connected) { |
|
if (s_mainWindow) |
|
s_mainWindow->onAutoConnectStatus(status, total_tried, node_count, connected); |
|
} |
|
|
|
static void onChannelPeersOnlineCallback(const char* ch_id, int ch_id_len, uint16_t online) { |
|
if (s_mainWindow) |
|
s_mainWindow->onChannelPeersOnline(ch_id, ch_id_len, online); |
|
} |
|
|
|
MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath, |
|
const QString& dbPath, const QString& debugFile, |
|
const QString& debugLevel, const QString& debugCategories) |
|
: QMainWindow(parent) |
|
, m_channelList(nullptr), m_messageList(nullptr), m_accountList(nullptr) |
|
, m_trayIcon(nullptr), m_trayMenu(nullptr) |
|
, m_db(db), m_cfgPath(cfgPath), m_cfgDbPath(dbPath) |
|
, m_cfgDebugFile(debugFile), m_cfgDebugLevel(debugLevel) |
|
, m_cfgDebugCategories(debugCategories) |
|
, m_recorder(nullptr) |
|
{ |
|
setWindowTitle("Chat"); |
|
resize(900, 600); |
|
if (m_db) m_db->setDbPath(m_cfgDbPath); |
|
setupUi(); |
|
setupTray(); |
|
setupSoundFromConfig(); |
|
if (m_db) { |
|
setupBridgeCallbacks(); |
|
setupMessaging(); |
|
setupNode(); |
|
setupConnects(); |
|
} |
|
} |
|
|
|
MainWindow::~MainWindow() { |
|
if (m_recorder) { m_recorder->shutdown(); m_recorder = nullptr; } |
|
SoundManager::instance()->shutdown(); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: ~MainWindow — saving channel state"); |
|
if (m_messageList) m_messageList->saveChannelState(); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: ~MainWindow — stopping utun node"); |
|
if (m_node) { m_node->stop(); delete m_node; m_node = nullptr; } |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: ~MainWindow — node stop+delete done"); |
|
AnimTimer::shutdown(); |
|
MessageList::cleanupAnimations(); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: ~MainWindow — animations cleaned"); |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: ~MainWindow done"); |
|
} |
|
|
|
void MainWindow::setupUi() { |
|
auto *splitter = new QSplitter(Qt::Horizontal, this); |
|
|
|
m_channelList = new ChannelList(m_db, splitter); |
|
m_recorder = new AudioRecorder(this); |
|
m_messageList = new MessageList(m_db, m_recorder, splitter); |
|
m_accountList = new AccountList(m_db, splitter); |
|
|
|
splitter->addWidget(m_channelList); |
|
splitter->addWidget(m_messageList); |
|
splitter->addWidget(m_accountList); |
|
|
|
splitter->setStretchFactor(0, 2); |
|
splitter->setStretchFactor(1, 5); |
|
splitter->setStretchFactor(2, 1); |
|
|
|
splitter->setSizes({220, 480, 180}); |
|
|
|
setCentralWidget(splitter); |
|
} |
|
|
|
void MainWindow::setupNode() { |
|
m_node = new UtunNode(this); |
|
connect(m_node, &UtunNode::error, this, [](const QString& msg) { |
|
qWarning("UtunNode error: %s", qPrintable(msg)); |
|
}); |
|
connect(m_node, &UtunNode::started, this, []() { |
|
qDebug("UtunNode started"); |
|
}); |
|
connect(m_node, &UtunNode::stopped, this, [this]() { |
|
qDebug("UtunNode stopped"); |
|
if (m_node) m_node->finalize(); |
|
qApp->quit(); |
|
}); |
|
|
|
if (!m_cfgDebugFile.isEmpty()) |
|
m_node->setDebugFile(m_cfgDebugFile); |
|
if (!m_cfgDebugLevel.isEmpty()) |
|
m_node->setDebugLevel(m_cfgDebugLevel); |
|
if (!m_cfgDebugCategories.isEmpty()) |
|
m_node->setDebugCategories(m_cfgDebugCategories); |
|
|
|
if (!m_cfgDbPath.isEmpty()) |
|
m_node->setDbPath(m_cfgDbPath); |
|
|
|
if (!m_node->start(m_cfgPath)) |
|
qWarning("Failed to start UtunNode"); |
|
else |
|
AnimTimer::instance()->logRegisteredIcons(); |
|
} |
|
|
|
void MainWindow::setupMessaging() { |
|
gui_bridge_init(); |
|
} |
|
|
|
void MainWindow::setupSoundFromConfig() { |
|
SoundManager* sm = SoundManager::instance(); |
|
if (!sm->init()) return; |
|
|
|
QString soundDir, msgInFile, msgOutFile, connectFile, disconnectFile; |
|
QFile cfgFile(m_cfgPath); |
|
if (cfgFile.open(QIODevice::ReadOnly | QIODevice::Text)) { |
|
bool inGui = false; |
|
QTextStream in(&cfgFile); |
|
while (!in.atEnd()) { |
|
QString line = in.readLine().trimmed(); |
|
if (line == "[gui]") { inGui = true; continue; } |
|
if (line.startsWith("[") && line.endsWith("]")) inGui = false; |
|
if (!inGui) continue; |
|
if (line.startsWith("sound_dir=")) soundDir = line.mid(10).trimmed(); |
|
else if (line.startsWith("sound_msg_incoming=")) msgInFile = line.mid(19).trimmed(); |
|
else if (line.startsWith("sound_msg_outgoing=")) msgOutFile = line.mid(19).trimmed(); |
|
else if (line.startsWith("sound_connect=")) connectFile = line.mid(14).trimmed(); |
|
else if (line.startsWith("sound_disconnect=")) disconnectFile = line.mid(17).trimmed(); |
|
} |
|
cfgFile.close(); |
|
} |
|
|
|
if (soundDir.isEmpty()) soundDir = "sounds"; |
|
QDir binDir(QApplication::applicationDirPath()); |
|
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 (m_db && m_db->isOpen()) { |
|
sm->setEnabled(m_db->getUiStateInt("sound_enabled", 1)); |
|
sm->setVolume(m_db->getUiStateInt("sound_volume", 80) / 100.0f); |
|
QString se[] = {"msg_incoming", "msg_outgoing", "connect", "disconnect"}; |
|
for (int i = 0; i < 4; i++) { |
|
const QString& ek = se[i]; |
|
sm->setEvent(ek, m_db->getUiStateInt(QStringLiteral("sound_%1_on").arg(ek), i == 0 ? 1 : 0), |
|
m_db->getUiStateInt(QStringLiteral("sound_%1_vol").arg(ek), 80) / 100.0f); |
|
} |
|
} |
|
|
|
if (m_recorder) { |
|
m_recorder->init(); |
|
if (m_db && m_db->isOpen()) { |
|
m_recorder->setCaptureDeviceIndex(m_db->getUiStateInt("audio_input_device", -1)); |
|
VoiceEncoder::setPreset(m_db->getUiStateInt("opus_codec_preset", 1)); |
|
|
|
int compEnabled = m_db->getUiStateInt("compressor_enabled", 0); |
|
m_recorder->setCompressorEnabled(compEnabled != 0); |
|
if (m_recorder->compressor()) { |
|
AudioCompressor::Config compCfg; |
|
compCfg.maxGainDb = (float)m_db->getUiStateInt("compressor_max_gain_db", 30); |
|
compCfg.lookbackMs = m_db->getUiStateInt("compressor_lookback_ms", 200); |
|
compCfg.lookaheadMs = m_db->getUiStateInt("compressor_lookahead_ms", 100); |
|
compCfg.riseRatePer500ms = (float)m_db->getUiStateInt("compressor_rise_rate_tenths", 20) / 10.0f; |
|
compCfg.targetLevel = powf(10.0f, (float)m_db->getUiStateInt("compressor_target_level_db", -12) / 20.0f); |
|
m_recorder->compressor()->configure(compCfg); |
|
} |
|
} |
|
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "AudioRecorder initialized (captureDevice=%d, opusPreset=%d, compressor=%d)", |
|
m_recorder->captureDeviceIndex(), VoiceEncoder::preset(), |
|
m_recorder->isCompressorEnabled() ? 1 : 0); |
|
} |
|
} |
|
|
|
void MainWindow::setupBridgeCallbacks() { |
|
s_mainWindow = this; |
|
gui_bridge_set_msg_received_cb(onMsgReceivedCallback); |
|
gui_bridge_set_channel_updated_cb(onChannelUpdatedCallback); |
|
gui_bridge_set_members_changed_cb(onMembersChangedCallback); |
|
gui_bridge_set_my_node_id_cb([](uint64_t nid) { |
|
qDebug("MainWindow: MY_NODE_ID setMyNodeId(%016llx)", (unsigned long long)nid); |
|
if (s_mainWindow && s_mainWindow->m_db) s_mainWindow->m_db->setMyNodeId((quint64)nid); |
|
}); |
|
gui_bridge_set_auto_connect_status_cb(onAutoConnectStatusCallback); |
|
gui_bridge_set_channel_peers_online_cb(onChannelPeersOnlineCallback); |
|
gui_bridge_set_db_ready_cb([]() { |
|
if (!s_mainWindow || !s_mainWindow->m_db) return; |
|
s_mainWindow->m_db->setDb(chat_core_get_db()); |
|
s_mainWindow->m_channelList->loadChannels(); |
|
QString lastCid = QSettings().value("last_channel_id").toString(); |
|
if (!lastCid.isEmpty()) { |
|
s_mainWindow->m_channelList->selectChannel(lastCid); |
|
s_mainWindow->m_accountList->setChannel(lastCid); |
|
} |
|
}); |
|
} |
|
|
|
void MainWindow::setupConnects() { |
|
connect(m_channelList, &ChannelList::channelSelected, |
|
this, [this](const QString& channelId) { |
|
QSettings().setValue("last_channel_id", channelId); |
|
m_currentChannelId = channelId; |
|
m_messageList->loadChannel(channelId); |
|
m_accountList->setChannel(channelId); |
|
QByteArray cidBA = channelId.toUtf8(); |
|
char* cid = u_strdup(cidBA.constData()); |
|
if (cid) gui_bridge_post_uasync_fn(chat_core_connect_channel_trampoline, cid); |
|
}); |
|
connect(m_channelList, &ChannelList::inviteRequested, this, &MainWindow::showInviteDialog); |
|
connect(m_channelList, &ChannelList::joinRequested, this, &MainWindow::showJoinDialog); |
|
connect(m_channelList, &ChannelList::createGroupRequested, this, &MainWindow::onCreateGroupRequested); |
|
connect(m_channelList, &ChannelList::settingsRequested, this, &MainWindow::showSettings); |
|
connect(m_accountList, &AccountList::memberRenamed, |
|
m_messageList, &MessageList::reloadLocalNicks); |
|
connect(m_messageList, &MessageList::readPositionChanged, |
|
this, [this](const QString& channelId, qint64 lastReadMsgId) { |
|
Q_UNUSED(lastReadMsgId); |
|
if (m_channelList && m_db) |
|
m_channelList->setChannelUnreadCount(channelId, |
|
m_db->getUnreadCount(channelId, lastReadMsgId)); |
|
}); |
|
} |
|
|
|
void MainWindow::setupTray() { |
|
m_trayIcon = new QSystemTrayIcon(this); |
|
m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_ComputerIcon)); |
|
m_trayIcon->setToolTip("Chat"); |
|
|
|
m_trayMenu = new QMenu(this); |
|
QAction *settingsAction = m_trayMenu->addAction("Settings"); |
|
connect(settingsAction, &QAction::triggered, this, &MainWindow::showSettings); |
|
|
|
m_trayMenu->addSeparator(); |
|
|
|
QAction *showAction = m_trayMenu->addAction("Show"); |
|
connect(showAction, &QAction::triggered, this, [this]() { |
|
show(); |
|
raise(); |
|
activateWindow(); |
|
}); |
|
|
|
QAction *quitAction = m_trayMenu->addAction("Quit"); |
|
connect(quitAction, &QAction::triggered, this, [this]() { |
|
if (m_node) m_node->requestStop(); |
|
}); |
|
|
|
m_trayIcon->setContextMenu(m_trayMenu); |
|
|
|
connect(m_trayIcon, &QSystemTrayIcon::activated, |
|
this, &MainWindow::onTrayActivated); |
|
|
|
m_trayIcon->show(); |
|
} |
|
|
|
void MainWindow::closeEvent(QCloseEvent *event) { |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow: closeEvent — hiding to tray"); |
|
hide(); |
|
m_trayIcon->showMessage("Chat", "Application minimized to tray.", |
|
QSystemTrayIcon::Information, 2000); |
|
event->ignore(); |
|
} |
|
|
|
void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) { |
|
if (reason == QSystemTrayIcon::Trigger || |
|
reason == QSystemTrayIcon::DoubleClick) { |
|
show(); |
|
raise(); |
|
activateWindow(); |
|
} |
|
} |
|
|
|
void MainWindow::showInviteDialog(const QString& channelId) { |
|
if (!m_db || !m_db->isOpen()) return; |
|
|
|
QByteArray pubkey; |
|
if (m_node && m_node->isRunning()) |
|
pubkey = QByteArray::fromHex(m_node->pubKeyHex().toLatin1()); |
|
|
|
if (pubkey.size() != 32) { |
|
QMessageBox::warning(this, "Error", "Failed to get node public key."); |
|
return; |
|
} |
|
|
|
QList<NodeAddr> addrs = m_node->getInviteAddresses(m_db); |
|
|
|
QString link = encodeInviteLink(channelId.toULongLong(), pubkey, addrs); |
|
if (link.isEmpty()) { |
|
QMessageBox::warning(this, "Error", "Failed to create invite link."); |
|
return; |
|
} |
|
|
|
InviteDialog dlg(link, this); |
|
dlg.exec(); |
|
} |
|
|
|
void MainWindow::showJoinDialog() { |
|
if (!m_node || !m_node->isRunning()) { |
|
QMessageBox::warning(this, "Error", "Node is not running."); |
|
return; |
|
} |
|
|
|
QString autoLink; |
|
QClipboard* cb = QApplication::clipboard(); |
|
QString clipText = cb->text(QClipboard::Clipboard).trimmed(); |
|
if (clipText.startsWith("utun://") && decodeInviteLink(clipText).error.isEmpty()) |
|
autoLink = clipText; |
|
else if (cb->supportsSelection()) { |
|
QString selText = cb->text(QClipboard::Selection).trimmed(); |
|
if (selText.startsWith("utun://") && decodeInviteLink(selText).error.isEmpty()) |
|
autoLink = selText; |
|
} |
|
|
|
JoinDialog dlg(m_node, m_db, this, autoLink); |
|
dlg.exec(); |
|
} |
|
|
|
void MainWindow::showSettings() { |
|
SettingsDialog dlg(m_cfgPath, m_db, m_node, m_recorder, this); |
|
dlg.exec(); |
|
reloadChannels(); |
|
} |
|
|
|
void MainWindow::onMessageReceived(const char* ch_id, int ch_id_len, uint64_t author) { |
|
QString cid = QString::fromUtf8(ch_id, ch_id_len); |
|
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MainWindow::onMessageReceived ch=%s author=%llu cur=%s", |
|
qPrintable(cid), (unsigned long long)author, qPrintable(m_currentChannelId)); |
|
if (m_db && author != m_db->myNodeId()) |
|
SoundManager::instance()->play("msg_incoming"); |
|
if (m_messageList) { |
|
if (cid == m_currentChannelId) { |
|
m_messageList->refresh(); |
|
} else if (m_channelList) { |
|
int unread = m_channelList->getChannelUnreadCount(cid) + 1; |
|
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MainWindow::onMessageReceived increment unread ch=%s %d->%d", |
|
qPrintable(cid), unread - 1, unread); |
|
m_channelList->setChannelUnreadCount(cid, unread); |
|
} |
|
} |
|
if (m_db && m_db->getUiStateInt("channel_sort", 0) == 1) |
|
reloadChannels(); |
|
} |
|
|
|
void MainWindow::onMembersChanged(const char* ch_id) { |
|
QString cur = QSettings().value("last_channel_id").toString(); |
|
if (cur == QString::fromUtf8(ch_id)) |
|
m_accountList->refresh(); |
|
} |
|
|
|
void MainWindow::onAutoConnectStatus(uint8_t status, uint16_t /*total_tried*/, |
|
uint16_t /*node_count*/, uint16_t /*connected*/) { |
|
if (m_channelList) { |
|
m_channelList->setAutoConnectActive(status == 0); |
|
} |
|
} |
|
|
|
void MainWindow::onChannelPeersOnline(const char* ch_id, int ch_id_len, uint16_t online) { |
|
if (m_channelList) { |
|
QString cid = QString::fromUtf8(ch_id, ch_id_len); |
|
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MainWindow::onChannelPeersOnline cid=%s online=%u", |
|
qPrintable(cid), online); |
|
m_channelList->setChannelPeersOnline(cid, (int)online); |
|
} |
|
} |
|
|
|
void MainWindow::reloadChannels() { |
|
if (m_channelList) { |
|
m_channelList->loadChannels(); |
|
if (!m_pendingChannelSelect.isEmpty()) { |
|
m_channelList->selectChannel(m_pendingChannelSelect); |
|
m_accountList->setChannel(m_pendingChannelSelect); |
|
m_messageList->loadChannel(m_pendingChannelSelect); |
|
QSettings().setValue("last_channel_id", m_pendingChannelSelect); |
|
m_currentChannelId = m_pendingChannelSelect; |
|
m_pendingChannelSelect.clear(); |
|
} |
|
} |
|
} |
|
|
|
void MainWindow::onCreateGroupRequested() { |
|
if (!m_node || !m_node->isRunning()) { |
|
QMessageBox::warning(this, "Error", "Node is not running."); |
|
return; |
|
} |
|
if (!gui_bridge_is_uasync_ready()) { |
|
QMessageBox::warning(this, "Error", "uTun thread is not ready yet. Please wait and try again."); |
|
return; |
|
} |
|
|
|
CreateGroupDialog dlg(this); |
|
if (dlg.exec() != QDialog::Accepted) return; |
|
|
|
QString name = dlg.groupName(); |
|
if (name.isEmpty()) return; |
|
|
|
char* name_copy = u_strdup(name.toUtf8().constData()); |
|
if (name_copy) gui_bridge_post_uasync_fn(chat_core_create_channel_auto_trampoline, name_copy); |
|
|
|
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "MainWindow: create group request posted name=%s", qPrintable(name)); |
|
}
|
|
|