Initial import

pull/2/head
alimu 2 weeks ago
commit 31b76ec69c

8
.gitignore vendored

@ -0,0 +1,8 @@
bin/
obj/
todolist.md
web-client/node_modules/
web-client/dist/
web-client/.vite
deploy/certs/
deploy/keys/

@ -0,0 +1,66 @@
using OnlineMsgServer.Core;
using WebSocketSharp.Server;
namespace OnlineMsgServer.Common
{
class BroadcastMessage : Message
{
public BroadcastMessage()
{
Type = "broadcast";
//收到客户端消息,并执行广播
}
public override Task Handler(string wsid, WebSocketSessionManager Sessions)
{
return Task.Run(() =>
{
try
{
if (!UserService.IsAuthenticated(wsid))
{
Log.Security("broadcast_denied_unauthenticated", $"wsid={wsid}");
return;
}
string key = Key?.Trim() ?? "";
if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError))
{
Log.Security("broadcast_payload_invalid", $"wsid={wsid} reason={parseError}");
return;
}
if (!SecurityValidator.VerifySignedMessage(wsid, Type, key, payload, out string securityReason))
{
Log.Security("broadcast_security_failed", $"wsid={wsid} reason={securityReason}");
return;
}
Message response = new()
{
Type = "broadcast",
Data = payload.Payload,
Key = UserService.GetUserNameByID(wsid),
};
foreach (IWebSocketSession session in Sessions.Sessions)
{
if (session.ID != wsid)//不用发给自己
{
string? publicKey = UserService.GetUserPublicKeyByID(session.ID);
if (publicKey != null)
{
string jsonString = response.ToJsonString();
string encryptString = RsaService.EncryptForClient(publicKey, jsonString);
session.Context.WebSocket.Send(encryptString);
}
}
}
}
catch (Exception ex)
{
Log.Security("broadcast_error", $"wsid={wsid} error={ex.Message}");
}
});
}
}
}

@ -0,0 +1,79 @@
using OnlineMsgServer.Core;
using WebSocketSharp.Server;
namespace OnlineMsgServer.Common
{
class ForwardMessage : Message
{
public ForwardMessage()
{
Type = "forward";
//收到客户端消息,并执行转发
}
public override Task Handler(string wsid, WebSocketSessionManager Sessions)
{
return Task.Run(() =>
{
try
{
if (!UserService.IsAuthenticated(wsid))
{
Log.Security("forward_denied_unauthenticated", $"wsid={wsid}");
return;
}
if (string.IsNullOrWhiteSpace(Key))
{
Log.Security("forward_invalid_target", $"wsid={wsid}");
return;
}
string forwardPublickKey = Key.Trim();
if (!SignedMessagePayload.TryParse(Data, out SignedMessagePayload payload, out string parseError))
{
Log.Security("forward_payload_invalid", $"wsid={wsid} reason={parseError}");
return;
}
if (!SecurityValidator.VerifySignedMessage(wsid, Type, forwardPublickKey, payload, out string securityReason))
{
Log.Security("forward_security_failed", $"wsid={wsid} reason={securityReason}");
return;
}
string fromPublicKey = UserService.GetUserPublicKeyByID(wsid)!;
Message response = new()
{
Type = "forward",
Data = payload.Payload,
Key = fromPublicKey,
};
string jsonString = response.ToJsonString();
string encryptString = RsaService.EncryptForClient(forwardPublickKey, jsonString);
List<User> userList = UserService.GetUserListByPublicKey(forwardPublickKey);
if (userList.Count == 0)
{
Log.Security("forward_target_offline_or_untrusted", $"wsid={wsid}");
return;
}
foreach (IWebSocketSession session in Sessions.Sessions)
{
if (userList.Exists(u => u.ID == session.ID))
{
session.Context.WebSocket.Send(encryptString);
break;
}
}
}
catch (Exception ex)
{
Log.Security("forward_error", $"wsid={wsid} error={ex.Message}");
}
});
}
}
}

@ -0,0 +1,17 @@
namespace OnlineMsgServer.Common
{
class Log
{
public static void Debug(string msg)
{
#if DEBUG
Console.WriteLine(msg);
#endif
}
public static void Security(string eventName, string details)
{
Console.WriteLine($"[SECURITY] {DateTime.UtcNow:O} {eventName} {details}");
}
}
}

@ -0,0 +1,73 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using WebSocketSharp.Server;
namespace OnlineMsgServer.Common
{
class Message
{
public string Type { get; set; }
/// <summary>
/// 转发的目标
/// </summary>
public string? Key { get; set; }
public dynamic? Data { get; set; }
public Message()
{
Type = "";
}
public static readonly JsonSerializerOptions options = new()
{
ReadCommentHandling = JsonCommentHandling.Skip, //允许注释
AllowTrailingCommas = true,//允许尾随逗号
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // 忽略 null 值
WriteIndented = true, // 美化输出
//PropertyNameCaseInsensitive = true,//属性名忽略大小写
Converters = { new MessageConverter() },
};
public static Message? JsonStringParse(string jsonString)
{
try
{
return JsonSerializer.Deserialize<Message>(jsonString, options);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return null;
}
}
public string ToJsonString()
{
return JsonSerializer.Serialize(this, options);
}
/// <summary>
/// 指令处理逻辑(包括加密及发送过程)
/// </summary>
/// <returns>将耗时任务交给Task以不阻塞单个连接的多个请求</returns>
public virtual Task Handler(string wsid, WebSocketSessionManager Sessions)
{
return Task.CompletedTask;
}
/// <summary>
/// 增加计时逻辑
/// </summary>
public async Task HandlerAndMeasure(string wsid, WebSocketSessionManager Sessions)
{
Stopwatch stopWatch = new();
stopWatch.Start();
await Handler(wsid, Sessions);
stopWatch.Stop();
Log.Debug($"处理{GetType()}耗时:{stopWatch.ElapsedMilliseconds}ms");
}
}
}

@ -0,0 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OnlineMsgServer.Common
{
/// <summary>
/// 实现依据instruct的值决定反序列化的对象类型
/// </summary>
class MessageConverter : JsonConverter<Message>
{
public override Message Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using JsonDocument doc = JsonDocument.ParseValue(ref reader);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("type", out JsonElement typeProperty))
{
string? instruct = typeProperty.GetString();
Message? message = instruct switch
{
//实现新指令需在这里添加反序列化类型,限定为客户端发过来的指令类型
// "test" => JsonSerializer.Deserialize<Message>(root.GetRawText(), options),
"publickey" => JsonSerializer.Deserialize<PublicKeyMessage>(root.GetRawText(), options),
"forward" => JsonSerializer.Deserialize<ForwardMessage>(root.GetRawText(), options),
"broadcast" => JsonSerializer.Deserialize<BroadcastMessage>(root.GetRawText(), options),
_ => null
};
return message ?? throw new JsonException($"{instruct} 反序列化失败");
}
else
{
throw new JsonException("instruct property not found");
}
}
public override void Write(Utf8JsonWriter writer, Message value, JsonSerializerOptions options)
{
//JsonSerializer.Serialize(writer, (object)value, options); 这个在Data是对象时导致了无限递归调用
writer.WriteStartObject();
writer.WriteString("type", value.Type);
if (value.Key != null)
{
writer.WriteString("key", value.Key);
}
if (value.Data != null)
{
writer.WritePropertyName("data");
JsonSerializer.Serialize(writer, value.Data, value.Data.GetType(), options);
}
writer.WriteEndObject();
}
}
}

@ -0,0 +1,147 @@
using System.Text.Json;
namespace OnlineMsgServer.Common
{
internal sealed class ClientRegistrationPayload
{
public string PublicKey { get; init; } = "";
public string Challenge { get; init; } = "";
public long Timestamp { get; init; }
public string Nonce { get; init; } = "";
public string Signature { get; init; } = "";
public static bool TryParse(dynamic? data, out ClientRegistrationPayload payload, out string error)
{
payload = new ClientRegistrationPayload();
error = "invalid data";
if (data is not JsonElement json || json.ValueKind != JsonValueKind.Object)
{
error = "data must be object";
return false;
}
if (!TryReadString(json, "publicKey", out string publicKey) ||
!TryReadString(json, "challenge", out string challenge) ||
!TryReadInt64(json, "timestamp", out long timestamp) ||
!TryReadString(json, "nonce", out string nonce) ||
!TryReadString(json, "signature", out string signature))
{
error = "missing registration fields";
return false;
}
payload = new ClientRegistrationPayload
{
PublicKey = publicKey,
Challenge = challenge,
Timestamp = timestamp,
Nonce = nonce,
Signature = signature
};
return true;
}
public static string BuildSigningInput(string userName, string publicKey, string challenge, long timestamp, string nonce)
{
return string.Join("\n", "publickey", userName, publicKey, challenge, timestamp, nonce);
}
private static bool TryReadString(JsonElement root, string property, out string value)
{
value = "";
if (!root.TryGetProperty(property, out JsonElement element) || element.ValueKind != JsonValueKind.String)
{
return false;
}
string? src = element.GetString();
if (string.IsNullOrWhiteSpace(src))
{
return false;
}
value = src.Trim();
return true;
}
private static bool TryReadInt64(JsonElement root, string property, out long value)
{
value = 0;
if (!root.TryGetProperty(property, out JsonElement element))
{
return false;
}
return element.TryGetInt64(out value);
}
}
internal sealed class SignedMessagePayload
{
public string Payload { get; init; } = "";
public long Timestamp { get; init; }
public string Nonce { get; init; } = "";
public string Signature { get; init; } = "";
public static bool TryParse(dynamic? data, out SignedMessagePayload payload, out string error)
{
payload = new SignedMessagePayload();
error = "invalid data";
if (data is not JsonElement json || json.ValueKind != JsonValueKind.Object)
{
error = "data must be object";
return false;
}
if (!TryReadString(json, "payload", out string messagePayload) ||
!TryReadInt64(json, "timestamp", out long timestamp) ||
!TryReadString(json, "nonce", out string nonce) ||
!TryReadString(json, "signature", out string signature))
{
error = "missing signed message fields";
return false;
}
payload = new SignedMessagePayload
{
Payload = messagePayload,
Timestamp = timestamp,
Nonce = nonce,
Signature = signature
};
return true;
}
public static string BuildSigningInput(string type, string key, string payload, long timestamp, string nonce)
{
return string.Join("\n", type, key, payload, timestamp, nonce);
}
private static bool TryReadString(JsonElement root, string property, out string value)
{
value = "";
if (!root.TryGetProperty(property, out JsonElement element) || element.ValueKind != JsonValueKind.String)
{
return false;
}
string? src = element.GetString();
if (string.IsNullOrWhiteSpace(src))
{
return false;
}
value = src;
return true;
}
private static bool TryReadInt64(JsonElement root, string property, out long value)
{
value = 0;
if (!root.TryGetProperty(property, out JsonElement element))
{
return false;
}
return element.TryGetInt64(out value);
}
}
}

@ -0,0 +1,127 @@
using OnlineMsgServer.Core;
using WebSocketSharp.Server;
namespace OnlineMsgServer.Common
{
class PublicKeyMessage : Message
{
public PublicKeyMessage()
{
Type = "publickey";
//收到客户端公钥,添加到用户列表中,并返回自己的公钥
}
public override Task Handler(string wsid, WebSocketSessionManager Sessions)
{
return Task.Run(() =>
{
try
{
if (!ClientRegistrationPayload.TryParse(Data, out ClientRegistrationPayload payload, out string parseError))
{
Log.Security("auth_payload_invalid", $"wsid={wsid} reason={parseError}");
CloseSession(Sessions, wsid, "auth payload invalid");
return;
}
if (!RsaService.IsPublicKeyValid(payload.PublicKey))
{
Log.Security("auth_publickey_invalid", $"wsid={wsid}");
CloseSession(Sessions, wsid, "public key invalid");
return;
}
SecurityConfig config = SecurityRuntime.Config;
if (!SecurityValidator.IsTimestampAcceptable(payload.Timestamp, config.MaxClockSkewSeconds, config.ChallengeTtlSeconds))
{
Log.Security("auth_timestamp_invalid", $"wsid={wsid} ts={payload.Timestamp}");
CloseSession(Sessions, wsid, "auth timestamp invalid");
return;
}
if (!UserService.TryGetChallenge(wsid, out string serverChallenge, out DateTime issuedAtUtc))
{
Log.Security("auth_challenge_missing", $"wsid={wsid}");
CloseSession(Sessions, wsid, "challenge missing");
return;
}
if (!string.Equals(serverChallenge, payload.Challenge, StringComparison.Ordinal))
{
Log.Security("auth_challenge_mismatch", $"wsid={wsid}");
CloseSession(Sessions, wsid, "challenge mismatch");
return;
}
if ((DateTime.UtcNow - issuedAtUtc).TotalSeconds > config.ChallengeTtlSeconds)
{
Log.Security("auth_challenge_expired", $"wsid={wsid}");
CloseSession(Sessions, wsid, "challenge expired");
return;
}
int idLength = Math.Min(wsid.Length, 8);
string userName = string.IsNullOrWhiteSpace(Key) ? $"anonymous-{wsid[..idLength]}" : Key.Trim();
if (userName.Length > 64)
{
userName = userName[..64];
}
string signingInput = ClientRegistrationPayload.BuildSigningInput(userName, payload.PublicKey, payload.Challenge, payload.Timestamp, payload.Nonce);
if (!RsaService.VerifySignature(payload.PublicKey, signingInput, payload.Signature))
{
Log.Security("auth_signature_invalid", $"wsid={wsid}");
CloseSession(Sessions, wsid, "auth signature invalid");
return;
}
if (!UserService.TryRecordNonce(wsid, payload.Nonce, payload.Timestamp, config.ReplayWindowSeconds, out string nonceReason))
{
Log.Security("auth_replay_nonce", $"wsid={wsid} reason={nonceReason}");
CloseSession(Sessions, wsid, "auth replay detected");
return;
}
UserService.UserLogin(wsid, payload.PublicKey, userName);
Log.Security("auth_success", $"wsid={wsid} user={userName}");
Message ack = new()
{
Type = "auth_ok",
Data = "authenticated"
};
string ackEncrypted = RsaService.EncryptForClient(payload.PublicKey, ack.ToJsonString());
SendToSession(Sessions, wsid, ackEncrypted);
}
catch (Exception ex)
{
Log.Security("auth_error", $"wsid={wsid} error={ex.Message}");
CloseSession(Sessions, wsid, "auth error");
}
});
}
private static void SendToSession(WebSocketSessionManager sessions, string wsid, string message)
{
foreach (IWebSocketSession session in sessions.Sessions)
{
if (session.ID == wsid)
{
session.Context.WebSocket.Send(message);
return;
}
}
}
private static void CloseSession(WebSocketSessionManager sessions, string wsid, string reason)
{
foreach (IWebSocketSession session in sessions.Sessions)
{
if (session.ID == wsid)
{
session.Context.WebSocket.Close(WebSocketSharp.CloseStatusCode.PolicyViolation, reason);
return;
}
}
}
}
}

@ -0,0 +1,56 @@
namespace OnlineMsgServer.Common
{
public class User(string ID)
{
/// <summary>
/// ws连接生成的唯一uuid
/// </summary>
public string ID { get; set; } = ID;
/// <summary>
/// 用户名,在客户端随意指定
/// </summary>
public string? Name { get; set; }
/// <summary>
/// 用户公钥 用于消息加密发送给用户
/// </summary>
public string? PublicKey { get; set; }
/// <summary>
/// 是否通过鉴权
/// </summary>
public bool IsAuthenticated { get; set; }
/// <summary>
/// 连接来源IP
/// </summary>
public string? IpAddress { get; set; }
/// <summary>
/// 服务端下发的一次性 challenge
/// </summary>
public string? PendingChallenge { get; set; }
/// <summary>
/// challenge 下发时间UTC
/// </summary>
public DateTime ChallengeIssuedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// 登录成功时间UTC
/// </summary>
public DateTime? AuthenticatedAtUtc { get; set; }
/// <summary>
/// 防重放 nonce 缓存nonce -> unix timestamp
/// </summary>
public Dictionary<string, long> ReplayNonceStore { get; } = [];
/// <summary>
/// 限流窗口内请求时间戳unix ms
/// </summary>
public Queue<long> RequestTimesMs { get; } = new();
}
}

@ -0,0 +1,174 @@
using System.Security.Cryptography;
using System.Text;
namespace OnlineMsgServer.Core
{
class RsaService
{
//用于服务端加密解密
private static readonly RSA _Rsa = RSA.Create();
private static readonly object _RsaLock = new();
//用于客户端加密
private static readonly RSA _PublicRsa = RSA.Create();
private static readonly object _PublicRsaLock = new();
/// <summary>
/// 用客户端公钥加密
/// </summary>
public static string EncryptForClient(string pkey, string msg)
{
lock (_PublicRsaLock)
{
_PublicRsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(pkey), out _);
// byte[] encrypt = _PublicRsa.Encrypt(Encoding.UTF8.GetBytes(msg), RSAEncryptionPadding.OaepSHA256);
// return Convert.ToBase64String(encrypt);
return RsaEncrypt(_PublicRsa, msg);
}
}
/// <summary>
/// 导入服务端私钥
/// </summary>
public static void LoadRsaPkey(SecurityConfig config)
{
lock (_RsaLock)
{
if (!string.IsNullOrWhiteSpace(config.ServerPrivateKeyBase64))
{
_Rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(config.ServerPrivateKeyBase64), out _);
return;
}
if (!string.IsNullOrWhiteSpace(config.ServerPrivateKeyPath))
{
string pkey = File.ReadAllText(config.ServerPrivateKeyPath).Trim();
_Rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(pkey), out _);
return;
}
if (config.AllowEphemeralServerKey)
{
OnlineMsgServer.Common.Log.Security("server_key_ephemeral", "using in-memory generated private key");
return;
}
throw new InvalidOperationException("服务端私钥未配置。请设置 SERVER_PRIVATE_KEY_B64 或 SERVER_PRIVATE_KEY_PATH。");
}
}
/// <summary>
/// 以base64格式导出公钥字符串
/// </summary>
/// <returns>公钥字符串base64格式</returns>
public static string GetRsaPublickKey()
{
lock (_RsaLock)
{
return Convert.ToBase64String(_Rsa.ExportSubjectPublicKeyInfo());
}
}
/// <summary>
/// 服务端解密 base64编码
/// </summary>
/// <param name="secret">密文</param>
/// <returns> 原文字符串</returns>
public static string Decrypt(string secret)
{
lock (_RsaLock)
{
byte[] secretBytes = Convert.FromBase64String(secret);
int size = secretBytes.Length;
int blockSize = 256;
if (size % blockSize != 0)
{
throw new FormatException("ciphertext length invalid");
}
int blockCount = size / blockSize;
List<byte> decryptList = [];
for (int i = 0; i < blockCount; i++)
{
byte[] block = new byte[blockSize];
Array.Copy(secretBytes, i * blockSize, block, 0, blockSize);
byte[] decryptBlock = _Rsa.Decrypt(block, RSAEncryptionPadding.OaepSHA256);
decryptList.AddRange(decryptBlock);
}
// byte[] decrypt = _Rsa.Decrypt(Convert.FromBase64String(base64),
// RSAEncryptionPadding.OaepSHA256);
return Encoding.UTF8.GetString([.. decryptList]);
}
}
/// <summary>
/// 服务端加密 base64编码
/// </summary>
/// <param name="src">原文字符串</param>
/// <returns>密文</returns>
public static string Encrypt(string src)
{
lock (_RsaLock)
{
return RsaEncrypt(_Rsa, src);
}
}
public static bool VerifySignature(string publicKeyBase64, string src, string signatureBase64)
{
lock (_PublicRsaLock)
{
try
{
_PublicRsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKeyBase64), out _);
byte[] srcBytes = Encoding.UTF8.GetBytes(src);
byte[] signatureBytes = Convert.FromBase64String(signatureBase64);
return _PublicRsa.VerifyData(srcBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
}
public static bool IsPublicKeyValid(string publicKeyBase64)
{
lock (_PublicRsaLock)
{
try
{
_PublicRsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKeyBase64), out _);
return true;
}
catch
{
return false;
}
}
}
private static string RsaEncrypt(RSA rsa, string src)
{
byte[] srcBytes = Encoding.UTF8.GetBytes(src);
int size = srcBytes.Length;
int blockSize = 190;
if (size == 0)
{
return "";
}
int blockCount = (size + blockSize - 1) / blockSize;
List<byte> encryptList = [];
for (int i = 0; i < blockCount; i++)
{
int len = Math.Min(blockSize, size - i * blockSize);
byte[] block = new byte[len];
Array.Copy(srcBytes, i * blockSize, block, 0, len);
byte[] encryptBlock = rsa.Encrypt(block, RSAEncryptionPadding.OaepSHA256);
encryptList.AddRange(encryptBlock);
}
return Convert.ToBase64String([.. encryptList]);
}
}
}

@ -0,0 +1,93 @@
namespace OnlineMsgServer.Core
{
internal sealed class SecurityConfig
{
public int ListenPort { get; init; } = 13173;
public bool RequireWss { get; init; } = false;
public string? TlsCertPath { get; init; }
public string? TlsCertPassword { get; init; }
public string? ServerPrivateKeyBase64 { get; init; }
public string? ServerPrivateKeyPath { get; init; }
public bool AllowEphemeralServerKey { get; init; }
public int MaxConnections { get; init; } = 1000;
public int MaxMessageBytes { get; init; } = 64 * 1024;
public int RateLimitCount { get; init; } = 30;
public int RateLimitWindowSeconds { get; init; } = 10;
public int IpBlockSeconds { get; init; } = 120;
public int ChallengeTtlSeconds { get; init; } = 120;
public int MaxClockSkewSeconds { get; init; } = 60;
public int ReplayWindowSeconds { get; init; } = 120;
public static SecurityConfig LoadFromEnvironment()
{
return new SecurityConfig
{
ListenPort = GetInt("LISTEN_PORT", 13173, 1),
RequireWss = GetBool("REQUIRE_WSS", false),
TlsCertPath = GetString("TLS_CERT_PATH"),
TlsCertPassword = GetString("TLS_CERT_PASSWORD"),
ServerPrivateKeyBase64 = GetString("SERVER_PRIVATE_KEY_B64"),
ServerPrivateKeyPath = GetString("SERVER_PRIVATE_KEY_PATH"),
AllowEphemeralServerKey = GetBool("ALLOW_EPHEMERAL_SERVER_KEY", false),
MaxConnections = GetInt("MAX_CONNECTIONS", 1000, 1),
MaxMessageBytes = GetInt("MAX_MESSAGE_BYTES", 64 * 1024, 512),
RateLimitCount = GetInt("RATE_LIMIT_COUNT", 30, 1),
RateLimitWindowSeconds = GetInt("RATE_LIMIT_WINDOW_SECONDS", 10, 1),
IpBlockSeconds = GetInt("IP_BLOCK_SECONDS", 120, 1),
ChallengeTtlSeconds = GetInt("CHALLENGE_TTL_SECONDS", 120, 10),
MaxClockSkewSeconds = GetInt("MAX_CLOCK_SKEW_SECONDS", 60, 1),
ReplayWindowSeconds = GetInt("REPLAY_WINDOW_SECONDS", 120, 10),
};
}
private static string? GetString(string key)
{
string? value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static bool GetBool(string key, bool defaultValue)
{
string? value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
if (bool.TryParse(value, out bool parsed))
{
return parsed;
}
return value.Trim() switch
{
"1" => true,
"0" => false,
_ => defaultValue
};
}
private static int GetInt(string key, int defaultValue, int minValue)
{
string? value = Environment.GetEnvironmentVariable(key);
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
if (!int.TryParse(value, out int parsed))
{
return defaultValue;
}
return Math.Max(parsed, minValue);
}
}
}

@ -0,0 +1,19 @@
namespace OnlineMsgServer.Core
{
internal static class SecurityRuntime
{
private static readonly object _lock = new();
public static SecurityConfig Config { get; private set; } = SecurityConfig.LoadFromEnvironment();
public static string? ServerCertificateFingerprintSha256 { get; private set; }
public static void Initialize(SecurityConfig config, string? certFingerprintSha256)
{
lock (_lock)
{
Config = config;
ServerCertificateFingerprintSha256 = certFingerprintSha256;
}
}
}
}

@ -0,0 +1,55 @@
using System.Security.Cryptography;
using OnlineMsgServer.Common;
namespace OnlineMsgServer.Core
{
internal static class SecurityValidator
{
public static string CreateNonce(int bytes = 24)
{
byte[] buffer = RandomNumberGenerator.GetBytes(bytes);
return Convert.ToBase64String(buffer);
}
public static bool IsTimestampAcceptable(long unixSeconds, int maxClockSkewSeconds, int replayWindowSeconds)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
long maxFuture = now + maxClockSkewSeconds;
long minPast = now - replayWindowSeconds;
return unixSeconds >= minPast && unixSeconds <= maxFuture;
}
public static bool VerifySignedMessage(string wsid, string type, string key, SignedMessagePayload payload, out string reason)
{
reason = "";
SecurityConfig config = SecurityRuntime.Config;
if (!IsTimestampAcceptable(payload.Timestamp, config.MaxClockSkewSeconds, config.ReplayWindowSeconds))
{
reason = "timestamp out of accepted window";
return false;
}
string? publicKey = UserService.GetUserPublicKeyByID(wsid);
if (string.IsNullOrWhiteSpace(publicKey))
{
reason = "sender public key missing";
return false;
}
string signingInput = SignedMessagePayload.BuildSigningInput(type, key, payload.Payload, payload.Timestamp, payload.Nonce);
if (!RsaService.VerifySignature(publicKey, signingInput, payload.Signature))
{
reason = "signature verify failed";
return false;
}
if (!UserService.TryRecordNonce(wsid, payload.Nonce, payload.Timestamp, config.ReplayWindowSeconds, out reason))
{
return false;
}
return true;
}
}
}

@ -0,0 +1,233 @@
using OnlineMsgServer.Common;
namespace OnlineMsgServer.Core
{
class UserService
{
#region 服务器用户管理
private static readonly List<User> _UserList = [];
private static readonly object _UserListLock = new();
/// <summary>
/// 通过wsid添加用户记录
/// </summary>
public static void AddUserConnect(string wsid, string ipAddress, string challenge)
{
lock (_UserListLock)
{
User user = new(wsid);
user.IpAddress = ipAddress;
user.PendingChallenge = challenge;
user.ChallengeIssuedAtUtc = DateTime.UtcNow;
_UserList.Add(user);
}
}
/// <summary>
/// 通过wsid移除用户记录
/// </summary>
/// <param name="wsid"></param>
public static void RemoveUserConnectByID(string wsid)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
if (user != null)
{
_UserList.Remove(user);
}
}
}
/// <summary>
/// 通过publickey返回用户列表
/// </summary>
public static List<User> GetUserListByPublicKey(string publicKey)
{
lock (_UserListLock)
{
return _UserList.FindAll(u => u.PublicKey == publicKey && u.IsAuthenticated);
}
}
/// <summary>
/// 通过wsid设置用户PublicKey
/// </summary>
public static void UserLogin(string wsid, string publickey, string name)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
if (user != null)
{
user.PublicKey = publickey.Trim();
user.Name = name.Trim();
user.IsAuthenticated = true;
user.PendingChallenge = null;
user.AuthenticatedAtUtc = DateTime.UtcNow;
Console.WriteLine(user.ID + " 登记成功");
}
else
{
throw new Exception("用户不存在");
}
}
}
/// <summary>
/// 通过wsid获取用户PublicKey
/// </summary>
public static string? GetUserPublicKeyByID(string wsid)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
if (user is { IsAuthenticated: true })
{
return user.PublicKey;
}
return null;
}
}
/// <summary>
/// 通过wsid获取UserName
/// </summary>
public static string? GetUserNameByID(string wsid)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
if (user is { IsAuthenticated: true })
{
return user.Name;
}
return null;
}
}
/// <summary>
/// 通过用户PublicKey获取wsid
/// </summary>
public static string? GetUserIDByPublicKey(string publicKey)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.PublicKey == publicKey && u.IsAuthenticated);
if (user != null)
{
return user.ID;
}
return null;
}
}
public static bool IsAuthenticated(string wsid)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
return user is { IsAuthenticated: true };
}
}
public static int GetConnectionCount()
{
lock (_UserListLock)
{
return _UserList.Count;
}
}
public static bool TryGetChallenge(string wsid, out string challenge, out DateTime issuedAtUtc)
{
lock (_UserListLock)
{
challenge = "";
issuedAtUtc = DateTime.MinValue;
User? user = _UserList.Find(u => u.ID == wsid);
if (user == null || string.IsNullOrWhiteSpace(user.PendingChallenge))
{
return false;
}
challenge = user.PendingChallenge;
issuedAtUtc = user.ChallengeIssuedAtUtc;
return true;
}
}
public static bool TryRecordNonce(string wsid, string nonce, long timestamp, int replayWindowSeconds, out string reason)
{
lock (_UserListLock)
{
reason = "";
User? user = _UserList.Find(u => u.ID == wsid);
if (user == null)
{
reason = "user not found";
return false;
}
if (string.IsNullOrWhiteSpace(nonce))
{
reason = "nonce empty";
return false;
}
long cutoff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - replayWindowSeconds;
List<string> expiredKeys = [];
foreach (KeyValuePair<string, long> item in user.ReplayNonceStore)
{
if (item.Value < cutoff)
{
expiredKeys.Add(item.Key);
}
}
foreach (string key in expiredKeys)
{
user.ReplayNonceStore.Remove(key);
}
if (user.ReplayNonceStore.ContainsKey(nonce))
{
reason = "replay nonce detected";
return false;
}
user.ReplayNonceStore[nonce] = timestamp;
return true;
}
}
public static bool IsRateLimitExceeded(string wsid, int maxRequests, int windowSeconds)
{
lock (_UserListLock)
{
User? user = _UserList.Find(u => u.ID == wsid);
if (user == null)
{
return true;
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long cutoff = nowMs - (windowSeconds * 1000L);
while (user.RequestTimesMs.Count > 0 && user.RequestTimesMs.Peek() < cutoff)
{
user.RequestTimesMs.Dequeue();
}
if (user.RequestTimesMs.Count >= maxRequests)
{
return true;
}
user.RequestTimesMs.Enqueue(nowMs);
return false;
}
}
#endregion
}
}

@ -0,0 +1,139 @@
using System.Net;
using OnlineMsgServer.Common;
using WebSocketSharp;
using WebSocketSharp.Server;
using ErrorEventArgs = WebSocketSharp.ErrorEventArgs;
namespace OnlineMsgServer.Core
{
class WsService : WebSocketBehavior
{
private IPEndPoint iPEndPoint = new(IPAddress.Any, 0);
private static readonly object _abuseLock = new();
private static readonly Dictionary<string, DateTime> _ipBlockedUntil = [];
protected override async void OnMessage(MessageEventArgs e)
{
SecurityConfig config = SecurityRuntime.Config;
try
{
string ip = iPEndPoint.Address.ToString();
if (IsIpBlocked(ip))
{
Common.Log.Security("message_rejected_ip_blocked", $"wsid={ID} ip={ip}");
Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "ip blocked");
return;
}
int messageSize = e.RawData?.Length ?? 0;
if (messageSize > config.MaxMessageBytes)
{
Common.Log.Security("message_too_large", $"wsid={ID} size={messageSize}");
BlockIp(ip, config.IpBlockSeconds);
Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "message too large");
return;
}
if (UserService.IsRateLimitExceeded(ID, config.RateLimitCount, config.RateLimitWindowSeconds))
{
Common.Log.Security("rate_limited", $"wsid={ID} ip={ip}");
BlockIp(ip, config.IpBlockSeconds);
Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "rate limited");
return;
}
Common.Log.Debug(ID + " " + Context.UserEndPoint.ToString() + ":" + e.Data);
//从base64字符串解密
string decryptString = RsaService.Decrypt(e.Data);
//json 反序列化
Message? message = Message.JsonStringParse(decryptString);
if (message != null)
{
await message.HandlerAndMeasure(ID, Sessions);
}
}
catch (Exception ex)
{
Common.Log.Security("message_process_error", $"wsid={ID} error={ex.Message}");
}
}
protected override void OnOpen()
{
iPEndPoint = Context.UserEndPoint;
SecurityConfig config = SecurityRuntime.Config;
string ip = iPEndPoint.Address.ToString();
if (IsIpBlocked(ip))
{
Common.Log.Security("connection_blocked_ip", $"ip={ip}");
Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "ip blocked");
return;
}
if (UserService.GetConnectionCount() >= config.MaxConnections)
{
Common.Log.Security("connection_rejected_max", $"ip={ip} max={config.MaxConnections}");
Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "server busy");
return;
}
string challenge = SecurityValidator.CreateNonce();
UserService.AddUserConnect(ID, ip, challenge);
Common.Log.Debug(ID + " " + iPEndPoint.ToString() + " Conection Open");
//连接时回复公钥,不加密
Message response = new()
{
Type = "publickey",
Data = new
{
publicKey = RsaService.GetRsaPublickKey(),
authChallenge = challenge,
authTtlSeconds = config.ChallengeTtlSeconds,
certFingerprintSha256 = SecurityRuntime.ServerCertificateFingerprintSha256
},
};
string jsonString = response.ToJsonString();
Send(jsonString);
}
protected override void OnClose(CloseEventArgs e)
{
UserService.RemoveUserConnectByID(ID);
Common.Log.Debug(this.ID + " " + this.iPEndPoint.ToString() + " Conection Close" + e.Reason);
}
protected override void OnError(ErrorEventArgs e)
{
UserService.RemoveUserConnectByID(ID);
Common.Log.Debug(this.ID + " " + this.iPEndPoint.ToString() + " Conection Error Close" + e.Message);
}
private static bool IsIpBlocked(string ip)
{
lock (_abuseLock)
{
if (!_ipBlockedUntil.TryGetValue(ip, out DateTime untilUtc))
{
return false;
}
if (untilUtc <= DateTime.UtcNow)
{
_ipBlockedUntil.Remove(ip);
return false;
}
return true;
}
}
private static void BlockIp(string ip, int seconds)
{
lock (_abuseLock)
{
_ipBlockedUntil[ip] = DateTime.UtcNow.AddSeconds(seconds);
}
}
}
}

@ -0,0 +1,47 @@
# 使用官方的 .NET 8 SDK 镜像进行构建
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# 设置工作目录
WORKDIR /app
# 将项目文件复制到容器中
COPY . ./
# 恢复项目依赖项
RUN dotnet restore
# 编译项目
RUN dotnet publish ./OnlineMsgServer.csproj -c Release -o out
# 使用更小的运行时镜像
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
# 设置工作目录
WORKDIR /app
# 运行时安全配置默认值(可在 docker run 时覆盖)
ENV REQUIRE_WSS=false \
MAX_CONNECTIONS=1000 \
MAX_MESSAGE_BYTES=65536 \
RATE_LIMIT_COUNT=30 \
RATE_LIMIT_WINDOW_SECONDS=10 \
IP_BLOCK_SECONDS=120 \
CHALLENGE_TTL_SECONDS=120 \
MAX_CLOCK_SKEW_SECONDS=60 \
REPLAY_WINDOW_SECONDS=120
# 创建非 root 用户
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
# 暴露端口
EXPOSE 13173
# 从构建镜像复制发布的应用到当前镜像
COPY --from=build /app/out .
# 收敛运行权限
RUN chown -R appuser:appgroup /app
USER appuser
# 设置容器启动命令
ENTRYPOINT ["dotnet", "OnlineMsgServer.dll"]

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="WebSocketSharp-netstandard" Version="1.0.1" />
</ItemGroup>
</Project>

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OnlineMsgServer", "OnlineMsgServer.csproj", "{25CCECC8-9D18-41C9-80CC-D4BF5DA23636}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25CCECC8-9D18-41C9-80CC-D4BF5DA23636}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6199C6A0-5CFC-4112-A9E7-E2B73FE4E358}
EndGlobalSection
EndGlobal

@ -0,0 +1,100 @@
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using OnlineMsgServer.Common;
using OnlineMsgServer.Core;
using WebSocketSharp.Server;
namespace OnlineMsgServer
{
class Program
{
static async Task Main(string[] args)
{
try
{
await MainLoop();
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
static async Task MainLoop()
{
SecurityConfig config = SecurityConfig.LoadFromEnvironment();
string? certFingerprint = null;
//初始化RSA
RsaService.LoadRsaPkey(config);
var wssv = new WebSocketServer(config.ListenPort, config.RequireWss);
if (config.RequireWss)
{
X509Certificate2 certificate = LoadTlsCertificate(config);
wssv.SslConfiguration.ServerCertificate = certificate;
wssv.SslConfiguration.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
certFingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
Console.WriteLine($"TLS cert SHA256 fingerprint: {certFingerprint}");
}
else
{
Log.Security("transport_weak", "REQUIRE_WSS=false, service is running without TLS");
}
SecurityRuntime.Initialize(config, certFingerprint);
//开启ws监听
wssv.AddWebSocketService<WsService>("/");
wssv.Start();
Console.WriteLine("已开启ws监听, 端口: " + config.ListenPort);
bool loopFlag = true;
while (loopFlag)
{
#if DEBUG
Console.WriteLine("输入exit退出程序");
string input = Console.ReadLine() ?? "";
switch (input.Trim())
{
case "exit":
loopFlag = false;
break;
case "port":
Console.WriteLine("服务器开放端口为" + config.ListenPort);
break;
default:
break;
}
#endif
await Task.Delay(5000);// 每5秒检查一次
}
wssv.Stop();
}
static X509Certificate2 LoadTlsCertificate(SecurityConfig config)
{
if (string.IsNullOrWhiteSpace(config.TlsCertPath))
{
throw new InvalidOperationException("启用WSS时必须配置 TLS_CERT_PATH。");
}
if (!File.Exists(config.TlsCertPath))
{
throw new FileNotFoundException("找不到 TLS 证书文件。", config.TlsCertPath);
}
X509Certificate2 cert = string.IsNullOrEmpty(config.TlsCertPassword)
? new X509Certificate2(config.TlsCertPath)
: new X509Certificate2(config.TlsCertPath, config.TlsCertPassword);
if (!cert.HasPrivateKey)
{
throw new InvalidOperationException("TLS 证书缺少私钥,请使用包含私钥的 PFX 证书。");
}
return cert;
}
}
}

@ -0,0 +1,202 @@
# OnlineMsgServer
在线消息中转服务WebSocket + RSA支持客户端鉴权、单播转发、广播、签名校验、防重放、限流。
## 当前默认行为
- 服务端默认 `REQUIRE_WSS=false`(便于内外网测试)
- 仍支持 `WSS(TLS)`,可通过环境变量启用
- 客户端需要完成 challenge-response 鉴权后才能发业务消息
## 快速开始
### 1) 测试模式(推荐先跑通)
```bash
cd /Users/solux/Codes/OnlineMsgServer
bash deploy/deploy_test_ws.sh
```
这个脚本会自动:
- 生成/复用服务端 RSA 私钥(`deploy/keys`
- 构建镜像并重启容器
- 以 `REQUIRE_WSS=false` 启动服务
- 输出可直接使用的 `ws://` 地址
### 2) 安全模式WSS + 局域网证书)
```bash
cd /Users/solux/Codes/OnlineMsgServer
bash deploy/redeploy_with_lan_cert.sh
```
这个脚本会自动:
- 重新生成带当前 LAN IP 的 TLS 证书(`deploy/certs`
- 构建镜像并重启容器
- 以 `REQUIRE_WSS=true` 启动服务
- 输出可直接使用的 `wss://` 地址
### 3) 生产准备(证书 + 镜像 + 部署产物)
```bash
cd /Users/solux/Codes/OnlineMsgServer
DOMAIN=chat.example.com \
TLS_CERT_PEM=/path/fullchain.pem \
TLS_KEY_PEM=/path/privkey.pem \
TLS_CHAIN_PEM=/path/chain.pem \
CERT_PASSWORD='change-me' \
bash deploy/prepare_prod_release.sh
```
脚本会自动:
- 准备服务端协议私钥(`deploy/keys`
- 生成运行时 `server.pfx``deploy/certs`
- 构建生产镜像(默认 `onlinemsgserver:prod`
- 导出部署产物到 `deploy/output/prod``prod.env`、镜像 tar、运行示例脚本
如果你暂时没有 CA 证书,也可用自签名兜底(仅测试):
```bash
DOMAIN=chat.example.com SAN_LIST='DNS:www.chat.example.com,IP:10.0.0.8' GENERATE_SELF_SIGNED=true bash deploy/prepare_prod_release.sh
```
## 手动 Docker 启动示例
### WS测试
```bash
docker run -d --name onlinemsgserver --restart unless-stopped \
-p 13173:13173 \
-v /Users/solux/Codes/OnlineMsgServer/deploy/keys:/app/keys:ro \
-e REQUIRE_WSS=false \
-e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \
onlinemsgserver:latest
```
### WSS生产/半生产)
```bash
docker run -d --name onlinemsgserver --restart unless-stopped \
-p 13173:13173 \
-v /Users/solux/Codes/OnlineMsgServer/deploy/certs:/app/certs:ro \
-v /Users/solux/Codes/OnlineMsgServer/deploy/keys:/app/keys:ro \
-e REQUIRE_WSS=true \
-e TLS_CERT_PATH=/app/certs/server.pfx \
-e TLS_CERT_PASSWORD=changeit \
-e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \
onlinemsgserver:latest
```
## 协议说明(客户端 -> 服务端)
### 加密方式
- RSA-2048-OAEP-SHA256
- 明文分块 190 字节加密
- 密文按 256 字节分块解密
- 传输格式为 base64 字符串
### 通用包结构
```json
{
"type": "publickey|forward|broadcast",
"key": "",
"data": {}
}
```
### 1) 鉴权登记 `type=publickey`
- `key`:用户名(可空)
- `data`
```json
{
"publicKey": "客户端公钥(base64 SPKI)",
"challenge": "服务端下发挑战值",
"timestamp": 1739600000,
"nonce": "随机字符串",
"signature": "签名(base64)"
}
```
签名串:
```text
publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce}
```
### 2) 单播转发 `type=forward`
- `key`:目标公钥
- `data`
```json
{
"payload": "消息内容",
"timestamp": 1739600000,
"nonce": "随机字符串",
"signature": "签名(base64)"
}
```
签名串:
```text
forward\n{targetPublicKey}\n{payload}\n{timestamp}\n{nonce}
```
### 3) 广播 `type=broadcast`
- `key`:可空
- `data`:同 `forward`
签名串:
```text
broadcast\n{key}\n{payload}\n{timestamp}\n{nonce}
```
### 连接流程
1. 客户端连接后,服务端先返回未加密 `publickey`含服务端公钥、challenge、TTL、证书指纹
2. 客户端发送签名鉴权包(`type=publickey`)。
3. 鉴权成功后发送 `forward` / `broadcast` 业务包。
## 环境变量
- `LISTEN_PORT`:监听端口(默认 `13173`
- `REQUIRE_WSS`:是否启用 WSS默认 `false`
- `TLS_CERT_PATH`证书路径WSS 必填)
- `TLS_CERT_PASSWORD`:证书密码(可空)
- `SERVER_PRIVATE_KEY_B64`服务端私钥PKCS8 base64
- `SERVER_PRIVATE_KEY_PATH`:服务端私钥文件路径(与上面二选一)
- `ALLOW_EPHEMERAL_SERVER_KEY`:允许仅内存临时私钥(默认 `false`
- `MAX_CONNECTIONS`:最大连接数
- `MAX_MESSAGE_BYTES`:最大消息字节数
- `RATE_LIMIT_COUNT`:限流窗口内最大消息数
- `RATE_LIMIT_WINDOW_SECONDS`:限流窗口秒数
- `IP_BLOCK_SECONDS`:触发滥用后 IP 封禁秒数
- `CHALLENGE_TTL_SECONDS`challenge 有效期
- `MAX_CLOCK_SKEW_SECONDS`:允许时钟偏差
- `REPLAY_WINDOW_SECONDS`:防重放窗口
## 前端React
前端目录:`/Users/solux/Codes/OnlineMsgServer/web-client`
```bash
cd /Users/solux/Codes/OnlineMsgServer/web-client
npm install
npm run dev
```
当前前端能力:
- 默认隐藏协议细节,手动地址放在“高级连接设置”
- 支持广播/私聊、查看并复制自己的公钥
- 每条消息支持一键复制
- 自动处理超长消息换行,不溢出消息框
- 用户名和客户端私钥本地持久化,刷新后继续使用
更多前端说明见 `web-client/README.md`

@ -0,0 +1,5 @@
.gradle/
local.properties
**/build/
.idea/
*.iml

@ -0,0 +1,54 @@
# OnlineMsg Android Client (Kotlin + Compose)
本目录是针对当前 `OnlineMsgServer` 协议实现的 Android 客户端。
## 已实现能力
- Kotlin + Jetpack Compose + Material3
- 与当前服务端协议兼容:
- 首包 `publickey` 握手(明文)
- `publickey` challenge-response 鉴权(签名)
- `broadcast` / `forward` 消息发送(签名 + 防重放字段)
- 消息体 RSA-OAEP-SHA256 分块加解密190/256
- Android Keystore 生成并持久化客户端 RSA 密钥
- 状态管理:`ViewModel + StateFlow`
- 本地偏好:`DataStore`(用户名、服务器地址、模式、系统消息开关)
- 易用性:
- 广播/私聊一键切换
- 消息复制
- 我的公钥查看与复制
- 服务器地址保存/删除
- 状态提示与诊断信息
## 工程结构
- `app/src/main/java/com/onlinemsg/client/ui`UI、ViewModel、状态模型
- `app/src/main/java/com/onlinemsg/client/data/crypto`RSA 加密、签名、nonce
- `app/src/main/java/com/onlinemsg/client/data/network`WebSocket 封装
- `app/src/main/java/com/onlinemsg/client/data/preferences`DataStore 与地址格式化
- `app/src/main/java/com/onlinemsg/client/data/protocol`:协议 DTO
## 运行方式
1. 使用 Android Studio 打开 `android-client` 目录。
2. 等待 Gradle Sync 完成。
3. 运行 `app`
## 联调建议
- 模拟器建议地址:`ws://10.0.2.2:13173/`
- 真机建议地址:`ws://<你的局域网IP>:13173/`
- 若服务端启用 WSS需要 Android 设备信任对应证书。
## 协议注意事项
- 鉴权签名串:
- `publickey\n{userName}\n{publicKey}\n{challenge}\n{timestamp}\n{nonce}`
- 业务签名串:
- `broadcast|forward\n{key}\n{payload}\n{timestamp}\n{nonce}`
- `forward``key` 必须是目标公钥。
- `broadcast``key` 为空字符串。
## 已知限制
- 当前未内置证书固定pinning如用于公网生产建议额外启用证书固定策略。

Binary file not shown.

@ -0,0 +1,84 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.onlinemsg.client"
compileSdk = 34
defaultConfig {
applicationId = "com.onlinemsg.client"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("com.google.android.material:material:1.12.0")
implementation(platform("androidx.compose:compose-bom:2024.09.03"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("junit:junit:4.13.2")
androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.03"))
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

@ -0,0 +1 @@
# Keep defaults; add project-specific ProGuard rules here when needed.

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.OnlineMsg">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

@ -0,0 +1,17 @@
package com.onlinemsg.client
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.onlinemsg.client.ui.OnlineMsgApp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
OnlineMsgApp()
}
}
}

@ -0,0 +1,186 @@
package com.onlinemsg.client.data.crypto
import android.content.Context
import android.util.Base64
import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.PrivateKey
import java.security.PublicKey
import java.security.SecureRandom
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
import java.security.spec.MGF1ParameterSpec
class RsaCryptoManager(
context: Context
) {
data class Identity(
val publicKeyBase64: String,
val privateKey: PrivateKey
)
private val secureRandom = SecureRandom()
private val appContext = context.applicationContext
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getOrCreateIdentity(): Identity {
val cachedPrivate = prefs.getString(KEY_PRIVATE_PKCS8_B64, null)
val cachedPublic = prefs.getString(KEY_PUBLIC_SPKI_B64, null)
if (!cachedPrivate.isNullOrBlank() && !cachedPublic.isNullOrBlank()) {
runCatching {
val privateKey = parsePrivateKey(cachedPrivate)
return Identity(
publicKeyBase64 = cachedPublic,
privateKey = privateKey
)
}.onFailure {
prefs.edit()
.remove(KEY_PRIVATE_PKCS8_B64)
.remove(KEY_PUBLIC_SPKI_B64)
.apply()
}
}
val generator = KeyPairGenerator.getInstance("RSA")
generator.initialize(KEY_SIZE_BITS)
val keyPair = generator.generateKeyPair()
val publicB64 = Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP)
val privateB64 = Base64.encodeToString(keyPair.private.encoded, Base64.NO_WRAP)
prefs.edit()
.putString(KEY_PRIVATE_PKCS8_B64, privateB64)
.putString(KEY_PUBLIC_SPKI_B64, publicB64)
.apply()
return Identity(
publicKeyBase64 = publicB64,
privateKey = keyPair.private
)
}
fun signText(privateKey: PrivateKey, text: String): String {
val signature = Signature.getInstance("SHA256withRSA")
signature.initSign(privateKey)
signature.update(text.toByteArray(StandardCharsets.UTF_8))
return Base64.encodeToString(signature.sign(), Base64.NO_WRAP)
}
fun encryptChunked(publicKeyBase64: String, plainText: String): String {
if (plainText.isEmpty()) return ""
val publicKey = parsePublicKey(publicKeyBase64)
val src = plainText.toByteArray(StandardCharsets.UTF_8)
val output = ByteArrayOutputStream(src.size + 256)
var offset = 0
while (offset < src.size) {
val len = minOf(ENCRYPT_BLOCK_SIZE, src.size - offset)
val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA256_SPEC)
val block = cipher.doFinal(src, offset, len)
output.write(block)
offset += len
}
return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
fun decryptChunked(privateKey: PrivateKey, cipherTextBase64: String): String {
if (cipherTextBase64.isEmpty()) return ""
val encrypted = Base64.decode(cipherTextBase64, Base64.DEFAULT)
require(encrypted.size % DECRYPT_BLOCK_SIZE == 0) {
"ciphertext length invalid"
}
val attempts = listOf<(PrivateKey, ByteArray) -> ByteArray>(
{ key, block ->
val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, OAEP_SHA256_SPEC)
cipher.doFinal(block)
},
{ key, block ->
val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, OAEP_SHA256_MGF1_SHA1_SPEC)
cipher.doFinal(block)
},
{ key, block ->
val cipher = Cipher.getInstance(RSA_OAEP_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key)
cipher.doFinal(block)
}
)
var lastError: Throwable? = null
for (decryptBlock in attempts) {
try {
val plainBytes = decryptBlocks(encrypted, privateKey, decryptBlock)
return String(plainBytes, StandardCharsets.UTF_8)
} catch (error: Throwable) {
lastError = error
}
}
throw IllegalStateException("rsa oaep decrypt failed", lastError)
}
fun createNonce(size: Int = 18): String {
val bytes = ByteArray(size)
secureRandom.nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
fun unixSecondsNow(): Long = System.currentTimeMillis() / 1000L
private fun parsePublicKey(publicKeyBase64: String): PublicKey {
val keyBytes = Base64.decode(publicKeyBase64, Base64.DEFAULT)
val spec = X509EncodedKeySpec(keyBytes)
return KeyFactory.getInstance("RSA").generatePublic(spec)
}
private fun parsePrivateKey(privateKeyBase64: String): PrivateKey {
val keyBytes = Base64.decode(privateKeyBase64, Base64.DEFAULT)
val spec = PKCS8EncodedKeySpec(keyBytes)
return KeyFactory.getInstance("RSA").generatePrivate(spec)
}
private fun decryptBlocks(
encrypted: ByteArray,
privateKey: PrivateKey,
decryptBlock: (PrivateKey, ByteArray) -> ByteArray
): ByteArray {
val output = ByteArrayOutputStream(encrypted.size)
var offset = 0
while (offset < encrypted.size) {
val cipherBlock = encrypted.copyOfRange(offset, offset + DECRYPT_BLOCK_SIZE)
output.write(decryptBlock(privateKey, cipherBlock))
offset += DECRYPT_BLOCK_SIZE
}
return output.toByteArray()
}
private companion object {
private const val KEY_SIZE_BITS = 2048
private const val ENCRYPT_BLOCK_SIZE = 190
private const val DECRYPT_BLOCK_SIZE = 256
private const val RSA_OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
private const val PREFS_NAME = "oms_crypto_identity"
private const val KEY_PRIVATE_PKCS8_B64 = "private_pkcs8_b64"
private const val KEY_PUBLIC_SPKI_B64 = "public_spki_b64"
private val OAEP_SHA256_SPEC: OAEPParameterSpec = OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT
)
private val OAEP_SHA256_MGF1_SHA1_SPEC: OAEPParameterSpec = OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA1,
PSource.PSpecified.DEFAULT
)
}
}

@ -0,0 +1,83 @@
package com.onlinemsg.client.data.network
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
class OnlineMsgSocketClient {
interface Listener {
fun onOpen()
fun onMessage(text: String)
fun onBinaryMessage(payload: ByteArray)
fun onClosed(code: Int, reason: String)
fun onFailure(throwable: Throwable)
}
private val client = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.build()
@Volatile
private var socket: WebSocket? = null
@Volatile
private var listener: Listener? = null
fun connect(url: String, listener: Listener) {
close(1000, "reconnect")
this.listener = listener
val request = Request.Builder()
.url(url)
.header("User-Agent", "OnlineMsg-Android/1.0")
.build()
socket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
this@OnlineMsgSocketClient.listener?.onOpen()
}
override fun onMessage(webSocket: WebSocket, text: String) {
this@OnlineMsgSocketClient.listener?.onMessage(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
val raw = bytes.toByteArray()
this@OnlineMsgSocketClient.listener?.onBinaryMessage(raw)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (socket === webSocket) {
socket = null
}
this@OnlineMsgSocketClient.listener?.onClosed(code, reason)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (socket === webSocket) {
socket = null
}
this@OnlineMsgSocketClient.listener?.onFailure(t)
}
})
}
fun send(text: String): Boolean = socket?.send(text) == true
fun close(code: Int = 1000, reason: String = "manual_close") {
socket?.close(code, reason)
socket = null
}
fun shutdown() {
close()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}

@ -0,0 +1,79 @@
package com.onlinemsg.client.data.preferences
import java.net.URI
object ServerUrlFormatter {
const val maxServerUrls: Int = 8
const val defaultServerUrl: String = "ws://10.0.2.2:13173/"
fun normalize(input: String): String {
var value = input.trim()
if (value.isEmpty()) return ""
if (!value.contains("://")) {
value = "ws://$value"
}
if (value.startsWith("http://", ignoreCase = true)) {
value = "ws://${value.substring("http://".length)}"
} else if (value.startsWith("https://", ignoreCase = true)) {
value = "wss://${value.substring("https://".length)}"
}
if (!value.endsWith('/')) {
value += "/"
}
return try {
val uri = URI(value)
if (uri.scheme.isNullOrBlank() || uri.host.isNullOrBlank()) {
""
} else {
value
}
} catch (_: Exception) {
""
}
}
fun dedupe(urls: List<String>): List<String> {
val result = LinkedHashSet<String>()
urls.forEach { raw ->
val normalized = normalize(raw)
if (normalized.isNotBlank()) {
result += normalized
}
}
return result.toList()
}
fun append(current: List<String>, rawUrl: String): List<String> {
val normalized = normalize(rawUrl)
if (normalized.isBlank()) return current
val merged = listOf(normalized) + current.filterNot { it == normalized }
return merged.take(maxServerUrls)
}
fun toggleWsProtocol(rawUrl: String): String {
val normalized = normalize(rawUrl)
if (normalized.isBlank()) return ""
return try {
val uri = URI(normalized)
if (uri.scheme.equals("ws", ignoreCase = true)) {
URI(
"wss",
uri.userInfo,
uri.host,
uri.port,
uri.path,
uri.query,
uri.fragment
).toString()
} else {
""
}
} catch (_: Exception) {
""
}
}
}

@ -0,0 +1,123 @@
package com.onlinemsg.client.data.preferences
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
private val Context.dataStore by preferencesDataStore(name = "oms_preferences")
data class UserPreferences(
val displayName: String,
val serverUrls: List<String>,
val currentServerUrl: String,
val showSystemMessages: Boolean,
val directMode: Boolean
)
class UserPreferencesRepository(
private val context: Context,
private val json: Json
) {
val preferencesFlow: Flow<UserPreferences> = context.dataStore.data.map { prefs ->
val storedDisplayName = prefs[KEY_DISPLAY_NAME].orEmpty().trim()
val displayName = storedDisplayName.takeIf { it.isNotBlank() } ?: "guest-${System.currentTimeMillis().toString().takeLast(6)}"
val serverUrls = decodeServerUrls(prefs[KEY_SERVER_URLS])
val currentServer = ServerUrlFormatter.normalize(prefs[KEY_CURRENT_SERVER_URL].orEmpty())
.takeIf { it.isNotBlank() }
?: serverUrls.firstOrNull()
?: ServerUrlFormatter.defaultServerUrl
UserPreferences(
displayName = displayName.take(64),
serverUrls = if (serverUrls.isEmpty()) listOf(ServerUrlFormatter.defaultServerUrl) else serverUrls,
currentServerUrl = currentServer,
showSystemMessages = prefs[KEY_SHOW_SYSTEM_MESSAGES] ?: false,
directMode = prefs[KEY_DIRECT_MODE] ?: false
)
}
suspend fun setDisplayName(name: String) {
context.dataStore.edit { prefs ->
prefs[KEY_DISPLAY_NAME] = name.trim().take(64)
}
}
suspend fun setCurrentServerUrl(rawUrl: String) {
val normalized = ServerUrlFormatter.normalize(rawUrl)
if (normalized.isBlank()) return
context.dataStore.edit { prefs ->
prefs[KEY_CURRENT_SERVER_URL] = normalized
val currentList = decodeServerUrls(prefs[KEY_SERVER_URLS])
val nextList = ServerUrlFormatter.append(currentList, normalized)
prefs[KEY_SERVER_URLS] = json.encodeToString(nextList)
}
}
suspend fun saveCurrentServerUrl(rawUrl: String) {
val normalized = ServerUrlFormatter.normalize(rawUrl)
if (normalized.isBlank()) return
context.dataStore.edit { prefs ->
val currentList = decodeServerUrls(prefs[KEY_SERVER_URLS])
val nextList = ServerUrlFormatter.append(currentList, normalized)
prefs[KEY_SERVER_URLS] = json.encodeToString(nextList)
prefs[KEY_CURRENT_SERVER_URL] = normalized
}
}
suspend fun removeCurrentServerUrl(rawUrl: String) {
val normalized = ServerUrlFormatter.normalize(rawUrl)
context.dataStore.edit { prefs ->
val currentList = decodeServerUrls(prefs[KEY_SERVER_URLS])
val filtered = currentList.filterNot { it == normalized }
val nextList = if (filtered.isEmpty()) {
listOf(ServerUrlFormatter.defaultServerUrl)
} else {
filtered
}
prefs[KEY_SERVER_URLS] = json.encodeToString(nextList)
prefs[KEY_CURRENT_SERVER_URL] = nextList.first()
}
}
suspend fun setShowSystemMessages(show: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_SHOW_SYSTEM_MESSAGES] = show
}
}
suspend fun setDirectMode(isDirect: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_DIRECT_MODE] = isDirect
}
}
private fun decodeServerUrls(raw: String?): List<String> {
if (raw.isNullOrBlank()) return listOf(ServerUrlFormatter.defaultServerUrl)
return runCatching {
val element = json.parseToJsonElement(raw)
element.jsonArray.mapNotNull { item ->
runCatching { json.decodeFromJsonElement<String>(item) }.getOrNull()
}
}.getOrElse { emptyList() }
.let(ServerUrlFormatter::dedupe)
.ifEmpty { listOf(ServerUrlFormatter.defaultServerUrl) }
}
private companion object {
val KEY_DISPLAY_NAME: Preferences.Key<String> = stringPreferencesKey("display_name")
val KEY_SERVER_URLS: Preferences.Key<String> = stringPreferencesKey("server_urls")
val KEY_CURRENT_SERVER_URL: Preferences.Key<String> = stringPreferencesKey("current_server_url")
val KEY_SHOW_SYSTEM_MESSAGES: Preferences.Key<Boolean> = booleanPreferencesKey("show_system_messages")
val KEY_DIRECT_MODE: Preferences.Key<Boolean> = booleanPreferencesKey("direct_mode")
}
}

@ -0,0 +1,54 @@
package com.onlinemsg.client.data.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class EnvelopeDto(
@SerialName("type") val type: String,
@SerialName("key") val key: String? = null,
@SerialName("data") val data: JsonElement? = JsonNull
)
@Serializable
data class HelloDataDto(
@SerialName("publicKey") val publicKey: String,
@SerialName("authChallenge") val authChallenge: String,
@SerialName("authTtlSeconds") val authTtlSeconds: Int? = null,
@SerialName("certFingerprintSha256") val certFingerprintSha256: String? = null
)
@Serializable
data class AuthPayloadDto(
@SerialName("publicKey") val publicKey: String,
@SerialName("challenge") val challenge: String,
@SerialName("timestamp") val timestamp: Long,
@SerialName("nonce") val nonce: String,
@SerialName("signature") val signature: String
)
@Serializable
data class SignedPayloadDto(
@SerialName("payload") val payload: String,
@SerialName("timestamp") val timestamp: Long,
@SerialName("nonce") val nonce: String,
@SerialName("signature") val signature: String
)
fun JsonElement?.asPayloadText(): String {
if (this == null || this is JsonNull) return ""
return if (this is JsonPrimitive && this.isString) {
this.content
} else {
this.toString()
}
}
fun JsonElement?.asStringOrNull(): String? {
if (this == null || this is JsonNull) return null
return runCatching { this.jsonPrimitive.content }.getOrNull()
}

@ -0,0 +1,589 @@
package com.onlinemsg.client.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Send
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.onlinemsg.client.ui.theme.OnlineMsgTheme
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private enum class MainTab(val label: String) {
CHAT("聊天"),
SETTINGS("设置")
}
@Composable
fun OnlineMsgApp(
viewModel: ChatViewModel = viewModel()
) {
OnlineMsgTheme {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val snackbarHostState = remember { SnackbarHostState() }
var tab by rememberSaveable { mutableStateOf(MainTab.CHAT) }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
}
}
}
Scaffold(
topBar = {
AppTopBar(
statusText = state.statusText,
statusColor = when (state.status) {
ConnectionStatus.READY -> MaterialTheme.colorScheme.primary
ConnectionStatus.ERROR -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.secondary
}
)
},
bottomBar = {
NavigationBar(modifier = Modifier.navigationBarsPadding()) {
NavigationBarItem(
selected = tab == MainTab.CHAT,
onClick = { tab = MainTab.CHAT },
label = { Text(MainTab.CHAT.label) },
icon = {}
)
NavigationBarItem(
selected = tab == MainTab.SETTINGS,
onClick = { tab = MainTab.SETTINGS },
label = { Text(MainTab.SETTINGS.label) },
icon = {}
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
when (tab) {
MainTab.CHAT -> {
ChatTab(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding),
state = state,
onToggleDirectMode = viewModel::toggleDirectMode,
onTargetKeyChange = viewModel::updateTargetKey,
onDraftChange = viewModel::updateDraft,
onSend = viewModel::sendMessage,
onConnect = viewModel::connect,
onDisconnect = viewModel::disconnect,
onClearMessages = viewModel::clearMessages,
onCopyMessage = { content ->
clipboard.setText(AnnotatedString(content))
viewModel.onMessageCopied()
}
)
}
MainTab.SETTINGS -> {
SettingsTab(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding),
state = state,
onDisplayNameChange = viewModel::updateDisplayName,
onServerUrlChange = viewModel::updateServerUrl,
onSaveServer = viewModel::saveCurrentServerUrl,
onRemoveServer = viewModel::removeCurrentServerUrl,
onSelectServer = viewModel::updateServerUrl,
onToggleShowSystem = viewModel::toggleShowSystemMessages,
onRevealPublicKey = viewModel::revealMyPublicKey,
onCopyPublicKey = {
if (state.myPublicKey.isNotBlank()) {
clipboard.setText(AnnotatedString(state.myPublicKey))
viewModel.onMessageCopied()
}
},
onConnect = viewModel::connect,
onDisconnect = viewModel::disconnect,
onClearMessages = viewModel::clearMessages
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppTopBar(
statusText: String,
statusColor: Color
) {
TopAppBar(
title = {
Text(
text = "OnlineMsg Chat",
style = MaterialTheme.typography.titleLarge
)
},
actions = {
AssistChip(
onClick = {},
label = { Text(statusText) },
leadingIcon = {
Box(
modifier = Modifier
.width(10.dp)
.height(10.dp)
.background(statusColor, RoundedCornerShape(999.dp))
)
}
)
Spacer(modifier = Modifier.width(12.dp))
}
)
}
@Composable
private fun ChatTab(
modifier: Modifier,
state: ChatUiState,
onToggleDirectMode: (Boolean) -> Unit,
onTargetKeyChange: (String) -> Unit,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onClearMessages: () -> Unit,
onCopyMessage: (String) -> Unit
) {
val listState = rememberLazyListState()
LaunchedEffect(state.visibleMessages.size) {
if (state.visibleMessages.isNotEmpty()) {
listState.animateScrollToItem(state.visibleMessages.lastIndex)
}
}
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
ConnectionRow(
statusHint = state.statusHint,
canConnect = state.canConnect,
canDisconnect = state.canDisconnect,
onConnect = onConnect,
onDisconnect = onDisconnect,
onClearMessages = onClearMessages
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip(
selected = !state.directMode,
onClick = { onToggleDirectMode(false) },
label = { Text("广播") }
)
FilterChip(
selected = state.directMode,
onClick = { onToggleDirectMode(true) },
label = { Text("私聊") }
)
}
if (state.directMode) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.targetKey,
onValueChange = onTargetKeyChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("目标公钥") },
placeholder = { Text("私聊模式:粘贴目标公钥") },
maxLines = 3
)
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (state.visibleMessages.isEmpty()) {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
} else {
items(state.visibleMessages, key = { it.id }) { message ->
MessageItem(
message = message,
onCopy = { onCopyMessage(message.content) }
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
label = { Text("输入消息") },
maxLines = 4,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(
onSend = { onSend() }
)
)
Button(
onClick = onSend,
enabled = state.canSend,
modifier = Modifier.height(56.dp)
) {
Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = "发送")
Spacer(Modifier.width(6.dp))
Text(if (state.sending) "发送中" else "发送")
}
}
}
}
@Composable
private fun ConnectionRow(
statusHint: String,
canConnect: Boolean,
canDisconnect: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onClearMessages: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "在线会话",
style = MaterialTheme.typography.titleMedium
)
Text(
text = statusHint,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onConnect, enabled = canConnect) {
Text("连接")
}
OutlinedButton(onClick = onDisconnect, enabled = canDisconnect) {
Text("断开")
}
OutlinedButton(onClick = onClearMessages) {
Text("清空")
}
}
}
}
}
@Composable
private fun MessageItem(
message: UiMessage,
onCopy: () -> Unit
) {
val container = when (message.role) {
MessageRole.SYSTEM -> MaterialTheme.colorScheme.secondaryContainer
MessageRole.INCOMING -> MaterialTheme.colorScheme.surface
MessageRole.OUTGOING -> MaterialTheme.colorScheme.primaryContainer
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = container)
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (message.role != MessageRole.SYSTEM) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = message.sender,
style = MaterialTheme.typography.labelLarge
)
if (message.subtitle.isNotBlank()) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = message.subtitle,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = formatTime(message.timestampMillis),
style = MaterialTheme.typography.labelSmall
)
}
}
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
IconButton(onClick = onCopy) {
Icon(Icons.Rounded.ContentCopy, contentDescription = "复制")
}
}
}
}
}
@Composable
private fun SettingsTab(
modifier: Modifier,
state: ChatUiState,
onDisplayNameChange: (String) -> Unit,
onServerUrlChange: (String) -> Unit,
onSaveServer: () -> Unit,
onRemoveServer: () -> Unit,
onSelectServer: (String) -> Unit,
onToggleShowSystem: (Boolean) -> Unit,
onRevealPublicKey: () -> Unit,
onCopyPublicKey: () -> Unit,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onClearMessages: () -> Unit
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Card {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("个人设置", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.displayName,
onValueChange = onDisplayNameChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("显示名称") },
supportingText = { Text("最长 64 字符") },
maxLines = 1
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onConnect, enabled = state.canConnect) {
Text("连接")
}
OutlinedButton(onClick = onDisconnect, enabled = state.canDisconnect) {
Text("断开")
}
OutlinedButton(onClick = onClearMessages) {
Text("清空消息")
}
}
}
}
}
item {
Card {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("服务器", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.serverUrl,
onValueChange = onServerUrlChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("服务器地址") },
placeholder = { Text("ws://10.0.2.2:13173/") },
maxLines = 1
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onSaveServer) {
Text("保存地址")
}
OutlinedButton(onClick = onRemoveServer) {
Text("删除当前")
}
}
if (state.serverUrls.isNotEmpty()) {
HorizontalDivider()
Text("已保存地址", style = MaterialTheme.typography.labelLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.serverUrls) { url ->
AssistChip(
onClick = { onSelectServer(url) },
label = {
Text(
text = url,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
}
}
}
item {
Card {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("身份与安全", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onRevealPublicKey,
enabled = !state.loadingPublicKey
) {
Text(if (state.loadingPublicKey) "读取中" else "查看/生成公钥")
}
OutlinedButton(
onClick = onCopyPublicKey,
enabled = state.myPublicKey.isNotBlank()
) {
Text("复制公钥")
}
}
OutlinedTextField(
value = state.myPublicKey,
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
readOnly = true,
label = { Text("我的公钥") },
placeholder = { Text("点击“查看/生成公钥”") },
maxLines = 4
)
}
}
}
item {
Card {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("诊断", style = MaterialTheme.typography.titleMedium)
Text("连接提示:${state.statusHint}")
Text("当前状态:${state.statusText}")
Text("证书指纹:${state.certFingerprint.ifBlank { "未获取" }}")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Switch(
checked = state.showSystemMessages,
onCheckedChange = onToggleShowSystem
)
Text("显示系统消息")
}
}
}
}
item {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
private fun formatTime(tsMillis: Long): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm")
return Instant.ofEpochMilli(tsMillis)
.atZone(ZoneId.systemDefault())
.toLocalTime()
.format(formatter)
}

@ -0,0 +1,86 @@
package com.onlinemsg.client.ui
import java.util.UUID
enum class ConnectionStatus {
IDLE,
CONNECTING,
HANDSHAKING,
AUTHENTICATING,
READY,
ERROR
}
enum class MessageRole {
SYSTEM,
INCOMING,
OUTGOING
}
enum class MessageChannel {
BROADCAST,
PRIVATE
}
data class UiMessage(
val id: String = UUID.randomUUID().toString(),
val role: MessageRole,
val sender: String,
val subtitle: String = "",
val content: String,
val channel: MessageChannel,
val timestampMillis: Long = System.currentTimeMillis()
)
data class ChatUiState(
val status: ConnectionStatus = ConnectionStatus.IDLE,
val statusHint: String = "点击连接开始聊天",
val displayName: String = "",
val serverUrls: List<String> = emptyList(),
val serverUrl: String = "",
val directMode: Boolean = false,
val targetKey: String = "",
val draft: String = "",
val messages: List<UiMessage> = emptyList(),
val showSystemMessages: Boolean = false,
val certFingerprint: String = "",
val myPublicKey: String = "",
val sending: Boolean = false,
val loadingPublicKey: Boolean = false
) {
val canConnect: Boolean
get() = status == ConnectionStatus.IDLE || status == ConnectionStatus.ERROR
val canDisconnect: Boolean
get() = status == ConnectionStatus.CONNECTING ||
status == ConnectionStatus.HANDSHAKING ||
status == ConnectionStatus.AUTHENTICATING ||
status == ConnectionStatus.READY
val canSend: Boolean
get() = status == ConnectionStatus.READY && draft.trim().isNotEmpty() && !sending
val statusText: String
get() = when (status) {
ConnectionStatus.IDLE -> "未连接"
ConnectionStatus.CONNECTING,
ConnectionStatus.HANDSHAKING,
ConnectionStatus.AUTHENTICATING -> "连接中"
ConnectionStatus.READY -> "已连接"
ConnectionStatus.ERROR -> "异常断开"
}
val visibleMessages: List<UiMessage>
get() = messages.filter { item ->
if (item.role == MessageRole.SYSTEM) {
showSystemMessages
} else {
val selected = if (directMode) MessageChannel.PRIVATE else MessageChannel.BROADCAST
item.channel == selected
}
}
}
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
}

@ -0,0 +1,838 @@
package com.onlinemsg.client.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.onlinemsg.client.data.crypto.RsaCryptoManager
import com.onlinemsg.client.data.network.OnlineMsgSocketClient
import com.onlinemsg.client.data.preferences.ServerUrlFormatter
import com.onlinemsg.client.data.preferences.UserPreferencesRepository
import com.onlinemsg.client.data.protocol.AuthPayloadDto
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.nio.charset.StandardCharsets
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val json = Json {
ignoreUnknownKeys = true
}
private val preferencesRepository = UserPreferencesRepository(application, json)
private val cryptoManager = RsaCryptoManager(application)
private val socketClient = OnlineMsgSocketClient()
private val _uiState = MutableStateFlow(ChatUiState())
val uiState = _uiState.asStateFlow()
private val _events = MutableSharedFlow<UiEvent>()
val events = _events.asSharedFlow()
private val identityMutex = Mutex()
private var identity: RsaCryptoManager.Identity? = null
private var manualClose = false
private var fallbackTried = false
private var connectedUrl = ""
private var serverPublicKey = ""
private var helloTimeoutJob: Job? = null
private var authTimeoutJob: Job? = null
private var reconnectJob: Job? = null
private var reconnectAttempt: Int = 0
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf()
private val socketListener = object : OnlineMsgSocketClient.Listener {
override fun onOpen() {
viewModelScope.launch {
_uiState.update {
it.copy(
status = ConnectionStatus.HANDSHAKING,
statusHint = "已连接,正在准备聊天..."
)
}
addSystemMessage("连接已建立")
startHelloTimeout()
}
}
override fun onMessage(text: String) {
viewModelScope.launch {
runCatching {
handleIncomingMessage(text)
}.onFailure { error ->
addSystemMessage("文本帧处理异常:${error.message ?: "unknown"}")
}
}
}
override fun onBinaryMessage(payload: ByteArray) {
viewModelScope.launch {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
_uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") }
}
val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty()
if (utf8.isNotBlank()) {
runCatching {
handleIncomingMessage(utf8)
}.onFailure { error ->
addSystemMessage("二进制帧处理异常:${error.message ?: "unknown"}")
}
} else if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
val hexPreview = payload.take(24).joinToString(" ") { byte ->
"%02x".format(byte)
}
addSystemMessage("握手二进制帧无法转为文本len=${payload.size} hex=$hexPreview")
}
}
}
override fun onClosed(code: Int, reason: String) {
viewModelScope.launch {
handleSocketClosed(code, reason)
}
}
override fun onFailure(throwable: Throwable) {
viewModelScope.launch {
if (manualClose) return@launch
val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown"
addSystemMessage("连接异常:$message")
if (_uiState.value.status == ConnectionStatus.READY) {
scheduleReconnect("连接异常")
} else {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "连接失败,请检查服务器地址"
)
}
}
}
}
}
init {
viewModelScope.launch {
val pref = preferencesRepository.preferencesFlow.first()
_uiState.update { current ->
current.copy(
displayName = pref.displayName,
serverUrls = pref.serverUrls,
serverUrl = pref.currentServerUrl,
directMode = pref.directMode,
showSystemMessages = pref.showSystemMessages
)
}
}
}
fun updateDisplayName(value: String) {
val displayName = value.take(64)
_uiState.update { it.copy(displayName = displayName) }
viewModelScope.launch {
preferencesRepository.setDisplayName(displayName)
}
}
fun updateServerUrl(value: String) {
_uiState.update { it.copy(serverUrl = value) }
}
fun updateTargetKey(value: String) {
_uiState.update { it.copy(targetKey = value) }
}
fun updateDraft(value: String) {
_uiState.update { it.copy(draft = value) }
}
fun toggleDirectMode(enabled: Boolean) {
_uiState.update { it.copy(directMode = enabled) }
viewModelScope.launch {
preferencesRepository.setDirectMode(enabled)
}
}
fun toggleShowSystemMessages(show: Boolean) {
_uiState.update { it.copy(showSystemMessages = show) }
viewModelScope.launch {
preferencesRepository.setShowSystemMessages(show)
}
}
fun clearMessages() {
cancelSystemMessageExpiryJobs()
_uiState.update { it.copy(messages = emptyList()) }
}
fun saveCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) {
viewModelScope.launch {
_events.emit(UiEvent.ShowSnackbar("请输入有效的服务器地址"))
}
return
}
val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
_uiState.update {
it.copy(
serverUrl = normalized,
serverUrls = nextUrls,
statusHint = "服务器地址已保存"
)
}
viewModelScope.launch {
preferencesRepository.saveCurrentServerUrl(normalized)
_events.emit(UiEvent.ShowSnackbar("服务器地址已保存"))
}
}
fun removeCurrentServerUrl() {
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
if (normalized.isBlank()) return
val filtered = _uiState.value.serverUrls.filterNot { it == normalized }
val nextUrls = if (filtered.isEmpty()) {
listOf(ServerUrlFormatter.defaultServerUrl)
} else {
filtered
}
_uiState.update {
it.copy(
serverUrls = nextUrls,
serverUrl = nextUrls.first(),
statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址"
)
}
viewModelScope.launch {
preferencesRepository.removeCurrentServerUrl(normalized)
_events.emit(UiEvent.ShowSnackbar("已更新服务器地址列表"))
}
}
fun revealMyPublicKey() {
viewModelScope.launch {
_uiState.update { it.copy(loadingPublicKey = true) }
runCatching {
ensureIdentity()
}.onSuccess { id ->
_uiState.update {
it.copy(
myPublicKey = id.publicKeyBase64,
loadingPublicKey = false
)
}
}.onFailure { error ->
_uiState.update { it.copy(loadingPublicKey = false) }
_events.emit(UiEvent.ShowSnackbar("公钥读取失败:${error.message ?: "unknown"}"))
}
}
}
fun connect() {
val state = _uiState.value
if (!state.canConnect) return
val normalized = ServerUrlFormatter.normalize(state.serverUrl)
if (normalized.isBlank()) {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "请填写有效服务器地址"
)
}
return
}
manualClose = false
fallbackTried = false
connectedUrl = normalized
serverPublicKey = ""
cancelReconnect()
reconnectAttempt = 0
cancelHelloTimeout()
cancelAuthTimeout()
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
statusHint = "正在连接服务器...",
serverUrl = normalized,
certFingerprint = ""
)
}
viewModelScope.launch {
preferencesRepository.setCurrentServerUrl(normalized)
}
socketClient.connect(normalized, socketListener)
}
fun disconnect() {
manualClose = true
cancelReconnect()
cancelHelloTimeout()
cancelAuthTimeout()
socketClient.close(1000, "manual_close")
_uiState.update {
it.copy(
status = ConnectionStatus.IDLE,
statusHint = "连接已关闭"
)
}
addSystemMessage("已断开连接")
}
fun sendMessage() {
val current = _uiState.value
if (!current.canSend) return
viewModelScope.launch {
val text = _uiState.value.draft.trim()
if (text.isBlank()) return@launch
val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else ""
if (_uiState.value.directMode && key.isBlank()) {
_uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") }
return@launch
}
val type = if (key.isBlank()) "broadcast" else "forward"
val channel = if (key.isBlank()) MessageChannel.BROADCAST else MessageChannel.PRIVATE
val subtitle = if (key.isBlank()) "" else "私聊 ${summarizeKey(key)}"
_uiState.update { it.copy(sending = true) }
runCatching {
val id = ensureIdentity()
val timestamp = cryptoManager.unixSecondsNow()
val nonce = cryptoManager.createNonce()
val signingInput = listOf(type, key, text, timestamp.toString(), nonce).joinToString("\n")
val signature = withContext(Dispatchers.Default) {
cryptoManager.signText(id.privateKey, signingInput)
}
val payload = SignedPayloadDto(
payload = text,
timestamp = timestamp,
nonce = nonce,
signature = signature
)
val envelope = EnvelopeDto(
type = type,
key = key,
data = json.encodeToJsonElement(payload)
)
val plain = json.encodeToString(envelope)
val cipher = withContext(Dispatchers.Default) {
cryptoManager.encryptChunked(serverPublicKey, plain)
}
check(socketClient.send(cipher)) { "连接不可用" }
}.onSuccess {
addOutgoingMessage(text, subtitle, channel)
_uiState.update { it.copy(draft = "", sending = false) }
}.onFailure { error ->
_uiState.update { it.copy(sending = false) }
addSystemMessage("发送失败:${error.message ?: "unknown"}")
}
}
}
fun onMessageCopied() {
viewModelScope.launch {
_events.emit(UiEvent.ShowSnackbar("已复制"))
}
}
private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
return identityMutex.withLock {
identity ?: withContext(Dispatchers.Default) {
cryptoManager.getOrCreateIdentity()
}.also { created ->
identity = created
}
}
}
private suspend fun handleIncomingMessage(rawText: String) {
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
_uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
}
val normalizedText = extractJsonCandidate(rawText)
val rootObject = runCatching {
json.decodeFromString<JsonElement>(normalizedText) as? JsonObject
}.getOrNull()
// 兼容某些代理/中间层直接转发 hello data 对象(没有 envelope 外层)
val directHello = rootObject?.let { obj ->
val hasPublicKey = obj["publicKey"] != null
val hasChallenge = obj["authChallenge"] != null
if (hasPublicKey && hasChallenge) {
runCatching { json.decodeFromJsonElement<HelloDataDto>(obj) }.getOrNull()
} else {
null
}
}
if (directHello != null) {
cancelHelloTimeout()
handleServerHello(directHello)
return
}
val plain = runCatching { json.decodeFromString<EnvelopeDto>(normalizedText) }.getOrNull()
if (plain?.type == "publickey") {
cancelHelloTimeout()
val hello = plain.data?.let {
runCatching { json.decodeFromJsonElement<HelloDataDto>(it) }.getOrNull()
}
if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "握手失败:服务端响应不完整"
)
}
return
}
handleServerHello(hello)
return
}
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
_uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
} else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) {
val preview = rawText
.replace("\n", " ")
.replace("\r", " ")
.take(80)
_uiState.update { it.copy(statusHint = "握手失败:首包解析失败") }
addSystemMessage("握手包解析失败:$preview")
}
val id = ensureIdentity()
val decrypted = runCatching {
withContext(Dispatchers.Default) {
cryptoManager.decryptChunked(id.privateKey, normalizedText)
}
}.getOrElse {
addSystemMessage("收到无法解密的消息")
return
}
val secure = runCatching {
json.decodeFromString<EnvelopeDto>(decrypted)
}.getOrNull() ?: return
handleSecureMessage(secure)
}
private suspend fun handleServerHello(hello: HelloDataDto) {
cancelHelloTimeout()
serverPublicKey = hello.publicKey
_uiState.update {
it.copy(
status = ConnectionStatus.AUTHENTICATING,
statusHint = "正在完成身份验证...",
certFingerprint = hello.certFingerprintSha256.orEmpty()
)
}
cancelAuthTimeout()
authTimeoutJob = viewModelScope.launch {
delay(AUTH_TIMEOUT_MS)
if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "连接超时,请重试"
)
}
addSystemMessage("认证超时,请检查网络后重试")
socketClient.close(1000, "auth_timeout")
}
}
runCatching {
sendAuth(hello.authChallenge)
}.onSuccess {
addSystemMessage("已发送认证请求")
}.onFailure { error ->
cancelAuthTimeout()
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "认证失败"
)
}
addSystemMessage("认证发送失败:${error.message ?: "unknown"}")
socketClient.close(1000, "auth_failed")
}
}
private suspend fun sendAuth(challenge: String) {
val id = ensureIdentity()
val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
if (displayName != _uiState.value.displayName) {
_uiState.update { it.copy(displayName = displayName) }
preferencesRepository.setDisplayName(displayName)
}
val timestamp = cryptoManager.unixSecondsNow()
val nonce = cryptoManager.createNonce()
val signingInput = listOf(
"publickey",
displayName,
id.publicKeyBase64,
challenge,
timestamp.toString(),
nonce
).joinToString("\n")
val signature = withContext(Dispatchers.Default) {
cryptoManager.signText(id.privateKey, signingInput)
}
val payload = AuthPayloadDto(
publicKey = id.publicKeyBase64,
challenge = challenge,
timestamp = timestamp,
nonce = nonce,
signature = signature
)
val envelope = EnvelopeDto(
type = "publickey",
key = displayName,
data = json.encodeToJsonElement(payload)
)
val plain = json.encodeToString(envelope)
val cipher = withContext(Dispatchers.Default) {
cryptoManager.encryptChunked(serverPublicKey, plain)
}
check(socketClient.send(cipher)) { "连接不可用" }
}
private fun handleSecureMessage(message: EnvelopeDto) {
when (message.type) {
"auth_ok" -> {
cancelAuthTimeout()
cancelReconnect()
reconnectAttempt = 0
_uiState.update {
it.copy(
status = ConnectionStatus.READY,
statusHint = "已连接,可以开始聊天"
)
}
addSystemMessage("连接准备完成")
}
"broadcast" -> {
val sender = message.key?.takeIf { it.isNotBlank() } ?: "匿名用户"
addIncomingMessage(
sender = sender,
subtitle = "",
content = message.data.asPayloadText(),
channel = MessageChannel.BROADCAST
)
}
"forward" -> {
val sourceKey = message.key.orEmpty()
addIncomingMessage(
sender = "私聊消息",
subtitle = sourceKey.takeIf { it.isNotBlank() }?.let { "来自 ${summarizeKey(it)}" }.orEmpty(),
content = message.data.asPayloadText(),
channel = MessageChannel.PRIVATE
)
}
else -> addSystemMessage("收到未识别消息类型:${message.type}")
}
}
private fun handleSocketClosed(code: Int, reason: String) {
cancelHelloTimeout()
cancelAuthTimeout()
if (manualClose) {
return
}
if (reconnectJob?.isActive == true) {
return
}
val currentStatus = _uiState.value.status
if (currentStatus == ConnectionStatus.READY) {
addSystemMessage("连接关闭 ($code)${reason.ifBlank { "连接中断" }}")
scheduleReconnect("连接已中断")
return
}
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
if (allowFallback) {
val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
if (fallbackUrl.isNotBlank()) {
fallbackTried = true
connectedUrl = fallbackUrl
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
statusHint = "正在自动重试连接...",
serverUrl = fallbackUrl
)
}
addSystemMessage("连接方式切换中,正在重试")
socketClient.connect(fallbackUrl, socketListener)
return
}
}
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "连接已中断,请检查网络或服务器地址"
)
}
addSystemMessage("连接关闭 ($code)${reason.ifBlank { "连接中断" }}")
}
private fun addSystemMessage(content: String) {
val message = UiMessage(
role = MessageRole.SYSTEM,
sender = "系统",
subtitle = "",
content = content,
channel = MessageChannel.BROADCAST
)
appendMessage(message)
scheduleSystemMessageExpiry(message.id)
}
private fun addIncomingMessage(
sender: String,
subtitle: String,
content: String,
channel: MessageChannel
) {
appendMessage(
UiMessage(
role = MessageRole.INCOMING,
sender = sender,
subtitle = subtitle,
content = content,
channel = channel
)
)
}
private fun addOutgoingMessage(
content: String,
subtitle: String,
channel: MessageChannel
) {
appendMessage(
UiMessage(
role = MessageRole.OUTGOING,
sender = "",
subtitle = subtitle,
content = content,
channel = channel
)
)
}
private fun appendMessage(message: UiMessage) {
_uiState.update { current ->
val next = (current.messages + message).takeLast(MAX_MESSAGES)
val aliveIds = next.asSequence().map { it.id }.toSet()
val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds }
removedIds.forEach { id ->
systemMessageExpiryJobs.remove(id)?.cancel()
}
current.copy(messages = next)
}
}
private fun cancelAuthTimeout() {
authTimeoutJob?.cancel()
authTimeoutJob = null
}
private fun scheduleReconnect(reason: String) {
if (manualClose) return
if (reconnectJob?.isActive == true) return
if (reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "重连失败,请手动重试"
)
}
addSystemMessage("自动重连已停止:超过最大重试次数")
return
}
reconnectAttempt += 1
val delaySeconds = minOf(30, 1 shl (reconnectAttempt - 1))
val total = MAX_RECONNECT_ATTEMPTS
addSystemMessage("$reason${delaySeconds}s 后自动重连($reconnectAttempt/$total")
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "${delaySeconds}s 后自动重连($reconnectAttempt/$total"
)
}
reconnectJob = viewModelScope.launch {
delay(delaySeconds * 1000L)
if (manualClose) return@launch
val target = ServerUrlFormatter.normalize(connectedUrl).ifBlank {
ServerUrlFormatter.normalize(_uiState.value.serverUrl)
}
if (target.isBlank()) {
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "重连失败:服务器地址无效"
)
}
return@launch
}
fallbackTried = false
serverPublicKey = ""
connectedUrl = target
cancelHelloTimeout()
cancelAuthTimeout()
_uiState.update {
it.copy(
status = ConnectionStatus.CONNECTING,
statusHint = "正在自动重连..."
)
}
socketClient.connect(target, socketListener)
}
}
private fun cancelReconnect() {
reconnectJob?.cancel()
reconnectJob = null
}
private fun scheduleSystemMessageExpiry(messageId: String) {
systemMessageExpiryJobs.remove(messageId)?.cancel()
systemMessageExpiryJobs[messageId] = viewModelScope.launch {
delay(SYSTEM_MESSAGE_TTL_MS)
_uiState.update { current ->
val filtered = current.messages.filterNot { it.id == messageId }
current.copy(messages = filtered)
}
systemMessageExpiryJobs.remove(messageId)
}
}
private fun cancelSystemMessageExpiryJobs() {
systemMessageExpiryJobs.values.forEach { it.cancel() }
systemMessageExpiryJobs.clear()
}
private fun startHelloTimeout() {
cancelHelloTimeout()
helloTimeoutJob = viewModelScope.launch {
delay(HELLO_TIMEOUT_MS)
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
val currentUrl = connectedUrl.ifBlank { "unknown" }
_uiState.update {
it.copy(
status = ConnectionStatus.ERROR,
statusHint = "握手超时,请检查地址路径与反向代理"
)
}
addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl")
socketClient.close(1000, "hello_timeout")
}
}
}
private fun cancelHelloTimeout() {
helloTimeoutJob?.cancel()
helloTimeoutJob = null
}
private fun summarizeKey(key: String): String {
if (key.length <= 16) return key
return "${key.take(8)}...${key.takeLast(8)}"
}
private fun createGuestName(): String {
val rand = (100000..999999).random()
return "guest-$rand"
}
private fun extractJsonCandidate(rawText: String): String {
val trimmed = rawText.trim()
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed
}
val start = rawText.indexOf('{')
val end = rawText.lastIndexOf('}')
return if (start in 0 until end) {
rawText.substring(start, end + 1)
} else {
rawText
}
}
override fun onCleared() {
super.onCleared()
cancelSystemMessageExpiryJobs()
cancelReconnect()
cancelHelloTimeout()
cancelAuthTimeout()
socketClient.shutdown()
}
private companion object {
const val HELLO_TIMEOUT_MS = 12_000L
const val AUTH_TIMEOUT_MS = 20_000L
const val MAX_MESSAGES = 500
const val MAX_RECONNECT_ATTEMPTS = 5
const val SYSTEM_MESSAGE_TTL_MS = 1_000L
}
}

@ -0,0 +1,10 @@
package com.onlinemsg.client.ui.theme
import androidx.compose.ui.graphics.Color
val Primary = Color(0xFF0C6D62)
val OnPrimary = Color(0xFFFFFFFF)
val Secondary = Color(0xFF4A635F)
val Surface = Color(0xFFF7FAF8)
val SurfaceVariant = Color(0xFFDCE8E4)
val Error = Color(0xFFB3261E)

@ -0,0 +1,49 @@
package com.onlinemsg.client.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme(
primary = Primary,
onPrimary = OnPrimary,
secondary = Secondary,
surface = Surface,
surfaceVariant = SurfaceVariant,
error = Error
)
private val DarkColors = darkColorScheme(
primary = Primary.copy(alpha = 0.9f),
secondary = Secondary.copy(alpha = 0.9f),
error = Error
)
@Composable
fun OnlineMsgTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}

@ -0,0 +1,5 @@
package com.onlinemsg.client.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()

@ -0,0 +1,3 @@
<resources>
<string name="app_name">OnlineMsg</string>
</resources>

@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.OnlineMsg" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style>
</resources>

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content />

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup />
<device-transfer />
</data-extraction-rules>

@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
}

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "OnlineMsgAndroidClient"
include(":app")

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
# One-click deployment for test environments (WS mode, no forced WSS).
#
# Usage:
# bash deploy/deploy_test_ws.sh
#
# Optional overrides:
# CONTAINER_NAME=onlinemsgserver IMAGE_NAME=onlinemsgserver:latest HOST_PORT=13173 bash deploy/deploy_test_ws.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTAINER_NAME="${CONTAINER_NAME:-onlinemsgserver}"
IMAGE_NAME="${IMAGE_NAME:-onlinemsgserver:latest}"
HOST_PORT="${HOST_PORT:-13173}"
for cmd in openssl docker ipconfig route awk base64 tr; do
if ! command -v "${cmd}" >/dev/null 2>&1; then
echo "Missing required command: ${cmd}"
exit 1
fi
done
DEFAULT_IFACE="$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
LAN_IP=""
if [ -n "${DEFAULT_IFACE}" ]; then
LAN_IP="$(ipconfig getifaddr "${DEFAULT_IFACE}" 2>/dev/null || true)"
fi
if [ -z "${LAN_IP}" ]; then
LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || true)"
fi
if [ -z "${LAN_IP}" ]; then
LAN_IP="$(ipconfig getifaddr en1 2>/dev/null || true)"
fi
if [ -z "${LAN_IP}" ]; then
LAN_IP="127.0.0.1"
fi
mkdir -p "${ROOT_DIR}/deploy/keys"
# Generate service RSA key only if missing.
if [ ! -f "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64" ]; then
echo "Generating service RSA key..."
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${ROOT_DIR}/deploy/keys/server_rsa.pem"
openssl pkcs8 -topk8 -inform PEM \
-in "${ROOT_DIR}/deploy/keys/server_rsa.pem" \
-outform DER -nocrypt \
-out "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der"
base64 < "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der" | tr -d '\n' > "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64"
fi
echo "Building image: ${IMAGE_NAME}"
docker build -t "${IMAGE_NAME}" "${ROOT_DIR}"
echo "Restarting container: ${CONTAINER_NAME}"
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \
-p "${HOST_PORT}:13173" \
-v "${ROOT_DIR}/deploy/keys:/app/keys:ro" \
-e REQUIRE_WSS=false \
-e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \
"${IMAGE_NAME}"
echo "Container logs (tail 30):"
docker logs --tail 30 "${CONTAINER_NAME}"
echo
echo "Done."
echo "Frontend URL (LAN): ws://${LAN_IP}:${HOST_PORT}/"
echo "Frontend URL (local): ws://localhost:${HOST_PORT}/"

@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
# Prepare production deployment artifacts:
# 1) Prepare TLS certificate (import existing PEM cert/key, or optional self-signed fallback)
# 2) Prepare server RSA key (for message protocol)
# 3) Build production Docker image
# 4) Export image tar + env template for deployment
#
# Usage examples:
# # Recommended: use real certificate files
# DOMAIN=chat.example.com \
# TLS_CERT_PEM=/path/fullchain.pem \
# TLS_KEY_PEM=/path/privkey.pem \
# TLS_CHAIN_PEM=/path/chain.pem \
# CERT_PASSWORD='change-me' \
# bash deploy/prepare_prod_release.sh
#
# # Fallback (not for internet production): generate self-signed cert
# DOMAIN=chat.example.com \
# SAN_LIST='DNS:www.chat.example.com,IP:10.0.0.8' \
# GENERATE_SELF_SIGNED=true \
# CERT_PASSWORD='change-me' \
# bash deploy/prepare_prod_release.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
DOMAIN="${DOMAIN:-}"
SAN_LIST="${SAN_LIST:-}"
GENERATE_SELF_SIGNED="${GENERATE_SELF_SIGNED:-false}"
CERT_DAYS="${CERT_DAYS:-365}"
TLS_CERT_PEM="${TLS_CERT_PEM:-}"
TLS_KEY_PEM="${TLS_KEY_PEM:-}"
TLS_CHAIN_PEM="${TLS_CHAIN_PEM:-}"
CERT_PASSWORD="${CERT_PASSWORD:-changeit}"
IMAGE_NAME="${IMAGE_NAME:-onlinemsgserver}"
IMAGE_TAG="${IMAGE_TAG:-prod}"
IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}"
EXPORT_IMAGE_TAR="${EXPORT_IMAGE_TAR:-true}"
OUTPUT_DIR="${OUTPUT_DIR:-${ROOT_DIR}/deploy/output/prod}"
CERT_DIR="${ROOT_DIR}/deploy/certs"
KEY_DIR="${ROOT_DIR}/deploy/keys"
for cmd in openssl docker base64 tr; do
if ! command -v "${cmd}" >/dev/null 2>&1; then
echo "Missing required command: ${cmd}"
exit 1
fi
done
mkdir -p "${OUTPUT_DIR}" "${CERT_DIR}" "${KEY_DIR}"
if [ -z "${DOMAIN}" ]; then
echo "DOMAIN is required. Example: DOMAIN=chat.example.com"
exit 1
fi
echo "Preparing production artifacts for domain: ${DOMAIN}"
# 1) Prepare service RSA key for protocol
if [ ! -f "${KEY_DIR}/server_rsa_pkcs8.b64" ]; then
echo "Generating protocol RSA key..."
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${KEY_DIR}/server_rsa.pem"
openssl pkcs8 -topk8 -inform PEM \
-in "${KEY_DIR}/server_rsa.pem" \
-outform DER -nocrypt \
-out "${KEY_DIR}/server_rsa_pkcs8.der"
base64 < "${KEY_DIR}/server_rsa_pkcs8.der" | tr -d '\n' > "${KEY_DIR}/server_rsa_pkcs8.b64"
else
echo "Protocol RSA key already exists, reusing: ${KEY_DIR}/server_rsa_pkcs8.b64"
fi
# 2) Prepare TLS material
TMP_CERT="${OUTPUT_DIR}/tls.crt"
TMP_KEY="${OUTPUT_DIR}/tls.key"
TMP_CHAIN="${OUTPUT_DIR}/chain.crt"
TMP_FULLCHAIN="${OUTPUT_DIR}/fullchain.crt"
if [ -n "${TLS_CERT_PEM}" ] || [ -n "${TLS_KEY_PEM}" ]; then
if [ -z "${TLS_CERT_PEM}" ] || [ -z "${TLS_KEY_PEM}" ]; then
echo "TLS_CERT_PEM and TLS_KEY_PEM must be set together."
exit 1
fi
if [ ! -f "${TLS_CERT_PEM}" ] || [ ! -f "${TLS_KEY_PEM}" ]; then
echo "TLS cert/key file not found."
exit 1
fi
echo "Using provided TLS certificate files..."
cp "${TLS_CERT_PEM}" "${TMP_CERT}"
cp "${TLS_KEY_PEM}" "${TMP_KEY}"
if [ -n "${TLS_CHAIN_PEM}" ]; then
if [ ! -f "${TLS_CHAIN_PEM}" ]; then
echo "TLS_CHAIN_PEM file not found."
exit 1
fi
cp "${TLS_CHAIN_PEM}" "${TMP_CHAIN}"
cat "${TMP_CERT}" "${TMP_CHAIN}" > "${TMP_FULLCHAIN}"
else
cp "${TMP_CERT}" "${TMP_FULLCHAIN}"
fi
else
if [ "${GENERATE_SELF_SIGNED}" != "true" ]; then
echo "No TLS_CERT_PEM/TLS_KEY_PEM provided."
echo "Provide real cert files, or set GENERATE_SELF_SIGNED=true as fallback."
exit 1
fi
echo "WARNING: generating self-signed TLS certificate (not recommended for internet production)."
SAN_VALUE="DNS:${DOMAIN}"
if [ -n "${SAN_LIST}" ]; then
SAN_VALUE="${SAN_VALUE},${SAN_LIST}"
fi
openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days "${CERT_DAYS}" \
-subj "/CN=${DOMAIN}" \
-addext "subjectAltName=${SAN_VALUE}" \
-keyout "${TMP_KEY}" \
-out "${TMP_CERT}"
cp "${TMP_CERT}" "${TMP_FULLCHAIN}"
fi
echo "Building PFX for server runtime..."
openssl pkcs12 -export \
-inkey "${TMP_KEY}" \
-in "${TMP_FULLCHAIN}" \
-out "${CERT_DIR}/server.pfx" \
-passout "pass:${CERT_PASSWORD}"
cp "${TMP_CERT}" "${CERT_DIR}/tls.crt"
cp "${TMP_KEY}" "${CERT_DIR}/tls.key"
# 3) Build production image
echo "Building image: ${IMAGE_REF}"
docker build -t "${IMAGE_REF}" "${ROOT_DIR}"
# 4) Export artifacts
if [ "${EXPORT_IMAGE_TAR}" = "true" ]; then
TAR_NAME="${IMAGE_NAME//\//_}_${IMAGE_TAG}.tar"
echo "Exporting image tar: ${OUTPUT_DIR}/${TAR_NAME}"
docker save "${IMAGE_REF}" -o "${OUTPUT_DIR}/${TAR_NAME}"
fi
cat > "${OUTPUT_DIR}/prod.env" <<EOF
REQUIRE_WSS=true
TLS_CERT_PATH=/app/certs/server.pfx
TLS_CERT_PASSWORD=${CERT_PASSWORD}
SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64
MAX_CONNECTIONS=1000
MAX_MESSAGE_BYTES=65536
RATE_LIMIT_COUNT=30
RATE_LIMIT_WINDOW_SECONDS=10
IP_BLOCK_SECONDS=120
CHALLENGE_TTL_SECONDS=120
MAX_CLOCK_SKEW_SECONDS=60
REPLAY_WINDOW_SECONDS=120
EOF
cat > "${OUTPUT_DIR}/run_prod_example.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# Example production run command:
# 1) copy deploy/certs + deploy/keys to deployment host
# 2) import image tar: docker load -i onlinemsgserver_prod.tar
# 3) run with env file:
#
# docker run -d --name onlinemsgserver --restart unless-stopped \
# -p 13173:13173 \
# --env-file /path/prod.env \
# -v /path/certs:/app/certs:ro \
# -v /path/keys:/app/keys:ro \
# onlinemsgserver:prod
EOF
chmod +x "${OUTPUT_DIR}/run_prod_example.sh"
echo
echo "Done."
echo "Artifacts:"
echo "- TLS runtime cert: ${CERT_DIR}/server.pfx"
echo "- TLS PEM cert/key: ${CERT_DIR}/tls.crt , ${CERT_DIR}/tls.key"
echo "- Protocol RSA key: ${KEY_DIR}/server_rsa_pkcs8.b64"
echo "- Deployment env: ${OUTPUT_DIR}/prod.env"
if [ "${EXPORT_IMAGE_TAR}" = "true" ]; then
echo "- Image tar: ${OUTPUT_DIR}/${IMAGE_NAME//\//_}_${IMAGE_TAG}.tar"
fi

@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage:
# bash deploy/redeploy_with_lan_cert.sh
#
# Optional overrides:
# CONTAINER_NAME=onlinemsgserver IMAGE_NAME=onlinemsgserver:latest CERT_PASSWORD=changeit bash deploy/redeploy_with_lan_cert.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTAINER_NAME="${CONTAINER_NAME:-onlinemsgserver}"
IMAGE_NAME="${IMAGE_NAME:-onlinemsgserver:latest}"
CERT_PASSWORD="${CERT_PASSWORD:-changeit}"
for cmd in openssl docker ipconfig route awk base64 tr; do
if ! command -v "${cmd}" >/dev/null 2>&1; then
echo "Missing required command: ${cmd}"
exit 1
fi
done
DEFAULT_IFACE="$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
LAN_IP=""
if [ -n "${DEFAULT_IFACE}" ]; then
LAN_IP="$(ipconfig getifaddr "${DEFAULT_IFACE}" 2>/dev/null || true)"
fi
if [ -z "${LAN_IP}" ]; then
LAN_IP="$(ipconfig getifaddr en0 2>/dev/null || true)"
fi
if [ -z "${LAN_IP}" ]; then
LAN_IP="$(ipconfig getifaddr en1 2>/dev/null || true)"
fi
if [ -z "${LAN_IP}" ]; then
echo "Failed to detect LAN IP from default interface/en0/en1."
exit 1
fi
echo "LAN IP: ${LAN_IP}"
mkdir -p "${ROOT_DIR}/deploy/certs" "${ROOT_DIR}/deploy/keys"
# Generate service RSA key only if missing.
if [ ! -f "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64" ]; then
echo "Generating service RSA key..."
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${ROOT_DIR}/deploy/keys/server_rsa.pem"
openssl pkcs8 -topk8 -inform PEM \
-in "${ROOT_DIR}/deploy/keys/server_rsa.pem" \
-outform DER -nocrypt \
-out "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der"
base64 < "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.der" | tr -d '\n' > "${ROOT_DIR}/deploy/keys/server_rsa_pkcs8.b64"
fi
echo "Reissuing TLS certificate with LAN SAN..."
openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 365 \
-subj "/CN=${LAN_IP}" \
-addext "subjectAltName=IP:${LAN_IP},IP:127.0.0.1,DNS:localhost" \
-keyout "${ROOT_DIR}/deploy/certs/tls.key" \
-out "${ROOT_DIR}/deploy/certs/tls.crt"
openssl pkcs12 -export \
-inkey "${ROOT_DIR}/deploy/certs/tls.key" \
-in "${ROOT_DIR}/deploy/certs/tls.crt" \
-out "${ROOT_DIR}/deploy/certs/server.pfx" \
-passout "pass:${CERT_PASSWORD}"
echo "Rebuilding image: ${IMAGE_NAME}"
docker build -t "${IMAGE_NAME}" "${ROOT_DIR}"
echo "Restarting container: ${CONTAINER_NAME}"
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \
-p 13173:13173 \
-v "${ROOT_DIR}/deploy/certs:/app/certs:ro" \
-v "${ROOT_DIR}/deploy/keys:/app/keys:ro" \
-e REQUIRE_WSS=true \
-e TLS_CERT_PATH=/app/certs/server.pfx \
-e TLS_CERT_PASSWORD="${CERT_PASSWORD}" \
-e SERVER_PRIVATE_KEY_PATH=/app/keys/server_rsa_pkcs8.b64 \
"${IMAGE_NAME}"
echo "Container logs (tail 30):"
docker logs --tail 30 "${CONTAINER_NAME}"
echo
echo "Done."
echo "Use this URL in frontend: wss://${LAN_IP}:13173/"
echo "If using self-signed cert, trust deploy/certs/tls.crt on client devices."

@ -0,0 +1,47 @@
# OnlineMsg Web Client
React 前端客户端,适配当前仓库的加密消息协议,默认隐藏连接细节,仅保留聊天交互。
## 开发运行
```bash
cd web-client
npm install
npm run dev
```
默认地址:`http://localhost:5173`
## 生产构建
```bash
cd web-client
npm install
npm run build
npm run preview
```
构建产物输出目录:`web-client/dist`
## 使用说明
- 打开页面后点击“连接”
- 默认会自动使用当前主机名拼接:
- `http` 页面下:`ws://<host>:13173/`
- `https` 页面下:`wss://<host>:13173/`
- 若你手动输入 `ws://`,前端会自动尝试升级到 `wss://` 一次
- 如需手动指定服务器地址,在“高级连接设置”中填写,例如:
- `wss://example.com:13173/`
- `ws://127.0.0.1:13173/`(仅本地调试)
- “目标公钥”留空为广播,填写后为私聊转发
- 用户名会自动保存在本地,刷新后继续使用
- 客户端私钥会保存在本地浏览器(用于持续身份),刷新后不会重复生成
## 移动端注意事项
- 客户端已支持两套加密实现:
- 优先 `WebCrypto`(性能更好)
- 退化到纯 JS `node-forge`(适配部分 `http` 局域网场景)
- 在纯 JS 加密模式下,首次连接可能需要几秒生成密钥;客户端会复用本地缓存密钥以减少后续等待。
- 若设备浏览器过旧,仍可能无法完成加密初始化,此时会在页面提示具体原因。
- 生产环境仍建议使用 `https/wss`

@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>OnlineMsg Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,20 @@
{
"name": "online-msg-web-client",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"node-forge": "^1.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.10"
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,348 @@
import forge from "node-forge";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const subtlePublicKeyCache = new Map();
const forgePublicKeyCache = new Map();
const STORAGE_KEYS = {
subtlePrivatePkcs8B64: "oms_subtle_private_pkcs8_b64",
subtlePublicSpkiB64: "oms_subtle_public_spki_b64",
forgePrivatePem: "oms_forge_private_pem",
forgePublicSpkiB64: "oms_forge_public_spki_b64"
};
function hasWebCryptoSubtle() {
return Boolean(globalThis.crypto?.subtle);
}
function toBase64(bytes) {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function fromBase64(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bytesToBinaryString(bytes) {
let result = "";
for (let i = 0; i < bytes.length; i += 1) {
result += String.fromCharCode(bytes[i]);
}
return result;
}
function binaryStringToBytes(binary) {
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function concatChunks(chunks) {
const totalLength = chunks.reduce((sum, item) => sum + item.length, 0);
const output = new Uint8Array(totalLength);
let offset = 0;
chunks.forEach((item) => {
output.set(item, offset);
offset += item.length;
});
return output;
}
async function importRsaOaepPublicKeySubtle(publicKeyBase64) {
if (subtlePublicKeyCache.has(publicKeyBase64)) {
return subtlePublicKeyCache.get(publicKeyBase64);
}
const key = await globalThis.crypto.subtle.importKey(
"spki",
fromBase64(publicKeyBase64),
{
name: "RSA-OAEP",
hash: "SHA-256"
},
false,
["encrypt"]
);
subtlePublicKeyCache.set(publicKeyBase64, key);
return key;
}
function importForgePublicKey(publicKeyBase64) {
if (forgePublicKeyCache.has(publicKeyBase64)) {
return forgePublicKeyCache.get(publicKeyBase64);
}
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(atob(publicKeyBase64)));
const key = forge.pki.publicKeyFromAsn1(asn1);
forgePublicKeyCache.set(publicKeyBase64, key);
return key;
}
function generateForgeKeyPair() {
return Promise.resolve().then(() => forge.pki.rsa.generateKeyPair({ bits: 2048, workers: 0, e: 0x10001 }));
}
function clearSubtleIdentityFromStorage() {
try {
globalThis.localStorage?.removeItem(STORAGE_KEYS.subtlePrivatePkcs8B64);
globalThis.localStorage?.removeItem(STORAGE_KEYS.subtlePublicSpkiB64);
} catch {
// ignore storage failures in private mode
}
}
function saveSubtleIdentityToStorage(privateKeyRaw, publicKeyRaw) {
try {
globalThis.localStorage?.setItem(STORAGE_KEYS.subtlePrivatePkcs8B64, toBase64(new Uint8Array(privateKeyRaw)));
globalThis.localStorage?.setItem(STORAGE_KEYS.subtlePublicSpkiB64, toBase64(new Uint8Array(publicKeyRaw)));
} catch {
// ignore storage failures in private mode
}
}
async function loadSubtleIdentityFromStorage() {
try {
const privatePkcs8B64 = globalThis.localStorage?.getItem(STORAGE_KEYS.subtlePrivatePkcs8B64);
const publicSpkiB64 = globalThis.localStorage?.getItem(STORAGE_KEYS.subtlePublicSpkiB64);
if (!privatePkcs8B64 || !publicSpkiB64) {
return null;
}
const privateKeyRaw = fromBase64(privatePkcs8B64);
const signPrivateKey = await globalThis.crypto.subtle.importKey(
"pkcs8",
privateKeyRaw,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256"
},
false,
["sign"]
);
const decryptPrivateKey = await globalThis.crypto.subtle.importKey(
"pkcs8",
privateKeyRaw,
{
name: "RSA-OAEP",
hash: "SHA-256"
},
false,
["decrypt"]
);
return {
provider: "subtle",
publicKeyBase64: publicSpkiB64,
signPrivateKey,
decryptPrivateKey
};
} catch {
clearSubtleIdentityFromStorage();
return null;
}
}
function loadForgeIdentityFromStorage() {
try {
const privatePem = globalThis.localStorage?.getItem(STORAGE_KEYS.forgePrivatePem);
const publicSpkiB64 = globalThis.localStorage?.getItem(STORAGE_KEYS.forgePublicSpkiB64);
if (!privatePem || !publicSpkiB64) {
return null;
}
const privateKey = forge.pki.privateKeyFromPem(privatePem);
return {
provider: "forge",
publicKeyBase64: publicSpkiB64,
signPrivateKey: privateKey,
decryptPrivateKey: privateKey
};
} catch {
return null;
}
}
function saveForgeIdentityToStorage(privateKey, publicKeyBase64) {
try {
const privatePem = forge.pki.privateKeyToPem(privateKey);
globalThis.localStorage?.setItem(STORAGE_KEYS.forgePrivatePem, privatePem);
globalThis.localStorage?.setItem(STORAGE_KEYS.forgePublicSpkiB64, publicKeyBase64);
} catch {
// ignore storage failures in private mode
}
}
export function canInitializeCrypto() {
return hasWebCryptoSubtle() || Boolean(forge?.pki?.rsa);
}
export async function generateClientIdentity() {
if (hasWebCryptoSubtle()) {
const cached = await loadSubtleIdentityFromStorage();
if (cached) {
return cached;
}
const signingKeyPair = await globalThis.crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256"
},
true,
["sign", "verify"]
);
const publicKeyRaw = await globalThis.crypto.subtle.exportKey("spki", signingKeyPair.publicKey);
const privateKeyRaw = await globalThis.crypto.subtle.exportKey("pkcs8", signingKeyPair.privateKey);
const decryptPrivateKey = await globalThis.crypto.subtle.importKey(
"pkcs8",
privateKeyRaw,
{
name: "RSA-OAEP",
hash: "SHA-256"
},
false,
["decrypt"]
);
saveSubtleIdentityToStorage(privateKeyRaw, publicKeyRaw);
return {
provider: "subtle",
publicKeyBase64: toBase64(new Uint8Array(publicKeyRaw)),
signPrivateKey: signingKeyPair.privateKey,
decryptPrivateKey
};
}
const cached = loadForgeIdentityFromStorage();
if (cached) {
return cached;
}
const keyPair = await generateForgeKeyPair();
const publicPem = forge.pki.publicKeyToPem(keyPair.publicKey);
const publicKeyBase64 = publicPem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(/\s+/g, "");
saveForgeIdentityToStorage(keyPair.privateKey, publicKeyBase64);
return {
provider: "forge",
publicKeyBase64,
signPrivateKey: keyPair.privateKey,
decryptPrivateKey: keyPair.privateKey
};
}
export async function signText(privateKey, text) {
if (hasWebCryptoSubtle() && privateKey?.type === "private") {
const signature = await globalThis.crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
privateKey,
textEncoder.encode(text)
);
return toBase64(new Uint8Array(signature));
}
const md = forge.md.sha256.create();
md.update(text, "utf8");
const signatureBinary = privateKey.sign(md);
return btoa(signatureBinary);
}
export async function rsaEncryptChunked(publicKeyBase64, plainText) {
if (!plainText) {
return "";
}
const srcBytes = textEncoder.encode(plainText);
const blockSize = 190;
if (hasWebCryptoSubtle()) {
const publicKey = await importRsaOaepPublicKeySubtle(publicKeyBase64);
const chunks = [];
for (let i = 0; i < srcBytes.length; i += blockSize) {
const block = srcBytes.slice(i, i + blockSize);
const encrypted = await globalThis.crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, block);
chunks.push(new Uint8Array(encrypted));
}
return toBase64(concatChunks(chunks));
}
const forgePublicKey = importForgePublicKey(publicKeyBase64);
let encryptedBinary = "";
for (let i = 0; i < srcBytes.length; i += blockSize) {
const block = srcBytes.slice(i, i + blockSize);
encryptedBinary += forgePublicKey.encrypt(bytesToBinaryString(block), "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() }
});
}
return btoa(encryptedBinary);
}
export async function rsaDecryptChunked(privateKey, cipherTextBase64) {
if (!cipherTextBase64) {
return "";
}
const secretBytes = fromBase64(cipherTextBase64);
const blockSize = 256;
if (secretBytes.length % blockSize !== 0) {
throw new Error("ciphertext length invalid");
}
if (hasWebCryptoSubtle() && privateKey?.type === "private") {
const chunks = [];
for (let i = 0; i < secretBytes.length; i += blockSize) {
const block = secretBytes.slice(i, i + blockSize);
const decrypted = await globalThis.crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, block);
chunks.push(new Uint8Array(decrypted));
}
return textDecoder.decode(concatChunks(chunks));
}
const cipherBinary = atob(cipherTextBase64);
let plainBinary = "";
for (let i = 0; i < cipherBinary.length; i += blockSize) {
const block = cipherBinary.slice(i, i + blockSize);
plainBinary += privateKey.decrypt(block, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() }
});
}
return textDecoder.decode(binaryStringToBytes(plainBinary));
}
export function createNonce(size = 18) {
if (globalThis.crypto?.getRandomValues) {
const buf = new Uint8Array(size);
globalThis.crypto.getRandomValues(buf);
return toBase64(buf);
}
if (forge?.random?.getBytesSync) {
return btoa(forge.random.getBytesSync(size));
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function unixSecondsNow() {
return Math.floor(Date.now() / 1000);
}

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,915 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap");
:root {
--vv-offset-top: 0px;
--vv-height: 100dvh;
--bg: #f3f7fc;
--bg-accent: #dff4ef;
--card: rgba(255, 255, 255, 0.92);
--card-border: rgba(19, 35, 58, 0.12);
--ink: #1a2c42;
--muted: #5f7084;
--brand: #0f766e;
--brand-strong: #0a5a55;
--accent: #ea580c;
--danger: #be123c;
--shadow: 0 18px 45px rgba(25, 43, 72, 0.14);
--radius-xl: 18px;
--radius-md: 12px;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: "Manrope", "Noto Sans SC", "PingFang SC", sans-serif;
color: var(--ink);
background: radial-gradient(circle at 0% 0%, #edf4ff 0, transparent 45%),
radial-gradient(circle at 100% 100%, #d7f7ee 0, transparent 42%), var(--bg);
overflow: hidden;
}
.page {
width: min(1180px, calc(100% - 28px));
margin: 12px auto;
height: calc(100dvh - 24px);
display: flex;
flex-direction: column;
min-height: 0;
animation: page-in 0.45s ease;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
padding: 6px 2px;
}
.hero-tag {
margin: 0;
color: #0c5f58;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 11px;
opacity: 0.9;
}
.hero h1 {
margin: 6px 0 4px;
font-size: clamp(22px, 3vw, 34px);
line-height: 1.15;
font-weight: 800;
}
.hero-sub {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--card-border);
background: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
}
.status-chip.loading .dot {
background: var(--accent);
}
.status-chip.ok .dot {
background: var(--brand);
}
.status-chip.bad .dot {
background: var(--danger);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
flex: 1;
min-height: 0;
}
.chat-card,
.side-card {
border-radius: var(--radius-xl);
border: 1px solid var(--card-border);
background: var(--card);
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
}
.chat-card {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--card-border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.85));
box-shadow: inset 0 -1px 0 rgba(19, 35, 58, 0.04);
}
.chat-head strong {
display: block;
font-size: 16px;
}
.chat-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 12px;
}
.chat-peer {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.chat-avatar {
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
aspect-ratio: 1 / 1;
flex: 0 0 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 800;
color: #fff;
background: linear-gradient(130deg, var(--brand), #14b8a6);
}
.head-actions {
display: flex;
gap: 8px;
align-items: center;
}
.icon-btn {
padding: 8px;
min-width: 40px;
min-height: 40px;
border-radius: 12px;
}
.icon-svg {
width: 18px;
height: 18px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.btn {
border: 0;
border-radius: var(--radius-md);
font: inherit;
font-size: 13px;
font-weight: 700;
padding: 9px 13px;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease, box-shadow 0.18s ease;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(19, 35, 58, 0.14);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.btn-main {
background: linear-gradient(130deg, var(--brand), var(--brand-strong));
color: #fff;
}
.btn-ghost {
background: #edf3f8;
color: var(--ink);
}
.desktop-only {
display: inline-flex;
}
.message-list {
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
min-height: 0;
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 0% 0%, rgba(15, 118, 110, 0.08), transparent 42%),
linear-gradient(180deg, rgba(248, 252, 255, 0.84), rgba(241, 250, 248, 0.84));
}
.empty-tip {
color: var(--muted);
margin: 20px auto 0;
text-align: center;
border: 1px dashed rgba(95, 112, 132, 0.4);
border-radius: 16px;
padding: 16px 14px;
width: min(460px, calc(100% - 10px));
background: rgba(255, 255, 255, 0.78);
}
.msg {
border-radius: 16px;
padding: 10px 40px 10px 12px;
max-width: 80%;
min-width: 0;
animation: msg-in 0.28s ease;
box-shadow: 0 6px 12px rgba(18, 37, 61, 0.06);
position: relative;
}
.msg.system {
align-self: center;
max-width: min(88%, 520px);
background: rgba(255, 255, 255, 0.92);
color: #50647f;
border: 1px solid #dbe5f1;
border-radius: 999px;
padding: 8px 34px 8px 12px;
}
.msg.incoming {
background: #ffffff;
border: 1px solid #dceaf5;
align-self: flex-start;
border-top-left-radius: 8px;
}
.msg.outgoing {
background: #dcf8c6;
border: 1px solid #bde9a1;
align-self: flex-end;
border-top-right-radius: 8px;
}
.msg-head {
display: flex;
gap: 8px;
align-items: center;
color: var(--muted);
font-size: 12px;
margin-bottom: 2px;
}
.msg-head strong {
color: var(--ink);
font-size: 13px;
}
.msg-head time {
margin-left: auto;
}
.msg p {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.5;
font-size: 14px;
}
.msg-actions {
margin-top: 0;
display: flex;
justify-content: flex-end;
position: absolute;
top: 8px;
right: 8px;
}
.btn-copy {
background: rgba(19, 35, 58, 0.08);
color: #2f4769;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1.2;
min-height: 22px;
box-shadow: none;
}
@media (hover: hover) and (pointer: fine) {
.msg .msg-actions {
opacity: 0;
transition: opacity 0.16s ease;
}
.msg:hover .msg-actions {
opacity: 1;
}
}
.composer {
border-top: 1px solid var(--card-border);
padding: 10px 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
background: rgba(255, 255, 255, 0.96);
box-shadow: inset 0 1px 0 rgba(19, 35, 58, 0.06);
}
.composer-input-wrap {
border: 1px solid rgba(19, 35, 58, 0.18);
border-radius: 15px;
background: #fff;
padding: 6px 10px;
}
.composer textarea {
width: 100%;
resize: none;
border: 0;
box-shadow: none;
padding: 4px 2px;
min-height: 36px;
max-height: 120px;
overflow: auto;
line-height: 1.45;
}
.composer textarea:focus {
border: 0;
box-shadow: none;
}
.btn-send {
min-width: 78px;
border-radius: 14px;
align-self: stretch;
}
.side-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
overflow: auto;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
color: var(--muted);
font-weight: 700;
}
input,
textarea,
select {
border: 1px solid var(--card-border);
border-radius: 11px;
padding: 10px 11px;
font: inherit;
color: var(--ink);
outline: none;
background: #fff;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
}
.advanced {
border: 1px dashed rgba(19, 35, 58, 0.2);
border-radius: 12px;
padding: 9px 10px;
background: rgba(239, 247, 255, 0.7);
}
.advanced summary {
cursor: pointer;
font-weight: 700;
font-size: 13px;
color: #2f4769;
}
.advanced .field {
margin-top: 10px;
}
.inline-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.server-list-row select {
width: 100%;
}
.server-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.btn-mode {
background: #edf3f8;
color: var(--muted);
}
.btn-mode.active {
background: linear-gradient(130deg, var(--brand), var(--brand-strong));
color: #fff;
}
.btn-sm {
padding: 6px 9px;
font-size: 12px;
}
.btn-disconnect {
width: 100%;
justify-content: center;
}
.copy-notice {
color: var(--brand);
font-size: 12px;
font-weight: 700;
}
.readonly-text {
background: #f8fbff;
font-size: 12px;
}
.meta {
display: grid;
gap: 8px;
border-top: 1px solid var(--card-border);
padding-top: 12px;
}
.meta p {
margin: 0;
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.meta span {
color: var(--muted);
font-size: 13px;
}
.meta strong {
font-size: 13px;
word-break: break-all;
text-align: right;
max-width: 70%;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.toggle-row input {
width: 16px;
height: 16px;
}
.mobile-only,
.mobile-nav {
display: none;
}
.message-list::-webkit-scrollbar,
.side-card::-webkit-scrollbar {
width: 10px;
}
.message-list::-webkit-scrollbar-thumb,
.side-card::-webkit-scrollbar-thumb {
background: rgba(92, 116, 142, 0.28);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.message-list::-webkit-scrollbar-track,
.side-card::-webkit-scrollbar-track {
background: transparent;
}
.settings-head {
align-items: center;
justify-content: space-between;
}
@keyframes page-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes msg-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 980px) {
html,
body,
#root {
overflow: hidden;
}
.page {
width: 100%;
height: var(--vv-height);
margin: 0;
border-radius: 0;
position: fixed;
top: var(--vv-offset-top);
left: 0;
right: 0;
}
.hero {
display: none;
}
.layout {
display: block;
position: relative;
flex: 1;
min-height: 0;
}
.layout.mobile-chat .side-card {
display: none;
}
.layout.mobile-settings .chat-card {
display: none;
}
.layout.mobile-chat .chat-card {
padding-top: calc(env(safe-area-inset-top) + 54px);
}
.chat-card,
.side-card {
height: 100%;
border-radius: 0;
box-shadow: none;
border-left: 0;
border-right: 0;
}
.side-card {
padding: 12px 12px 76px;
gap: 12px;
}
.chat-head {
display: none;
}
.chat-mode-strip.mobile-only {
display: flex;
justify-content: space-between;
gap: 8px;
padding: calc(env(safe-area-inset-top) + 7px) 10px 7px;
border-bottom: 1px solid rgba(19, 35, 58, 0.1);
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(8px);
align-items: center;
position: fixed;
top: var(--vv-offset-top);
left: 0;
right: 0;
z-index: 26;
}
.chat-mode-left {
display: flex;
gap: 6px;
align-items: center;
background: #edf3f8;
border-radius: 14px;
padding: 3px;
}
.mode-strip-icon {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 800;
color: #fff;
background: linear-gradient(130deg, var(--brand), #14b8a6);
flex: 0 0 28px;
}
.chat-mode-strip .btn-mode {
padding: 6px 9px;
font-size: 11px;
line-height: 1.2;
border-radius: 11px;
min-width: 56px;
flex: 0 0 auto;
background: transparent;
color: #5f7084;
box-shadow: none;
}
.chat-mode-strip .btn-mode.active {
background: #ffffff;
color: #0a5a55;
box-shadow: 0 2px 8px rgba(18, 37, 61, 0.12);
}
.chat-mode-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.mobile-conn-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 30px;
padding: 0 11px;
border-radius: 11px;
background: #f7fafc;
color: #5f7084;
font-size: 10px;
font-weight: 800;
border: 1px solid rgba(19, 35, 58, 0.12);
white-space: nowrap;
min-width: 94px;
box-shadow: none;
}
.mobile-conn-btn .dot {
width: 7px;
height: 7px;
background: #8da0b5;
}
.mobile-conn-btn.ok .dot {
background: #0f766e;
}
.mobile-conn-btn.bad .dot {
background: #be123c;
}
.mobile-conn-btn.loading .dot {
background: #ea580c;
}
.mobile-conn-btn.ok {
color: #0a5a55;
background: rgba(15, 118, 110, 0.1);
border-color: rgba(15, 118, 110, 0.28);
}
.mobile-conn-btn.bad {
color: #9f1239;
background: rgba(190, 18, 60, 0.08);
border-color: rgba(190, 18, 60, 0.22);
}
.mobile-conn-btn.loading {
color: #9a5800;
background: rgba(234, 88, 12, 0.1);
border-color: rgba(234, 88, 12, 0.24);
}
.mobile-conn-btn.actionable {
color: #0a5a55;
background: rgba(15, 118, 110, 0.14);
border-color: rgba(15, 118, 110, 0.32);
cursor: pointer;
}
.chat-target-box.mobile-only {
display: block;
padding: 8px 12px;
border-bottom: 1px solid var(--card-border);
background: rgba(255, 255, 255, 0.92);
}
.chat-target-box textarea {
width: 100%;
min-height: 64px;
}
.message-list {
padding: 10px 10px calc(8px + 92px + env(safe-area-inset-bottom));
scroll-padding-bottom: calc(92px + env(safe-area-inset-bottom));
}
.composer {
position: sticky;
bottom: 56px;
grid-template-columns: 1fr auto;
align-items: center;
padding: 6px 8px;
gap: 6px;
background: rgba(247, 250, 253, 0.98);
border-top: 1px solid rgba(19, 35, 58, 0.08);
}
.btn-send {
width: auto;
min-width: 56px;
min-height: 34px;
border-radius: 17px;
align-self: center;
padding: 0 12px;
font-size: 12px;
}
.composer-input-wrap {
border-radius: 22px;
min-height: 40px;
display: flex;
align-items: center;
padding: 6px 12px;
}
.composer textarea {
min-height: 20px;
max-height: 88px;
padding: 0;
font-size: 16px;
line-height: 1.35;
}
.mobile-only {
display: inline-flex;
}
.desktop-only {
display: none !important;
}
.settings-head.mobile-only {
display: flex;
}
.mobile-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 54px;
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid var(--card-border);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(8px);
z-index: 20;
box-shadow: 0 -8px 18px rgba(21, 39, 64, 0.08);
}
.mobile-nav-btn {
border: 0;
background: transparent;
font: inherit;
color: #607086;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
padding: 3px 6px;
}
.mobile-nav-btn.active {
color: #0a5a55;
background: rgba(15, 118, 110, 0.12);
}
.mobile-nav-icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.mobile-nav-label {
font-size: 10px;
font-weight: 700;
line-height: 1.1;
}
}
@media (max-width: 640px) {
.msg {
max-width: 88%;
padding-right: 38px;
}
.btn-copy {
font-size: 10px;
min-height: 20px;
padding: 2px 7px;
}
}

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173
}
});
Loading…
Cancel
Save