From fe9689438985a779bab297e96526fdc44b3b65db Mon Sep 17 00:00:00 2001 From: alimu Date: Sat, 14 Mar 2026 12:03:06 +0400 Subject: [PATCH 1/3] docs: add contribution guide and README link --- CONTRIBUTING.md | 179 ++++++++++++++++++++++++++++++++++++++++++++++++ ReadMe.md | 5 ++ 2 files changed, 184 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..90d67ef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,179 @@ +# Contributing Guide + +本仓库已经同时包含服务端、Android 客户端、Web 客户端和本地部署脚本。后续协作必须按下面的规则执行,避免再次出现分支漂移、协议不一致、WIP PR 长期悬挂、本地调试资产误入版本库等问题。 + +## 基本原则 + +1. `main` 只接收可合并、可验证、可发布的代码。 +2. 一个 `issue` 对应一个主题分支,不要在同一分支里混入多个不相关需求。 +3. 本地调试资产不进 Git,包括证书、密钥、构建产物、个人环境配置。 +4. 任何跨端协议改动都必须做双端联调,不能只改单边实现。 +5. 没做完的工作不要以普通可合并 PR 的形式挂着;未完成状态请使用 Draft 或继续在本地分支开发。 + +## 分支规则 + +1. 新功能或修复必须从最新 `main` 切出: + +```bash +git fetch origin +git checkout main +git pull --ff-only origin main +git checkout -b emilia-t/issue-- +``` + +2. 分支名建议包含作者、issue、主题,例如: + - `emilia-t/issue-7-audio-message` + - `emilia-t/issue-12-notification-sound` + - `emilia-t/issue-18-server-switching` +3. 不要长期复用一个旧分支持续堆新需求。 +4. 合并前必须再次同步 `main`,优先用 `rebase`,避免无意义 merge commit: + +```bash +git fetch origin +git rebase origin/main +``` + +## 提交规则 + +1. 提交应当聚焦单一目的,避免一个提交同时混入协议、UI、美术、文档、重构。 +2. 提交信息必须带范围和目的,推荐格式: + - `feat(android): add voice message playback` + - `fix(web): parse Android audio payload correctly` + - `docs: document local debug CA setup` + - `chore(server): tighten message size validation` +3. 不要使用 `update`、`misc`、`fix bug` 这类无信息量的提交说明。 +4. 提交前先看一遍差异,确认没有把临时调试代码、个人地址、日志打印一起带上。 + +## PR 规则 + +1. PR 标题必须描述结果,不要长期保留 `WIP:` 前缀。 +2. 未完成内容请使用 Draft PR;准备好评审后再转正式 PR。 +3. PR 描述至少写清楚以下内容: + - 改了什么 + - 为什么改 + - 怎么验证 + - 是否影响协议、兼容性或部署方式 +4. 如果 PR 和目标分支冲突,提交人必须先在本地解决冲突,再更新 PR。 +5. 如果发现 PR 只包含部分有效改动,而剩余部分与当前主线冲突或重复,应当把有效增量摘到新提交中,而不是强行点合并。 + +## 验证要求 + +提交 PR 前,至少执行与你改动范围对应的验证命令,并在 PR 描述里写明结果。 + +### 服务端 + +根据改动类型至少做一项: + +```bash +dotnet build +dotnet test +``` + +如果改动涉及消息大小、限流、鉴权、peer 转发或 TLS/WSS,请补充实际联调结果。 + +### Android 客户端 + +```bash +cd android-client +./gradlew :app:assembleDebug +``` + +如果改动涉及以下内容,还需要额外验证: + +- 语音消息:Android 发 -> Web 收;Web 发 -> Android 收 +- 通知:前台/后台通知、系统通知开关、通知音效预览 +- 服务器切换:切换后自动重连、聊天记录按服务器隔离 +- WSS 联调:确认 `deploy/certs/android-local/local_ca.crt` 能被 debug 包信任 + +### Web 客户端 + +```bash +cd web-client +npm install +npm run build +``` + +如果改动涉及录音、播放或协议兼容,还要验证: + +- Web 发语音 -> Android 收 +- Android 发语音 -> Web 收 +- 分片消息能正确重组 + +## 协议改动规则 + +本项目最容易出问题的部分是“服务端、Android、Web 三端协议不同步”。任何协议改动必须遵守以下要求: + +1. 明确改动的是哪一层: + - 握手 + - 鉴权签名 + - 业务包结构 + - 语音标签或分片 + - 消息大小限制 +2. 修改协议后,至少检查: + - Android 客户端 + - Web 客户端 + - 服务端解析或转发逻辑 +3. 如需兼容旧客户端,必须写清楚退化策略。 +4. 不允许只在某一端“默认推断”字段而不在代码或文档中说明。 + +## 本地调试资产规则 + +以下内容只允许本地存在,不允许提交到版本库: + +- `deploy/certs/` +- `deploy/keys/` +- 本地生成的 `server.pfx` +- `android-client/app/build/` +- `web-client/dist/` +- 调试日志、抓包、临时脚本 + +Android debug 本地 CA 信任的约定如下: + +1. 本地 CA 证书路径固定为 `deploy/certs/android-local/local_ca.crt` +2. 该路径已被 `.gitignore` 覆盖 +3. `android-client/app/build.gradle.kts` 会在 `assembleDebug` 时动态生成 debug-only `networkSecurityConfig` +4. release 构建不会信任本地 CA + +## 冲突处理建议 + +如果 PR 已经明显落后于 `main`,不要直接在平台上硬点合并。正确做法: + +1. 本地同步最新 `main` +2. 手动解决冲突 +3. 重新跑验证 +4. 再推回分支 + +如果发现 PR 中一部分内容已经被主线吸收,另一部分仍有价值,优先做“摘取有效增量”,不要把旧分支整条强行合进来。 + +## Issue 与关闭规则 + +1. 处理 issue 前先确认需求边界,避免顺手扩需求。 +2. 做完后在 issue 或 PR 中写清楚: + - 实际完成内容 + - 验证方式 + - 仍未解决的边界 +3. 关闭 issue 前,确认代码已经合入目标分支,而不是只停留在本地或个人分支。 + +## 推荐工作流 + +1. 从最新 `main` 切分支 +2. 只做单一目标改动 +3. 本地自测 +4. 更新必要文档 +5. 同步最新 `main` +6. 再次验证 +7. 提交 PR +8. 评审完成后合并 +9. 删除分支 + +## 最低合并清单 + +合并前至少确认以下事项全部满足: + +- 改动范围单一且清晰 +- 无本地调试资产入库 +- 与改动相关的构建命令已通过 +- 协议变更已做跨端验证 +- 文档已补齐 +- PR 无未处理冲突 +- PR 标题和描述可读,不是 `WIP` diff --git a/ReadMe.md b/ReadMe.md index f454772..362f7fa 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -12,6 +12,11 @@ 这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。 +## 协作规范 + +- 提交代码前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) +- 分支、PR、协议联调、本地证书与调试资产管理都按该文档执行 + ## 功能概览 - WebSocket 服务,支持 `ws://` 和 `wss://` From ffc9a77b5a188818459c4ef6d1c8da8b97a7a088 Mon Sep 17 00:00:00 2001 From: alimu Date: Sat, 14 Mar 2026 12:06:24 +0400 Subject: [PATCH 2/3] Document rules in AGENTS --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 25d92c4..c2a2466 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ web-client/dist/ web-client/.vite deploy/certs/ deploy/keys/ +AGENTS.md # macOS metadata .DS_Store From 8a77f0735a19450e88dbe99bbdf1608f0c4c7e09 Mon Sep 17 00:00:00 2001 From: alimu Date: Sat, 14 Mar 2026 13:46:53 +0400 Subject: [PATCH 3/3] feat(server,android): add online display name sync - add signed rename protocol on the server for authenticated sessions - update Android settings flow to submit display name changes on commit - sync display name without restart and preserve message ordering after rename --- Common/MessageConverter.cs | 3 +- Common/RenameMessage.cs | 104 ++++++++++++++++++ Core/UserService.cs | 28 +++++ .../com/onlinemsg/client/ui/ChatScreen.kt | 38 ++++++- .../onlinemsg/client/ui/ChatSessionManager.kt | 68 +++++++++++- .../onlinemsg/client/util/LanguageManager.kt | 25 +++++ 6 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 Common/RenameMessage.cs diff --git a/Common/MessageConverter.cs b/Common/MessageConverter.cs index e29599a..94e8dc7 100644 --- a/Common/MessageConverter.cs +++ b/Common/MessageConverter.cs @@ -22,6 +22,7 @@ namespace OnlineMsgServer.Common "publickey" => JsonSerializer.Deserialize(root.GetRawText(), options), "forward" => JsonSerializer.Deserialize(root.GetRawText(), options), "broadcast" => JsonSerializer.Deserialize(root.GetRawText(), options), + "rename" => JsonSerializer.Deserialize(root.GetRawText(), options), _ => null }; return message ?? throw new JsonException($"{instruct} 反序列化失败"); @@ -49,4 +50,4 @@ namespace OnlineMsgServer.Common writer.WriteEndObject(); } } -} \ No newline at end of file +} diff --git a/Common/RenameMessage.cs b/Common/RenameMessage.cs new file mode 100644 index 0000000..31f2458 --- /dev/null +++ b/Common/RenameMessage.cs @@ -0,0 +1,104 @@ +using OnlineMsgServer.Core; +using WebSocketSharp.Server; + +namespace OnlineMsgServer.Common +{ + class RenameMessage : Message + { + public RenameMessage() + { + Type = "rename"; + Key = ""; + } + + public override Task Handler(string wsid, WebSocketSessionManager Sessions) + { + return Task.Run(() => + { + try + { + if (!UserService.IsAuthenticated(wsid)) + { + Log.Security("rename_denied_unauthenticated", $"wsid={wsid}"); + return; + } + + string key = Key?.Trim() ?? ""; + if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError)) + { + Log.Security("rename_payload_invalid", $"wsid={wsid} reason={parseError}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "rename payload invalid"); + return; + } + + if (!SecurityValidator.VerifySignedMessage(wsid, Type, key, payload, out string securityReason)) + { + Log.Security("rename_security_failed", $"wsid={wsid} reason={securityReason}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "rename signature invalid"); + return; + } + + string nextName = payload.Payload.Trim(); + if (string.IsNullOrWhiteSpace(nextName)) + { + Log.Security("rename_invalid_name", $"wsid={wsid} reason=blank"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name cannot be empty"); + return; + } + + if (!UserService.IsPeerNodeSession(wsid) && PeerNetworkService.IsPeerUserName(nextName)) + { + Log.Security("rename_invalid_name", $"wsid={wsid} reason=peer_prefix"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name uses reserved prefix"); + return; + } + + if (!UserService.TryUpdateUserName(wsid, nextName, out string appliedName)) + { + Log.Security("rename_update_failed", $"wsid={wsid}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed"); + return; + } + + Log.Security("rename_success", $"wsid={wsid} user={appliedName}"); + SendEncryptedResult(Sessions, wsid, "rename_ok", appliedName); + } + catch (Exception ex) + { + Log.Security("rename_error", $"wsid={wsid} error={ex.Message}"); + SendEncryptedResult(Sessions, wsid, "rename_error", "display name update failed"); + } + }); + } + + private static void SendEncryptedResult( + WebSocketSessionManager sessions, + string wsid, + string type, + string data + ) + { + string? publicKey = UserService.GetUserPublicKeyByID(wsid); + if (string.IsNullOrWhiteSpace(publicKey)) + { + return; + } + + Message result = new() + { + Type = type, + Data = data + }; + string encrypted = RsaService.EncryptForClient(publicKey, result.ToJsonString()); + + foreach (IWebSocketSession session in sessions.Sessions) + { + if (session.ID == wsid) + { + session.Context.WebSocket.Send(encrypted); + return; + } + } + } + } +} diff --git a/Core/UserService.cs b/Core/UserService.cs index f7cd5d4..2eb66d4 100644 --- a/Core/UserService.cs +++ b/Core/UserService.cs @@ -110,6 +110,34 @@ namespace OnlineMsgServer.Core } } + public static bool TryUpdateUserName(string wsid, string name, out string appliedName) + { + appliedName = ""; + lock (_UserListLock) + { + User? user = _UserList.Find(u => u.ID == wsid); + if (user is not { IsAuthenticated: true }) + { + return false; + } + + string normalized = name.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return false; + } + + if (normalized.Length > 64) + { + normalized = normalized[..64]; + } + + user.Name = normalized; + appliedName = normalized; + return true; + } + } + /// /// 通过用户PublicKey获取wsid /// diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index 389b606..84fe8bf 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -96,7 +96,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow @@ -1110,14 +1112,28 @@ private fun SettingsTab( onNotificationSoundChange: (String) -> Unit ) { val context = LocalContext.current + val focusManager = LocalFocusManager.current val previewPlayer = remember(context) { NotificationSoundPreviewPlayer(context) } val notificationStatus = rememberNotificationSoundStatus(state.notificationSound) + var displayNameInput by rememberSaveable { mutableStateOf(state.displayName) } DisposableEffect(Unit) { onDispose { previewPlayer.release() } } + LaunchedEffect(state.displayName) { + if (state.displayName != displayNameInput) { + displayNameInput = state.displayName + } + } + fun t(key: String) = LanguageManager.getString(key, state.language) + fun commitDisplayName() { + val candidate = displayNameInput.take(64) + if (candidate != state.displayName) { + onDisplayNameChange(candidate) + } + } val settingsCardModifier = Modifier.fillMaxWidth() val settingsCardContentModifier = Modifier @@ -1140,11 +1156,25 @@ private fun SettingsTab( ) { Text(t("settings.personal"), style = MaterialTheme.typography.titleMedium) OutlinedTextField( - value = state.displayName, - onValueChange = onDisplayNameChange, - modifier = Modifier.fillMaxWidth(), + value = displayNameInput, + onValueChange = { displayNameInput = it.take(64) }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + if (!focusState.isFocused) { + commitDisplayName() + } + }, label = { Text(t("settings.display_name")) }, - maxLines = 1 + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + commitDisplayName() + focusManager.clearFocus() + } + ) ) } } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index 73813ef..2723f0a 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -29,6 +29,7 @@ import com.onlinemsg.client.data.protocol.EnvelopeDto import com.onlinemsg.client.data.protocol.HelloDataDto import com.onlinemsg.client.data.protocol.SignedPayloadDto import com.onlinemsg.client.data.protocol.asPayloadText +import com.onlinemsg.client.data.protocol.asStringOrNull import com.onlinemsg.client.service.ChatForegroundService import com.onlinemsg.client.util.LanguageManager import com.onlinemsg.client.util.NotificationSoundCatalog @@ -103,6 +104,7 @@ object ChatSessionManager { private var helloTimeoutJob: Job? = null // 握手超时任务 private var authTimeoutJob: Job? = null // 认证超时任务 private var reconnectJob: Job? = null // 自动重连任务 + private var displayNameSyncJob: Job? = null // 在线改名同步任务 private var reconnectAttempt: Int = 0 // 当前重连尝试次数 private val systemMessageExpiryJobs: MutableMap = mutableMapOf() // 系统消息自动过期任务 private var autoReconnectTriggered = false @@ -286,10 +288,27 @@ object ChatSessionManager { * @param value 新名称(自动截断至 64 字符) */ fun updateDisplayName(value: String) { - val displayName = value.take(64) + val displayName = value.trim().take(64).ifBlank { createGuestName() } + val previous = _uiState.value.displayName + if (displayName == previous) return _uiState.update { it.copy(displayName = displayName) } - scope.launch { - preferencesRepository.setDisplayName(displayName) + displayNameSyncJob?.cancel() + displayNameSyncJob = scope.launch { + if (_uiState.value.status == ConnectionStatus.READY) { + _uiState.update { it.copy(statusHint = t("session.hint.updating_display_name")) } + runCatching { + sendDisplayNameUpdate(displayName) + preferencesRepository.setDisplayName(displayName) + }.onFailure { error -> + val message = error.message ?: t("common.unknown") + _uiState.update { + it.copy(statusHint = tf("session.hint.display_name_update_failed", message)) + } + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_update_failed", message))) + } + } else { + preferencesRepository.setDisplayName(displayName) + } } } @@ -516,6 +535,7 @@ object ChatSessionManager { */ fun disconnect(stopService: Boolean = true) { manualClose = true + displayNameSyncJob?.cancel() cancelReconnect() cancelHelloTimeout() cancelAuthTimeout() @@ -552,6 +572,7 @@ object ChatSessionManager { scope.launch { _uiState.update { it.copy(sending = true) } + displayNameSyncJob?.join() runCatching { sendSignedPayload(route = route, payloadText = text) @@ -581,6 +602,7 @@ object ChatSessionManager { scope.launch { _uiState.update { it.copy(sending = true) } + displayNameSyncJob?.join() val safeDuration = durationMillis.coerceAtLeast(0L) val normalized = audioBase64.trim() val chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE) @@ -874,6 +896,33 @@ object ChatSessionManager { addSystemMessage(t("session.msg.ready")) } + "rename_ok" -> { + val appliedName = message.data.asStringOrNull()?.trim().orEmpty() + .take(64) + .ifBlank { _uiState.value.displayName } + _uiState.update { + it.copy( + displayName = appliedName, + statusHint = tf("session.hint.display_name_updated", appliedName) + ) + } + scope.launch { + preferencesRepository.setDisplayName(appliedName) + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_updated", appliedName))) + } + } + + "rename_error" -> { + val reason = message.data.asStringOrNull()?.takeIf { it.isNotBlank() } + ?: t("common.unknown") + _uiState.update { + it.copy(statusHint = tf("session.hint.display_name_update_failed", reason)) + } + scope.launch { + _events.emit(UiEvent.ShowSnackbar(tf("session.msg.display_name_update_failed", reason))) + } + } + "broadcast" -> { val sender = message.key?.takeIf { it.isNotBlank() } ?: t("session.sender.anonymous") val payloadText = message.data.asPayloadText() @@ -1194,6 +1243,18 @@ object ChatSessionManager { check(socketClient.send(cipher)) { t("session.error.connection_unavailable") } } + private suspend fun sendDisplayNameUpdate(displayName: String) { + sendSignedPayload( + route = OutgoingRoute( + type = "rename", + key = "", + channel = MessageChannel.BROADCAST, + subtitle = "" + ), + payloadText = displayName + ) + } + private fun parseAudioPayload(payloadText: String): AudioPayloadDto? { if (!payloadText.startsWith(AUDIO_MESSAGE_PREFIX)) return null val encoded = payloadText.removePrefix(AUDIO_MESSAGE_PREFIX).trim() @@ -1547,6 +1608,7 @@ object ChatSessionManager { * 关闭所有资源(用于应用退出时)。 */ fun shutdownAll() { + displayNameSyncJob?.cancel() cancelSystemMessageExpiryJobs() cancelReconnect() cancelHelloTimeout() diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt index 5792aeb..c14bbf5 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -45,6 +45,9 @@ object LanguageManager { "hint.ready_chat" to "已連線,可以開始聊天", "hint.closed" to "連線已關閉", "hint.reconnecting" to "連線已中斷,正在重試", + "session.hint.updating_display_name" to "正在同步顯示名稱...", + "session.hint.display_name_updated" to "顯示名稱已更新為 %s", + "session.hint.display_name_update_failed" to "顯示名稱更新失敗:%s", "session.hint.switching_server" to "正在切換伺服器", "hint.reconnect_invalid_server" to "重連失敗:伺服器位址無效", "hint.fill_target_key" to "請先填寫目標公鑰,再傳送私訊", @@ -71,6 +74,8 @@ object LanguageManager { "chat.audio_recording" to "錄音中", "chat.audio_play" to "播放語音", "chat.audio_stop" to "停止播放", + "session.msg.display_name_updated" to "顯示名稱已更新為 %s", + "session.msg.display_name_update_failed" to "顯示名稱同步失敗:%s", "sound.default" to "預設", "sound.ding" to "叮", "sound.nameit5" to "音效 5", @@ -165,6 +170,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "连接超时,请重试", "session.hint.auth_failed" to "认证失败", "session.hint.ready_to_chat" to "已连接,可以开始聊天", + "session.hint.updating_display_name" to "正在同步显示名称...", + "session.hint.display_name_updated" to "显示名称已更新为 %s", + "session.hint.display_name_update_failed" to "显示名称更新失败:%s", "session.hint.server_rejected" to "服务器拒绝连接:%s", "session.hint.auto_retry_connecting" to "正在自动重试连接...", "session.hint.connection_interrupted_retry" to "连接已中断,正在重试", @@ -190,6 +198,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "已发送认证请求", "session.msg.auth_send_failed" to "认证发送失败:%s", "session.msg.ready" to "连接准备完成", + "session.msg.display_name_updated" to "显示名称已更新为 %s", + "session.msg.display_name_update_failed" to "显示名称同步失败:%s", "session.msg.unknown_message_type" to "收到未识别消息类型:%s", "session.msg.server_rejected" to "连接被服务器拒绝(%d):%s", "session.msg.switching_connection_mode_retry" to "连接方式切换中,正在重试", @@ -325,6 +335,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "Connection timeout, please retry", "session.hint.auth_failed" to "Authentication failed", "session.hint.ready_to_chat" to "Connected, ready to chat", + "session.hint.updating_display_name" to "Syncing display name...", + "session.hint.display_name_updated" to "Display name updated to %s", + "session.hint.display_name_update_failed" to "Display name update failed: %s", "session.hint.server_rejected" to "Server rejected connection: %s", "session.hint.auto_retry_connecting" to "Auto retry connecting...", "session.hint.connection_interrupted_retry" to "Connection interrupted, retrying", @@ -350,6 +363,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "Authentication request sent", "session.msg.auth_send_failed" to "Authentication send failed: %s", "session.msg.ready" to "Connection ready", + "session.msg.display_name_updated" to "Display name updated to %s", + "session.msg.display_name_update_failed" to "Display name sync failed: %s", "session.msg.unknown_message_type" to "Unknown message type received: %s", "session.msg.server_rejected" to "Connection rejected by server (%d): %s", "session.msg.switching_connection_mode_retry" to "Switching connection mode, retrying", @@ -485,6 +500,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "接続タイムアウト、再試行してください", "session.hint.auth_failed" to "認証失敗", "session.hint.ready_to_chat" to "接続済み、チャット可能", + "session.hint.updating_display_name" to "表示名を同期中...", + "session.hint.display_name_updated" to "表示名を %s に更新しました", + "session.hint.display_name_update_failed" to "表示名の更新に失敗しました:%s", "session.hint.server_rejected" to "サーバーが接続を拒否しました:%s", "session.hint.auto_retry_connecting" to "自動再試行で接続中...", "session.hint.connection_interrupted_retry" to "接続が中断され、再試行中", @@ -510,6 +528,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "認証リクエストを送信しました", "session.msg.auth_send_failed" to "認証送信失敗:%s", "session.msg.ready" to "接続準備完了", + "session.msg.display_name_updated" to "表示名を %s に更新しました", + "session.msg.display_name_update_failed" to "表示名の同期に失敗しました:%s", "session.msg.unknown_message_type" to "未識別メッセージ種別を受信:%s", "session.msg.server_rejected" to "サーバーに接続拒否されました(%d):%s", "session.msg.switching_connection_mode_retry" to "接続方式を切り替えて再試行中", @@ -645,6 +665,9 @@ object LanguageManager { "session.hint.connection_timeout_retry" to "연결 시간 초과, 다시 시도하세요", "session.hint.auth_failed" to "인증 실패", "session.hint.ready_to_chat" to "연결 완료, 채팅 가능", + "session.hint.updating_display_name" to "표시 이름을 동기화하는 중...", + "session.hint.display_name_updated" to "표시 이름이 %s(으)로 업데이트되었습니다", + "session.hint.display_name_update_failed" to "표시 이름 업데이트 실패: %s", "session.hint.server_rejected" to "서버가 연결을 거부했습니다: %s", "session.hint.auto_retry_connecting" to "자동 재시도 연결 중...", "session.hint.connection_interrupted_retry" to "연결이 끊겨 재시도 중", @@ -670,6 +693,8 @@ object LanguageManager { "session.msg.auth_request_sent" to "인증 요청을 전송했습니다", "session.msg.auth_send_failed" to "인증 전송 실패: %s", "session.msg.ready" to "연결 준비 완료", + "session.msg.display_name_updated" to "표시 이름이 %s(으)로 업데이트되었습니다", + "session.msg.display_name_update_failed" to "표시 이름 동기화 실패: %s", "session.msg.unknown_message_type" to "알 수 없는 메시지 유형 수신: %s", "session.msg.server_rejected" to "서버가 연결을 거부했습니다 (%d): %s", "session.msg.switching_connection_mode_retry" to "연결 방식을 전환하여 재시도 중",