Merge branch 'main' into emilia-t

pull/14/head
emilia-t 1 week ago
commit 7c05911921

1
.gitignore vendored

@ -6,6 +6,7 @@ web-client/dist/
web-client/.vite
deploy/certs/
deploy/keys/
AGENTS.md
# macOS metadata
.DS_Store

@ -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-<id>-<topic>
```
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`

@ -22,6 +22,7 @@ namespace OnlineMsgServer.Common
"publickey" => JsonSerializer.Deserialize<PublicKeyMessage>(root.GetRawText(), options),
"forward" => JsonSerializer.Deserialize<ForwardMessage>(root.GetRawText(), options),
"broadcast" => JsonSerializer.Deserialize<BroadcastMessage>(root.GetRawText(), options),
"rename" => JsonSerializer.Deserialize<RenameMessage>(root.GetRawText(), options),
_ => null
};
return message ?? throw new JsonException($"{instruct} 反序列化失败");

@ -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;
}
}
}
}
}

@ -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;
}
}
/// <summary>
/// 通过用户PublicKey获取wsid
/// </summary>

@ -12,6 +12,11 @@
这套 peer 能力更接近“盲转发网络”,不是强一致的用户目录或联邦路由系统。
## 协作规范
- 提交代码前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)
- 分支、PR、协议联调、本地证书与调试资产管理都按该文档执行
## 功能概览
- WebSocket 服务,支持 `ws://``wss://`

@ -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()
}
)
)
}
}

@ -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<String, Job> = 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()

@ -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 "연결 방식을 전환하여 재시도 중",

Loading…
Cancel
Save