diff --git a/AGENTS.md b/AGENTS.md index f3249f01..d5a2414c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -380,6 +380,18 @@ KEEPALIVE=21, ETCPROUTE=22, BBR=23, ETCP_DUMP=24 ## Config Rules - В серверном конфиге только собственные ключи и нет секций `[client]` - В клиентском конфиге есть собственные ключи и pubkey каждого сервера в секции `[client]` +- Chat-настройки хранятся в секции `[chat]`. Парсер вызывает `chat_setting_set(key, value)` для каждого ключа — неизвестные ключи вызывают ошибку. Пример: + ```ini + [chat] + storage_autoload=1 + storage_autoload_maxsize_mb=10 + storage_maxsize_gb=1 + opus_codec_preset=1 + compressor_enabled=0 + compressor_max_gain_db=25 + compressor_rise_rate=10 + media_download_max_peers=3 + ``` ## Bugs Debugging Protocol Всегда когда начинаешь диагностику ознакомься со скиллом (используй skills) diff --git a/src/Makefile.am b/src/Makefile.am index 3abcaad7..70b3f5d4 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -63,6 +63,7 @@ utun_CORE_SOURCES = \ lwip_tcp/lwip_tcp_in.c \ lwip_tcp/lwip_tcp_out.c \ chat/chat_event.c \ + chat/chat_setting.c \ chat/chat_core.c \ chat/chat_msg.c \ chat/chat_channel.c \ @@ -133,6 +134,7 @@ libutun_a_SOURCES = \ lwip_tcp/lwip_tcp_in.c \ lwip_tcp/lwip_tcp_out.c \ chat/chat_event.c \ + chat/chat_setting.c \ chat/chat_core.c \ chat/chat_msg.c \ chat/chat_channel.c \ diff --git a/src/chat/chat_core.c b/src/chat/chat_core.c index d297d381..ae935155 100644 --- a/src/chat/chat_core.c +++ b/src/chat/chat_core.c @@ -12,6 +12,7 @@ #include "chat_core_priv.h" #include "chat_event.h" +#include "chat_setting.h" #include "../utun_instance.h" #include "../ntp_time.h" @@ -239,3 +240,18 @@ void chat_core_destroy(struct UTUN_INSTANCE* inst) { sqlite3* chat_core_get_db(void) { return g_cc.db; } struct UTUN_INSTANCE* chat_core_get_inst(void) { return g_cc.inst; } int chat_core_is_initialized(void) { return g_cc.initialized; } + +/* ─── chat settings (GUI → uasync) ─── */ + +void chat_core_set_setting(const char* name, const char* value) { + chat_setting_set(name, value); +} + +struct chat_setting_arg { char data[256]; }; +void chat_core_set_setting_trampoline(void* arg) { + struct chat_setting_arg* a = (struct chat_setting_arg*)arg; + const char* name = a->data; + const char* value = name + strlen(name) + 1; + chat_core_set_setting(name, value); + u_free(arg); +} diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 0f5b7576..6f8c195a 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -76,6 +76,10 @@ void chat_core_sync_my_addresses(void); void chat_core_save_ui_state(const char* key, const char* value); void chat_core_save_ui_state_trampoline(void* arg); +/* Установка chat-настройки (name=value) через chat_setting API (GUI → uasync) */ +void chat_core_set_setting(const char* name, const char* value); +void chat_core_set_setting_trampoline(void* arg); + /* Запуск подключения к узлам выбранного канала (GUI → uasync) */ void chat_core_connect_channel(const char* ch_id); void chat_core_connect_channel_trampoline(void* arg); diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c new file mode 100644 index 00000000..dad125f4 --- /dev/null +++ b/src/chat/chat_setting.c @@ -0,0 +1,127 @@ +/* + * chat_setting.c — chat-related settings: validation, storage, on-the-fly apply + * + * Используется и из парсера конфига (headless), и из GUI (через bridge). + * Никаких зависимостей от g_cc или instance — работает автономно. + */ + +#include "chat_setting.h" +#include "../../lib/debug_config.h" + +#ifdef DEBUG_CATEGORY_DB_SYNC +#define CS_ID DEBUG_CATEGORY_DB_SYNC +#else +#define CS_ID DEBUG_CATEGORY_GENERAL +#endif + +#include +#include +#include + +/* ─── типы настроек ─── */ + +#define CHAT_SETTING_INT 0 +#define CHAT_SETTING_BOOL 1 + +struct chat_setting_def { + const char* name; + int type; + int default_val; + int min_val; + int max_val; +}; + +/* ─── реестр всех известных настроек ─── */ + +static const struct chat_setting_def g_setting_defs[] = { + {"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1}, + {"storage_autoload_maxsize_mb", CHAT_SETTING_INT, 10, 1, 500}, + {"storage_maxsize_gb", CHAT_SETTING_INT, 1, 1, 100}, + {"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4}, + {"compressor_enabled", CHAT_SETTING_BOOL, 0, 0, 1}, + {"compressor_max_gain_db", CHAT_SETTING_INT, 25, 1, 100}, + {"compressor_rise_rate", CHAT_SETTING_INT, 10, 1, 100}, + {"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16}, +}; + +#define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0])) + +/* ─── состояние (текущие значения) ─── */ + +static int g_chat_values[CHAT_SETTING_COUNT]; +static int g_chat_initialized = 0; + +static void chat_setting_reset_defaults(void) { + for (size_t i = 0; i < CHAT_SETTING_COUNT; i++) + g_chat_values[i] = g_setting_defs[i].default_val; + g_chat_initialized = 1; +} + +/* ─── поиск ─── */ + +static int chat_setting_find(const char* name) { + for (size_t i = 0; i < CHAT_SETTING_COUNT; i++) + if (strcmp(g_setting_defs[i].name, name) == 0) return (int)i; + return -1; +} + +/* ─── apply: вызов специфичного для настройки кода ─── */ + +static void chat_setting_apply(void) { + /* будет заполняться по мере появления runtime-setter'ов в модулях: + if (idx == ...) some_module_set_xxx(value); */ +} + +/* ─── public API ─── */ + +int chat_setting_set(const char* name, const char* value) { + if (!name || !value || !*name || !*value) return -2; + + if (!g_chat_initialized) chat_setting_reset_defaults(); + + int idx = chat_setting_find(name); + if (idx < 0) { + DEBUG_ERROR(CS_ID, "chat_setting: unknown setting '%s'", name); + return -1; + } + + const struct chat_setting_def* def = &g_setting_defs[idx]; + int v; + + if (def->type == CHAT_SETTING_BOOL) { + if (strcmp(value, "0") == 0 || strcmp(value, "no") == 0 || strcmp(value, "false") == 0) v = 0; + else if (strcmp(value, "1") == 0 || strcmp(value, "yes") == 0 || strcmp(value, "true") == 0) v = 1; + else { DEBUG_ERROR(CS_ID, "chat_setting: '%s' invalid bool '%s' (expect 0/1)", name, value); return -2; } + } else { + char* end = NULL; + long lv = strtol(value, &end, 10); + if (!end || *end != '\0' || lv < def->min_val || lv > def->max_val) { + DEBUG_ERROR(CS_ID, "chat_setting: '%s' invalid int '%s' (range %d..%d)", name, value, def->min_val, def->max_val); + return -2; + } + v = (int)lv; + } + + if (g_chat_values[idx] == v) return 0; + + g_chat_values[idx] = v; + DEBUG_INFO(CS_ID, "chat_setting: %s = %s", name, value); + chat_setting_apply(); + return 0; +} + +const char* chat_setting_get(const char* name) { + if (!g_chat_initialized) chat_setting_reset_defaults(); + int idx = chat_setting_find(name); + if (idx < 0) return NULL; + + static char buf[16]; + snprintf(buf, sizeof(buf), "%d", g_chat_values[idx]); + return buf; +} + +int chat_setting_get_int(const char* name, int def) { + if (!g_chat_initialized) chat_setting_reset_defaults(); + int idx = chat_setting_find(name); + return idx >= 0 ? g_chat_values[idx] : def; +} diff --git a/src/chat/chat_setting.h b/src/chat/chat_setting.h new file mode 100644 index 00000000..fd8df873 --- /dev/null +++ b/src/chat/chat_setting.h @@ -0,0 +1,28 @@ +/* + * chat_setting.h — chat-related settings (key-value API) + * + * Один API для всех потребителей: парсер конфига (headless) и GUI (через bridge). + * chat_setting_set(name, value) — валидация + хранение + apply к модулям. + * Возвращает 0=OK, -1=неизвестная настройка, -2=невалидное значение. + */ + +#ifndef CHAT_SETTING_H +#define CHAT_SETTING_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Установить настройку. Применяет на лету если модуль уже инициализирован. */ +int chat_setting_set(const char* name, const char* value); + +/* Прочитать сохранённое значение. NULL если такой настройки нет. */ +const char* chat_setting_get(const char* name); + +/* Прочитать как int. Возвращает def если настройка не найдена. */ +int chat_setting_get_int(const char* name, int def); + +#ifdef __cplusplus +} +#endif +#endif /* CHAT_SETTING_H */ diff --git a/src/config_parser.c b/src/config_parser.c index 0e6c0db7..452dc18b 100644 --- a/src/config_parser.c +++ b/src/config_parser.c @@ -16,6 +16,7 @@ #include #endif #include "../lib/mem.h" +#include "chat/chat_setting.h" #define MAX_LINE_LEN 1024 #define INITIAL_ARRAY_CAPACITY 8 @@ -49,6 +50,7 @@ typedef enum { SECTION_TCP_PROXY_SERVER, SECTION_NETWORK, SECTION_GUI, + SECTION_CHAT, SECTION_NTP, SECTION_LOG_UDP } section_type_t; @@ -755,6 +757,7 @@ static section_type_t parse_section_header(const char *line, char *name, size_t if (strcasecmp(section, "tcp_proxy_server") == 0) return SECTION_TCP_PROXY_SERVER; if (strcasecmp(section, "ntp") == 0) return SECTION_NTP; if (strcasecmp(section, "gui") == 0) return SECTION_GUI; + if (strcasecmp(section, "chat") == 0) return SECTION_CHAT; if (strcasecmp(section, "log_udp") == 0) return SECTION_LOG_UDP; char *colon = strchr(section, ':'); @@ -985,6 +988,11 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename) DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'. Valid: enabled, server, interval", filename, line_num, key); } break; + case SECTION_CHAT: + if (chat_setting_set(key, value) < 0) { + /* chat_setting_set уже напечатал ошибку */ + } + break; case SECTION_GUI: break; default: @@ -1108,6 +1116,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha case SECTION_TCP_PROXY_SERVER: cfg->global.tcp_proxy_server_enabled = 1; if (strcmp(key, "tcp_recv_buf") == 0) cfg->global.tcp_recv_buf = atoi(value); break; case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break; case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break; + case SECTION_CHAT: chat_setting_set(key, value); break; case SECTION_GUI: break; case SECTION_LOG_UDP: if (strcmp(key, "ip") == 0) strncpy(cfg->global.log_udp_ip, value, sizeof(cfg->global.log_udp_ip) - 1); diff --git a/tests/test_media_delivery_full.c b/tests/test_media_delivery_full.c index 93bfec9d..82d04898 100644 --- a/tests/test_media_delivery_full.c +++ b/tests/test_media_delivery_full.c @@ -71,6 +71,7 @@ static int db_count(sqlite3* db, const char* tbl, const char* wh, uint64_t val) static void to_cb(void* arg) { (void)arg; fprintf(stderr, " TIMEOUT\n"); g_result = 2; } static void mon(void* arg) { (void)arg; + if (g_result) return; int ok = 0; for (int i = 0; i < N_NODES; i++) { struct ll_entry* e = g_inst[i]->connections->head; @@ -303,6 +304,7 @@ int main(void) { printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); fflush(stdout); done: + g_result = 1; for (int i = 0; i < N_NODES; i++) { if (g_inst[i]) { g_inst[i]->running = 0; utun_instance_destroy(g_inst[i]); g_inst[i] = NULL; } } if (g_ua) { uasync_destroy(g_ua, 0); g_ua = NULL; } t_rmdir(g_tdir); diff --git a/tools/chatgui-android/AGENTS.md b/tools/chatgui-android/AGENTS.md index 9531db04..1c0fd7c6 100644 --- a/tools/chatgui-android/AGENTS.md +++ b/tools/chatgui-android/AGENTS.md @@ -189,6 +189,16 @@ allow_all=yes [ntp] enabled=no +[chat] +storage_autoload=1 +storage_autoload_maxsize_mb=10 +storage_maxsize_gb=1 +opus_codec_preset=1 +compressor_enabled=0 +compressor_max_gain_db=25 +compressor_rise_rate=10 +media_download_max_peers=3 + [debug] console_level=info 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 41880940..00106e38 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 @@ -36,9 +36,14 @@ class ConfigProvider(private val context: Context) { private val logUdpIp = stringPreferencesKey("log_udp_ip") private val logUdpPort = intPreferencesKey("log_udp_port") private val serversJson = stringPreferencesKey("servers") - private val storageAutoload = intPreferencesKey("storage_autoload") - private val storageAutoloadMaxsizeMb = intPreferencesKey("storage_autoload_maxsize_mb") - private val storageMaxsizeGb = intPreferencesKey("storage_maxsize_gb") + private val chatStorageAutoload = intPreferencesKey("chat_storage_autoload") + private val chatStorageAutoloadMaxsizeMb = intPreferencesKey("chat_storage_autoload_maxsize_mb") + private val chatStorageMaxsizeGb = intPreferencesKey("chat_storage_maxsize_gb") + private val chatOpusCodecPreset = intPreferencesKey("chat_opus_codec_preset") + private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled") + private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db") + private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate") + private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers") private val firstLaunchDone = booleanPreferencesKey("first_launch_done") private val gson = Gson() @@ -133,9 +138,14 @@ class ConfigProvider(private val context: Context) { key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345 key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999 key == "log_udp.port" -> context.dataStore.data.first()[logUdpPort] ?: 9999 - key == "storage_autoload" -> context.dataStore.data.first()[storageAutoload] ?: 1 - key == "storage_autoload_maxsize_mb" -> context.dataStore.data.first()[storageAutoloadMaxsizeMb] ?: 10 - key == "storage_maxsize_gb" -> context.dataStore.data.first()[storageMaxsizeGb] ?: 1 + key == "chat.storage_autoload" -> context.dataStore.data.first()[chatStorageAutoload] ?: 1 + key == "chat.storage_autoload_maxsize_mb" -> context.dataStore.data.first()[chatStorageAutoloadMaxsizeMb] ?: 10 + key == "chat.storage_maxsize_gb" -> context.dataStore.data.first()[chatStorageMaxsizeGb] ?: 1 + key == "chat.opus_codec_preset" -> context.dataStore.data.first()[chatOpusCodecPreset] ?: 1 + key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 0 + key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 25 + key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 10 + key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3 else -> 0 } } @@ -155,9 +165,14 @@ class ConfigProvider(private val context: Context) { "debug.categories" -> prefs[debugCategories] = value "log_udp.ip" -> prefs[logUdpIp] = value "log_udp.port" -> prefs[logUdpPort] = value.toIntOrNull() ?: 0 - "storage_autoload" -> prefs[storageAutoload] = value.toIntOrNull() ?: 1 - "storage_autoload_maxsize_mb" -> prefs[storageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10 - "storage_maxsize_gb" -> prefs[storageMaxsizeGb] = value.toIntOrNull() ?: 1 + "chat.storage_autoload" -> prefs[chatStorageAutoload] = value.toIntOrNull() ?: 1 + "chat.storage_autoload_maxsize_mb" -> prefs[chatStorageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10 + "chat.storage_maxsize_gb" -> prefs[chatStorageMaxsizeGb] = value.toIntOrNull() ?: 1 + "chat.opus_codec_preset" -> prefs[chatOpusCodecPreset] = value.toIntOrNull() ?: 1 + "chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 0 + "chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 25 + "chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 10 + "chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3 } } } @@ -281,6 +296,24 @@ class ConfigProvider(private val context: Context) { appendLine("[ntp]") appendLine("enabled=no") appendLine() + appendLine("[chat]") + val chatAutoload = getInt("chat.storage_autoload") + val chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb") + val chatMaxGb = getInt("chat.storage_maxsize_gb") + val chatOpusPreset = getInt("chat.opus_codec_preset") + val chatCompEnabled = getInt("chat.compressor_enabled") + val chatCompMaxGain = getInt("chat.compressor_max_gain_db") + val chatCompRiseRate = getInt("chat.compressor_rise_rate") + val chatDlMaxPeers = getInt("chat.media_download_max_peers") + appendLine("storage_autoload=$chatAutoload") + appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb") + appendLine("storage_maxsize_gb=$chatMaxGb") + appendLine("opus_codec_preset=$chatOpusPreset") + appendLine("compressor_enabled=$chatCompEnabled") + appendLine("compressor_max_gain_db=$chatCompMaxGain") + appendLine("compressor_rise_rate=$chatCompRiseRate") + appendLine("media_download_max_peers=$chatDlMaxPeers") + appendLine() appendLine("[debug]") appendLine("console_level=info") val udpIp = getString("log_udp.ip") ?: "" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 7c966f23..4483aa43 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -72,6 +72,10 @@ object NativeLib { fun clearDatabase(): Boolean = nativeClearDatabase() fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) } + /* ── Chat settings (live update via C side) ── */ + fun setChatSetting(name: String, value: String) { nativeSetChatSetting(name, value) } + fun getChatSettingInt(name: String, def: Int = 0): Int = nativeGetChatSettingInt(name, def) + /** Enable/disable forwarding native C logs to LogManager (expensive, off by default) */ fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) } fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) } @@ -114,6 +118,8 @@ object NativeLib { private external fun nativeGetChannels(): String? private external fun nativeGetMessages(channelId: String, limit: Int): String? private external fun nativeSetDebugLevel(category: String, level: String) + private external fun nativeSetChatSetting(name: String, value: String) + private external fun nativeGetChatSettingInt(name: String, def: Int): Int private external fun nativeSubscribeLogs(enabled: Boolean) private external fun nativeSetUdpLogTarget(ip: String, port: Int) private external fun nativeRegenerateKeys() 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 de805cda..35bb4d0c 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 @@ -324,6 +324,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool @Composable private fun AudioSettingsTab(vm: ChatViewModel) { val scope = rememberCoroutineScope() + val provider = ChatApplication.instance.configProvider /* Mic test state */ var micTesting by remember { mutableStateOf(false) } @@ -352,6 +353,16 @@ private fun AudioSettingsTab(vm: ChatViewModel) { vm.setVoicePreset(preset) vm.setVoiceCompressor(compressorOn) vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRateTenths / 10f, targetLevelDb.toFloat()) + scope.launch { + provider.setValue("chat.opus_codec_preset", preset.toString()) + provider.setValue("chat.compressor_enabled", if (compressorOn) "1" else "0") + provider.setValue("chat.compressor_max_gain_db", maxGainDb.toString()) + provider.setValue("chat.compressor_rise_rate", riseRateTenths.toString()) + } + NativeLib.setChatSetting("opus_codec_preset", preset.toString()) + NativeLib.setChatSetting("compressor_enabled", if (compressorOn) "1" else "0") + NativeLib.setChatSetting("compressor_max_gain_db", maxGainDb.toString()) + NativeLib.setChatSetting("compressor_rise_rate", riseRateTenths.toString()) } Column( @@ -427,9 +438,9 @@ private fun StorageSettingsTab() { var maxStorageGb by remember { mutableStateOf("1") } LaunchedEffect(Unit) { - autoload = provider.getInt("storage_autoload") != 0 - autoloadMaxSizeMb = provider.getInt("storage_autoload_maxsize_mb").toString() - maxStorageGb = provider.getInt("storage_maxsize_gb").toString() + autoload = provider.getInt("chat.storage_autoload") != 0 + autoloadMaxSizeMb = provider.getInt("chat.storage_autoload_maxsize_mb").toString() + maxStorageGb = provider.getInt("chat.storage_maxsize_gb").toString() } Column( @@ -441,20 +452,22 @@ private fun StorageSettingsTab() { Text("Autoload media", modifier = Modifier.weight(1f)) Switch(checked = autoload, onCheckedChange = { autoload = it - scope.launch { provider.setValue("storage_autoload", if (it) "1" else "0") } + val s = if (it) "1" else "0" + scope.launch { provider.setValue("chat.storage_autoload", s) } + NativeLib.setChatSetting("storage_autoload", s) }) } HorizontalDivider(Modifier.padding(vertical = 4.dp)) OutlinedTextField(autoloadMaxSizeMb, - { v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) scope.launch { provider.setValue("storage_autoload_maxsize_mb", v) } }, + { v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) { scope.launch { provider.setValue("chat.storage_autoload_maxsize_mb", v) }; NativeLib.setChatSetting("storage_autoload_maxsize_mb", v) } }, label = { Text("Autoload max size (MB)") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier.padding(top = 4.dp)) OutlinedTextField(maxStorageGb, - { v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) scope.launch { provider.setValue("storage_maxsize_gb", v) } }, + { v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) { scope.launch { provider.setValue("chat.storage_maxsize_gb", v) }; NativeLib.setChatSetting("storage_maxsize_gb", v) } }, label = { Text("Max storage size (GB)") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true) diff --git a/tools/chatgui-android/headless/headless_control.c b/tools/chatgui-android/headless/headless_control.c index 14837c7b..1e32fa8b 100644 --- a/tools/chatgui-android/headless/headless_control.c +++ b/tools/chatgui-android/headless/headless_control.c @@ -4,6 +4,7 @@ #include "headless_control.h" #include "../libutun_lite/utun_config_api.h" #include "../libutun_lite/invite_link_c.h" +#include "../../../src/chat/chat_setting.h" #include #include @@ -303,6 +304,20 @@ static int handle_join(int fd, int id, const char* json) { return 0; } +static int handle_chat_setting(int fd, int id, const char* json) { + char name[128], value[128]; + if (json_get_str(json, "name", name, sizeof(name)) < 0 || json_get_str(json, "value", value, sizeof(value)) < 0) { + send_response(fd, id, NULL, "missing name or value"); + return 0; + } + int rc = chat_setting_set(name, value); + if (rc == -1) { send_response(fd, id, NULL, "unknown setting"); return 0; } + if (rc == -2) { send_response(fd, id, NULL, "invalid value"); return 0; } + printf("control: chat_setting %s=%s\n", name, value); + send_response(fd, id, "{\"set\":true}", NULL); + return 0; +} + /* ── Command dispatch ── */ typedef int (*cmd_handler_fn)(int fd, int id, const char* json); @@ -310,13 +325,13 @@ typedef int (*cmd_handler_fn)(int fd, int id, const char* json); static const char* const g_cmd_names[] = { "ping", "status", "connect", "disconnect", "connections", "create_channel", "channels", "send", "messages", - "debug_level", "subscribe", "join", "quit", NULL + "debug_level", "subscribe", "join", "chat_setting", "quit", NULL }; static cmd_handler_fn g_cmd_handlers[] = { handle_ping, handle_status, NULL, NULL, handle_connections, NULL, NULL, NULL, NULL, - handle_debug_level, handle_subscribe, handle_join, handle_quit + handle_debug_level, handle_subscribe, handle_join, handle_chat_setting, handle_quit }; static void handle_command(struct control_client* cli, const char* json) { diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 58caebe5..02163167 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -18,6 +18,7 @@ #include "../../../lib/mem.h" #include "../../../lib/sqlite3.h" #include "../../../src/chat/chat_core.h" +#include "../../../src/chat/chat_setting.h" #include #include #include @@ -435,6 +436,14 @@ void utun_bridge_set_debug_level(const char* category, const char* level) { bridge_log(BLEV_INFO, "debug %s=%s", category ? category : "all", level ? level : "info"); } +void utun_bridge_chat_setting_set(const char* name, const char* value) { + chat_setting_set(name, value); +} + +int utun_bridge_chat_setting_get_int(const char* name, int def) { + return chat_setting_get_int(name, def); +} + void utun_bridge_restart(const char* config_text) { bridge_log(BLEV_INFO, "bridge restart: posting to instance"); instance_lite_restart(config_text); @@ -913,6 +922,25 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetDebugLevel( if (c) (*env)->ReleaseStringUTFChars(env, category, c); } +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetChatSetting( + JNIEnv* env, jobject thiz, jstring name, jstring value) { + (void)thiz; + const char* n = (*env)->GetStringUTFChars(env, name, NULL); + const char* v = (*env)->GetStringUTFChars(env, value, NULL); + chat_setting_set(n, v); + if (v) (*env)->ReleaseStringUTFChars(env, value, v); + if (n) (*env)->ReleaseStringUTFChars(env, name, n); +} + +JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChatSettingInt( + JNIEnv* env, jobject thiz, jstring name, jint def) { + (void)thiz; + const char* n = (*env)->GetStringUTFChars(env, name, NULL); + int val = chat_setting_get_int(n, (int)def); + if (n) (*env)->ReleaseStringUTFChars(env, name, n); + return (jint)val; +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSubscribeLogs( JNIEnv* env, jobject thiz, jboolean enabled) { (void)thiz; (void)env; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 11152d76..8f96a18e 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -105,6 +105,11 @@ void utun_bridge_voice_decode_close(void* handle); void utun_bridge_set_debug_level(const char* category, const char* level); +/* ── Chat settings ── */ + +void utun_bridge_chat_setting_set(const char* name, const char* value); +int utun_bridge_chat_setting_get_int(const char* name, int def); + #ifdef __cplusplus } #endif diff --git a/tools/chatgui/src/audiodevicesettingspage.cpp b/tools/chatgui/src/audiodevicesettingspage.cpp index 37a0df08..a147fc51 100644 --- a/tools/chatgui/src/audiodevicesettingspage.cpp +++ b/tools/chatgui/src/audiodevicesettingspage.cpp @@ -9,6 +9,7 @@ extern "C" { #include "../../lib/debug_config.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" +#include "../transport/config_updater.h" #include "../../lib/mem.h" extern "C" { #include "chat/chat_core.h" @@ -211,6 +212,24 @@ void AudioDeviceSettingsPage::applyAndSave() { saveKey("compressor_max_gain_db", compMaxGain); saveKey("compressor_rise_rate", compRiseRate); + /* also update [chat] config section + live apply for headless-compat settings */ + if (!m_configPath.isEmpty()) { + auto postChat = [](const QString& path, const char* name, int val) { + config_update_option(path, "chat", name, QString::number(val), CONFIG_UPDATE_REPLACE); + QByteArray v = QByteArray::number(val); + size_t nlen = strlen(name), vlen = (size_t)v.size(), total = nlen + 1 + vlen + 1; + if (total > 256) return; + void* arg = u_malloc(total); + memcpy(arg, name, nlen + 1); + memcpy((char*)arg + nlen + 1, v.constData(), vlen + 1); + gui_bridge_post_uasync_fn(chat_core_set_setting_trampoline, arg); + }; + postChat(m_configPath, "opus_codec_preset", codecPreset); + postChat(m_configPath, "compressor_enabled", compEnabled); + postChat(m_configPath, "compressor_max_gain_db", compMaxGain); + postChat(m_configPath, "compressor_rise_rate", compRiseRate); + } + m_savedOutputIdx = outIdx; m_savedInputIdx = inIdx; m_savedCodecPreset = codecPreset; diff --git a/tools/chatgui/src/audiodevicesettingspage.h b/tools/chatgui/src/audiodevicesettingspage.h index c4e6f9ac..623edc81 100644 --- a/tools/chatgui/src/audiodevicesettingspage.h +++ b/tools/chatgui/src/audiodevicesettingspage.h @@ -15,6 +15,7 @@ class AudioDeviceSettingsPage : public QWidget { public: explicit AudioDeviceSettingsPage(DbManager* db, AudioRecorder* recorder, QWidget* parent = nullptr); ~AudioDeviceSettingsPage() override; + void setConfigPath(const QString& path) { m_configPath = path; } void loadFromDb(); void applyAndSave(); @@ -31,6 +32,7 @@ private: DbManager* m_db; AudioRecorder* m_recorder = nullptr; AudioRecorder* m_testRecorder = nullptr; + QString m_configPath; QComboBox* m_outputCombo; QComboBox* m_inputCombo; QComboBox* m_codecPresetCombo; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 155466f1..4e86c9c3 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -252,6 +252,8 @@ void MainWindow::setupBridgeCallbacks() { if (!lastCid.isEmpty()) { s_mainWindow->m_channelList->selectChannel(lastCid); s_mainWindow->m_accountList->setChannel(lastCid); + s_mainWindow->m_currentChannelId = lastCid; + s_mainWindow->m_messageList->loadChannel(lastCid); } }); } diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index b50b7f3f..70728043 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -32,6 +32,8 @@ extern "C" { #include "../../lib/u_async.h" #include "../../lib/mem.h" #include "../../lib/debug_config.h" +struct save_ui_state_arg { char data[256]; }; +void chat_core_save_ui_state_trampoline(void* arg); } static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) { @@ -360,7 +362,7 @@ void MessageList::loadChannel(const QString& channelId) { m_localNicks = m_db->getLocalNicknames(channelId); - auto msgs = m_db->getMessages(channelId, 100); + auto msgs = m_db->getMessages(channelId, 200); for (const auto& m : msgs) { AccountRow acc = m_db->getAccount(m.authorNodeId); @@ -450,7 +452,7 @@ void MessageList::refresh() { setVoiceMessageRoles(item, m.data, m.contentType, m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs); m_model->appendRow(item); - while (m_model->rowCount() > 100) + while (m_model->rowCount() > 200) m_model->removeRow(0); } @@ -495,29 +497,37 @@ void MessageList::addMessage(const QString& channelId, quint64 authorNodeId, void MessageList::saveChannelState() { if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return; - int vpHeight = m_view->viewport()->height(); - QModelIndex topIdx = m_view->indexAt(QPoint(0, 0)); QScrollBar* sb = m_view->verticalScrollBar(); bool atBottom = sb && (sb->value() >= sb->maximum()); qint64 lastPosMsgId = 0; - if (!atBottom && topIdx.isValid()) - lastPosMsgId = topIdx.data(MsgIdRole).toLongLong(); + if (!atBottom) { + QModelIndex topIdx = m_view->indexAt(QPoint(0, 0)); + if (topIdx.isValid()) + lastPosMsgId = topIdx.data(MsgIdRole).toLongLong(); + } qint64 lastReadMsgId = m_lastReadMsgId; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MessageList::saveChannelState ch=%s read=%lld pos=%lld", - qPrintable(m_currentChannelId), (long long)lastReadMsgId, (long long)lastPosMsgId); - if (lastReadMsgId == 0 && lastPosMsgId == 0) return; + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MessageList::saveChannelState ch=%s bottom=%d read=%lld pos=%lld", + qPrintable(m_currentChannelId), (int)atBottom, (long long)lastReadMsgId, (long long)lastPosMsgId); QByteArray cid = m_currentChannelId.toUtf8(); - auto saveKey = [&cid](const char* suffix, qint64 val) { + auto postSaveKey = [&cid](const char* suffix, qint64 val) { QByteArray key = "ch_" + cid + suffix; QByteArray valStr = QByteArray::number(val); - chat_core_save_ui_state(key.constData(), valStr.constData()); + size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size(); + size_t total = keyLen + 1 + valLen + 1; + if (total > 256) return; + struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg)); + if (!arg) return; + memcpy(arg->data, key.constData(), keyLen + 1); + memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1); + gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); }; - if (lastReadMsgId > 0) saveKey("_last_read", lastReadMsgId); - if (lastPosMsgId > 0) saveKey("_last_pos", lastPosMsgId); + if (lastReadMsgId > 0) + postSaveKey("_last_read", lastReadMsgId); + postSaveKey("_last_pos", lastPosMsgId); } QString MessageList::authorName(quint64 nodeId) const { @@ -589,26 +599,32 @@ void MessageList::updateReadPosition() { qint64 lastPosMsgId = (!atBottom && topIdx.isValid()) ? topIdx.data(MsgIdRole).toLongLong() : 0; DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, - "MessageList::updateReadPosition ch=%s oldRead=%lld newRead=%lld pos=%lld lastVisRow=%d/%d", + "MessageList::updateReadPosition ch=%s oldRead=%lld newRead=%lld pos=%lld bottom=%d lastVisRow=%d/%d", qPrintable(m_currentChannelId), (long long)m_lastReadMsgId, - (long long)newReadMsgId, (long long)lastPosMsgId, + (long long)newReadMsgId, (long long)lastPosMsgId, (int)atBottom, lastFullyVisibleRow, m_model->rowCount()); QByteArray cid = m_currentChannelId.toUtf8(); + auto postSaveKey = [&cid](const char* suffix, qint64 val) { + QByteArray key = "ch_" + cid + suffix; + QByteArray valStr = QByteArray::number(val); + size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size(); + size_t total = keyLen + 1 + valLen + 1; + if (total > 256) return; + struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg)); + if (!arg) return; + memcpy(arg->data, key.constData(), keyLen + 1); + memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1); + gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); + }; if (newReadMsgId > m_lastReadMsgId) { m_lastReadMsgId = newReadMsgId; - QByteArray key = "ch_" + cid + "_last_read"; - QByteArray valStr = QByteArray::number(m_lastReadMsgId); - chat_core_save_ui_state(key.constData(), valStr.constData()); + postSaveKey("_last_read", m_lastReadMsgId); emit readPositionChanged(m_currentChannelId, m_lastReadMsgId); } - if (lastPosMsgId > 0) { - QByteArray posKey = "ch_" + cid + "_last_pos"; - QByteArray posVal = QByteArray::number(lastPosMsgId); - chat_core_save_ui_state(posKey.constData(), posVal.constData()); - } + postSaveKey("_last_pos", lastPosMsgId); } void MessageList::cleanupAnimations() { diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 39c37081..31f43fd5 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -88,6 +88,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod // Storage page m_storagePage = new StorageSettingsPage(db, m_pages); + m_storagePage->setConfigPath(m_configPath); m_pages->addWidget(m_storagePage); // Network page @@ -110,6 +111,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_pages->addWidget(m_soundPage); m_audioDevicePage = new AudioDeviceSettingsPage(db, recorder, m_pages); + m_audioDevicePage->setConfigPath(m_configPath); m_pages->addWidget(m_audioDevicePage); rightLayout->addWidget(m_pages, 1); diff --git a/tools/chatgui/src/storagesettingspage.cpp b/tools/chatgui/src/storagesettingspage.cpp index c4cc43b6..49aa23fb 100644 --- a/tools/chatgui/src/storagesettingspage.cpp +++ b/tools/chatgui/src/storagesettingspage.cpp @@ -1,6 +1,7 @@ #include "storagesettingspage.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" +#include "../transport/config_updater.h" #include "../../lib/mem.h" extern "C" { #include "chat/chat_core.h" @@ -22,6 +23,19 @@ static void saveUiStateInt(const char* key, int val) { gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); } +static void postChatSetting(const QString& configPath, const char* name, int val) { + /* persist to config file */ + config_update_option(configPath, "chat", name, QString::number(val), CONFIG_UPDATE_REPLACE); + /* apply on-the-fly via bridge */ + QByteArray v = QByteArray::number(val); + size_t nlen = strlen(name), vlen = (size_t)v.size(), total = nlen + 1 + vlen + 1; + if (total > 256) return; + void* arg = u_malloc(total); + memcpy(arg, name, nlen + 1); + memcpy((char*)arg + nlen + 1, v.constData(), vlen + 1); + gui_bridge_post_uasync_fn(chat_core_set_setting_trampoline, arg); +} + StorageSettingsPage::StorageSettingsPage(DbManager* db, QWidget* parent) : QWidget(parent) , m_db(db) @@ -84,7 +98,17 @@ void StorageSettingsPage::loadFromDb() { } void StorageSettingsPage::applyAndSave() { - saveUiStateInt("storage_autoload", m_autoloadCheck->isChecked() ? 1 : 0); - saveUiStateInt("storage_autoload_maxsize_mb", m_autoloadMaxSize->value()); - saveUiStateInt("storage_maxsize_gb", m_maxStorageSize->value()); + int autoload = m_autoloadCheck->isChecked() ? 1 : 0; + int autoloadMaxSize = m_autoloadMaxSize->value(); + int maxGb = m_maxStorageSize->value(); + + saveUiStateInt("storage_autoload", autoload); + saveUiStateInt("storage_autoload_maxsize_mb", autoloadMaxSize); + saveUiStateInt("storage_maxsize_gb", maxGb); + + if (!m_configPath.isEmpty()) { + postChatSetting(m_configPath, "storage_autoload", autoload); + postChatSetting(m_configPath, "storage_autoload_maxsize_mb", autoloadMaxSize); + postChatSetting(m_configPath, "storage_maxsize_gb", maxGb); + } } diff --git a/tools/chatgui/src/storagesettingspage.h b/tools/chatgui/src/storagesettingspage.h index bfe1b939..28924908 100644 --- a/tools/chatgui/src/storagesettingspage.h +++ b/tools/chatgui/src/storagesettingspage.h @@ -11,11 +11,13 @@ class StorageSettingsPage : public QWidget { public: explicit StorageSettingsPage(DbManager* db, QWidget* parent = nullptr); + void setConfigPath(const QString& path) { m_configPath = path; } void loadFromDb(); void applyAndSave(); private: DbManager* m_db = nullptr; + QString m_configPath; QCheckBox* m_autoloadCheck; QSpinBox* m_autoloadMaxSize; QSpinBox* m_maxStorageSize;