Initial import
commit
31b76ec69c
@ -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,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,5 @@
|
||||
.gradle/
|
||||
local.properties
|
||||
**/build/
|
||||
.idea/
|
||||
*.iml
|
||||
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,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
|
||||
Binary file not shown.
@ -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…
Reference in New Issue