本文介绍如何将前文的i2p-p2p-chat接入Minecraft中。
准备工作
前文中我们完成了i2p-p2p-chat的编写,这个库封装了所有基于I2P的点对点通讯的操作。本文中,我们需要在Minecraft这部分做出修改。
首先是要找到拦截和注入消息的位置,拦截到的消息将有我们的模组发送,而模组接收到的消息需要注入到Minecraft原本的聊天框中。然后就是想办法引入一套命令系统:公屏聊天当然不需要,但我们还得能够私聊,况且我们还得想办法让用户分享他们自己的地址。最后我们还得搞一套简单的配置系统,用来持久化地存储我们的私钥和曾经见过的节点。
配置系统
配置系统应该是整个模组最简单的部分了。我们的模组有两个需要存储的配置:一个是控制模组行为的配置文件;另一个则是存储见过的节点。前者无需过多解释,对于后者,建立这种缓存是为了当我们在服务器中遇到以前见过的用户名,则可以直接向过去见过的地址发起连接。因为我们的隧道私钥是随Minecraft实例持久化保存的,除非用户选择刷新私钥或者更换了实例,在大多数情况下,我们应该可以通过旧的地址直接连上用户,从而跳过了PEX和手工分享地址。这也使得整个mod的体验随着使用时间增加而变好。
配置文件的格式当然是JSON,虽然后者用KV数据库性能会更好,但是我的宗旨是能不用数据库就不用数据库,最好一个map就能解决。两个配置的模型定义如下:
@Serializable
class MCAConfig(
@Serializable(with = ByteArrayAsBase64Serializer::class)
private var _i2pKey: ByteArray = I2PHelper.generateDestinationKey(),
@Serializable(with = DurationSerializer ::class)
var pexInterval: Duration = Duration.ofSeconds(45),
@Serializable(with = DurationSerializer::class)
var lastSeenUpdateInterval: Duration = Duration.ofMinutes(3),
@Serializable(with = DurationSerializer::class)
var sessionRemoverInterval: Duration = Duration.ofMinutes(2),
@Serializable(with = DurationSerializer::class)
var autoConnectInterval: Duration = Duration.ofSeconds(15),
) {
val i2pDestinationKey
get() = _i2pKey
fun discardI2PKey() {
_i2pKey = I2PHelper.generateDestinationKey()
}
}
@Serializable
class PeerRepoEntry(
@Serializable(with = DestinationSerializer::class)
var destination: Destination,
@Serializable(with = ZonedDateTimeAsTimestampSerializer::class)
private var _lastSeen: ZonedDateTime
) {
val lastSeen
get() = _lastSeen
fun seen(dest: Destination) {
destination = dest
_lastSeen = ZonedDateTime.now()
}
}
其中MCAConfig
就是控制行为的配置文件了。里面的_i2pKey
就是我们I2P隧道的私钥,他的类型是ByteArray,但实际用的时候需要转换成Destination,于是就有了后面的i2pDestinationKey
,当然,为了实现刷新功能,还有discardI2PKey
方法。其他的各种Interval则决定了定时任务的执行间隔,比如多久进行一次PEX,多久记录一次最后见到的节点信息,多久进行一次会话检查(关闭不在同一个服务器中的会话),多久尝试一次从缓存中连接节点。
下面的PeerRepoEntry
就是一个节点缓存条目。它记录了节点的地址和最后一次见到的时间。这里本来想实现一个简单的最久淘汰策略,但是后来给忘了。不过忘了也不会有太严重的后果,一个条目不会超过1KB,因此见过一千个玩家才占用1MB空间,见过一百万个玩家才占用1GB空间。这个文件可以随时被删掉,唯一的影响就是下一次你见到这个玩家,mod不会自动连接了。
有了模型,我们得想办法提供一个与配置文件交互的接口。这里我们选择使用sealed class来实现。
首先我们需要一个能够输出多行的Json:
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
漂亮打印使得用户能够以人类可读的形式修改配置;而忽略未知的键则有助于提供额外的兼容性:让我们在后续版本废弃了一些选项,JSON并不会因为这些已经废弃的选项而报错;最后的编码默认值则是将我们的默认值打印到配置文件中,以便用户修改。我们的sealed class定义如下:
sealed class ConfigFile<T>(
private val filename: String,
private val serializer: KSerializer<T>,
defaultValue: T,
) {
/**
* Current value. It use default value unless we overwrite it when [loadOrCreate]
* */
private var value: T = defaultValue
/**
* The actual [Path] for this config file.
* */
private val configPath = FabricLoader.getInstance().configDir.resolve(this.filename)
init {
reload()
// write the latest
save()
}
/**
* Load from disk, create with default value if not exist.
* */
private fun loadOrCreate(): T {
// ensure we don't read and write against the same config at the same time
synchronized(this) {
return if (Files.exists(configPath)) {
// read it
json.decodeFromString(serializer, configPath.readText())
} else {
//create it
@Suppress("BlockingMethodInNonBlockingContext")
Files.createDirectories(configPath.parent)
configPath.writeText(json.encodeToString(serializer, value))
value
}
}
}
/**
* Use the config sync.
* */
fun <R> use(block: T.() -> R): R {
synchronized(this) {
return block(value)
}
}
/**
* Discard changes and reload latest from disk.
* Same as [loadOrCreate]
* */
fun reload() {
value = loadOrCreate()
}
/**
* Save the current value info disk.
* */
fun save() {
synchronized(this) {
configPath.writeText(json.encodeToString(serializer, value))
}
}
}
ConfigFile有三个构造函数:文件路径、序列化器和一个默认值。每个实例都有一个value,默认是默认值。在实例化过程中,先尝试从磁盘中载入配置,如果配置存在,则从配置中解析并代替默认值;如果文件不存在,则将默认值的内容写入文件,并返回默认值。除了常用的重载和保存,还提供了一个线程安全的use
方法。这个方法保证对于value的访问是同步的,这样我们就不必为忘记加锁而烦恼了。
具体的两个实例如下:
object ModConfig : ConfigFile<MCAConfig>(
filename = "minecraft-chat-alternative/config.json",
serializer = MCAConfig.serializer(),
defaultValue = MCAConfig()
)
@Suppress("OPT_IN_USAGE")
object PeerRepo : ConfigFile<MutableMap<String, PeerRepoEntry>>(
filename = "minecraft-chat-alternative/peer_repo.json",
serializer = object : KSerializer<MutableMap<String, PeerRepoEntry>> {
val delegate = MapSerializer(String.serializer(), PeerRepoEntry.serializer())
override val descriptor: SerialDescriptor = SerialDescriptor(
"MutableMap<String, PeerRepoEntry>", delegate.descriptor
)
override fun deserialize(decoder: Decoder): MutableMap<String, PeerRepoEntry> {
return decoder.decodeSerializableValue(delegate).toMutableMap()
}
override fun serialize(encoder: Encoder, value: MutableMap<String, PeerRepoEntry>) {
encoder.encodeSerializableValue(delegate, value)
}
},
defaultValue = mutableMapOf()
) {
/**
* Update from session list.
* */
fun updateLastSeenFromSessions(sessions: List<PeerSession<MCAContext>>) {
sessions.forEach { s ->
if (s.isClosed()) return@forEach
val username = s.useContextSync { username } ?: return@forEach
this.use {
if (containsKey(username))
get(username)!!.seen(s.getPeerDestination())
else
put(username, PeerRepoEntry(s.getPeerDestination(), ZonedDateTime.now()))
}
}
}
}
MCAConfig那个挺简单的,后面那个PeerRepo就有一些复杂了。对于Map类型,在Java中没有过多的区分,但kotlin这边会区分是只读Map还是可修改的MutableMap。而从json反序列化出来的都是Map(因为内置的MapSerializer)要解决这个问题,有两种方法:一种是实现一个自定义序列化器,底层委托给MapSerializer;另一种则是修改sealed class,加一个mapper,对反序列化结果进行处理。按道理来说,后者应该更好一些(但可能需要更复杂的类型,例如KSerializer<U>
和(U) -> T
),但不知道怎么回事,当时我选了第一种方法。
会话上下文
虽然有了配置系统,但我们还没办法直接启动节点:我们还需要一个上下文来保存会话状态。好在这个mod并不需要什么复杂的状态:
class MCAContext(
override val sessionSource: SessionSource
) : SessionContext {
var username: String? = null
private set
override val nickname: String? by this::username
override var peerInfo: PeerInfo? = null
set(value) {
username = value?.getUsername()
field = value
}
private var authAccepted: Boolean = false
override fun onAuthAccepted() {
authAccepted = true
}
override fun isAccepted(): Boolean = authAccepted
}
这里面有个username,其实用nickname也无妨,但是我觉得username对于MC来说更贴切一些,于是使用属性代理把对nickname的访问变成对username的访问。
证书签名与验证
等一下!你以为有了上下文就可以启动节点了?在此之前我们还要处理一个问题:怎么用Mojang的证书来签名我们的PeerInfo,以及用它来验证别人发来的PeerInfo呢?
签名倒是好办:
private fun createPeerInfo(peer: Peer<MCAContext>): PeerInfo {
val (session, profileKeys) = MinecraftClient.getInstance()
?.let { it.session to it.profileKeys }
?: error("Minecraft instance is null")
val (username, uuid) = session
?.let { (it.username ?: error("Cannot get current username")) to it.uuidOrNull }
?: error("Minecraft session is null")
val (pubKey, signer) = profileKeys
?.let {
(it.publicKey.orElse(null) ?: error("Cannot get player's public key")
) to (it.signer ?: error("Cannot get signer"))
}
?: error("Cannot get profile key")
val info = mapOf<String, String>(
PeerInfo.INFO_KEY_DEST to peer.getMyDestination().toBase64(),
"minecraft.username" to username,
"minecraft.profile_uuid" to (uuid?.toString() ?: error("Player's uuid is null")),
)
val dataToSign = PeerInfo.getDataForSign(info)
val signature = signer.sign(dataToSign)
return PeerInfo(
info = info,
signatureBase64 = Base64.getEncoder().encodeToString(signature),
publicKeyBase64 = pubKey.toBase64()
)
}
你会发现这里除了用户名,我们还额外加了一个profile uuid。这个东西是你每次使用正版登录,mojang随机给你分配的一个uuid,并不保证和你的账号绑定,但一般情况下是一一对应的,而且可以凭这个uuid在mojang的服务中代表你这个正版账号。
private fun verifyPeerInfo(peerInfo: PeerInfo): Boolean {
try {
val profileUUID = peerInfo.info["minecraft.profile_uuid"]
?.let { UUID.fromString(it) } ?: return false
val publicKeyData = peerInfo.getPublicKeyBytes().paresPublicKeyData()
val pubKey = MinecraftClient.getInstance()?.let {
PlayerPublicKey.verifyAndDecode(
it.servicesSignatureVerifier,
profileUUID, publicKeyData,
PlayerPublicKey.EXPIRATION_GRACE_PERIOD
)
} ?: error("Minecraft instance is null")
val verifier = pubKey.createSignatureInstance()
val payload = peerInfo.getInfoBencodeBytes()
val signature = peerInfo.getSignatureBytes()
return verifier.validate(payload, signature)
} catch (_: Throwable) {
// if any error, invalid peer info
return false
}
}
你会发现,在验证过程中我们调用了MC的代码,这里面用到了uuid,而非用户名。
有了签名和验证节点信息的能力,我们就可以启动节点了,对吧?
i18n与注入
我知道你很急,但是你先别急,一会儿有你急的。
我们启动节点之前要提供一个Handler,在接收到TextMessage的时候需要调用我们的代码。而我们的代码需要把接收到的消息注入到MC原本的聊天框中。但是我们不能楞把消息插到聊天框里,我们想要一种格式,例如:
[I2p]Hahaha this is a message!
类似这种,我们希望来自I2P的消息与来自MC的消息区分开。实现这种效果最简单的方式就是用String format,但是直接把格式写死在代码里好像不太好,例如别人发送私信给我们的时候:
[I2P]whispers to you: This is a private message!!!
如果是中文的话,我们想翻译成:
[I2P]私信对你说:This is a private message!!!
i18n
这显然是硬编码格式做不到的。但好在Mojang也考虑到了这一点,引入了Lang。我们可以在en_us.json
中写英文的格式,在zh_cn.json
中写中文的格式,只要符合对应的语言代码,我们可以翻译成任何一种语言:
{
"chat.mca.system": "[MCA] %s",
"chat.mca.system.narrate": "MCA: %s",
"chat.mca.i2p.broadcast": "[I2P] <%s> %s",
"chat.mca.i2p.broadcast.narrate": "%s on I2P says %s",
"chat.mca.i2p.incoming": "[I2P] <%s> whispers to you: %s",
"chat.mca.i2p.incoming.narrate": "%s on I2P whispers to you: %s",
"chat.mca.i2p.outgoing": "You whisper to [I2P] <%s>: %s",
"chat.mca.i2p.outgoing.narrate": "You whisper to %s on I2P: %s",
"text.mca.system.connection.incoming": "Accept connection from %s",
"text.mca.system.connection.outgoing": "Connected to %s",
"text.mca.system.connection.disconnected": "Disconnected by %s, because %s",
"text.mca.system.connection.socket_closed": "Socket with %s closed",
"text.mca.system.error.general": "Error occurred, see log for more info",
"text.mca.system.error.peer_stopped": "I2P Peer stopped. This should never have happened.",
"text.mca.system.error.on_error_reply": "Player %s replies an error to your previous request. See log for more info."
}
这个JSON看起来挺结构化的,但是用起来的话完全就是个字符串。例如我需要引用收到私信的格式时,我需要在代码中指定chat.mca.i2p.incoming
这个键。这种字符串没法保证这个翻译键存在,而这个键明显有一种树状结构,所以,老规矩,sealed class:
sealed class Lang(
vararg path: String
) {
private val fullPath: String = path.joinToString(".")
protected fun translate(vararg args: Any): MinecraftText {
require(Language.getInstance().get(fullPath) != fullPath) { "Missing translation key: $fullPath" }
return MinecraftText.translatable(fullPath, *args)
}
}
这个sealed class接收一个path数组,用来表示完整的key。例如chat.mca.i2p.incoming
就变成了["chat", "mca", "i2p", "incoming"]
,而后者可以通过树形结构产生,例如表示Chat类的有一个chat.mca
前缀,而Chat的子类有i2p
的前缀,借助vararg,我们可以这么写:Lang("chat.mca", *path)
,在Chat类实例化的过程中,把自己的前缀加到前面,然后把子类的路径放在后面,有点类似递归的意思。而那个translate
方法则起到了一个检查的效果,如果引用了未找到的翻译键,那么直接报错引发崩溃。
具体来说,Lang下面有两个子类,一个是直接显示到聊天框里面的chat.mca.*
,另一类是作为聊天消息的一部分解析的text.mca.*
。对于聊天类消息:
sealed class ChatMessage(
vararg path: String
) : Lang("chat.mca", *path) {
sealed class SystemMessage(
vararg path: String
) : ChatMessage("system", *path) {
object Text : SystemMessage()
object Narrate : SystemMessage("narrate")
fun translate(message: MinecraftText): MinecraftText =
super.translate(message)
}
sealed class I2PMessage(
vararg path: String
) : ChatMessage("i2p", *path) {
sealed class Broadcast(
vararg path: String
) : I2PMessage("broadcast", *path) {
object Text : Broadcast()
object Narrate : Broadcast("narrate")
fun translate(username: MinecraftText, content: MinecraftText): MinecraftText =
super.translate(username, content)
}
sealed class Incoming(
vararg path: String
) : I2PMessage("incoming", *path) {
object Text : Incoming()
object Narrate : Incoming("narrate")
fun translate(username: MinecraftText, content: MinecraftText): MinecraftText =
super.translate(username, content)
}
sealed class Outgoing(
vararg path: String
) : I2PMessage("outgoing", *path) {
object Text : Outgoing()
object Narrate : Outgoing("narrate")
fun translate(username: MinecraftText, content: MinecraftText): MinecraftText =
super.translate(username, content)
}
}
}
这里面基本上就是对Lang的建模。对于聊天消息,除了显示成文本的键,例如chat.mca.i2p.broadcast
,还有对应的需要讲述人念出来的键chat.mca.i2p.broadcast.narrate
。对于chat.mca.i2p.broadcast
,作为一个可以被引用的键,它应该是Object,但它底下又有一个narrate
,它得是sealed class。为了解决这个问题,引入了一个名叫Text,但path为空的object,从而保证Text的路径和父对象的路径相同,而与Text同级的Narrate则用于.narrate
后缀。此外每个object还都有一个translate方法,这个调用了超类的translate,保证对引用键的检查,同时用代码约束了参数。
类似地,具有text.mca.*
前缀的TextMessage也是如此,只不过没有了narrate,更加简单一些:
sealed class TextMessage(
vararg path: String
) : Lang("text.mca", *path) {
sealed class SystemMessage(
vararg path: String
) : TextMessage("system", *path) {
sealed class Connection(
vararg path: String
) : SystemMessage("connection", *path) {
object Incoming : Connection("incoming") {
fun translate(username: MinecraftText): MinecraftText =
super.translate(username)
}
object Outgoing : Connection("outgoing") {
fun translate(username: MinecraftText): MinecraftText =
super.translate(username)
}
object Disconnected : Connection("disconnected") {
fun translate(username: MinecraftText, reason: MinecraftText): MinecraftText =
super.translate(username, reason)
}
object SocketClosed : Connection("socket_closed") {
fun translate(username: MinecraftText): MinecraftText =
super.translate(username)
}
}
sealed class Error(
vararg path: String
) : SystemMessage("error", *path) {
object General : Error("general") {
fun translate(): MinecraftText = super.translate()
}
object PeerStopped : Error("peer_stopped") {
fun translate(): MinecraftText = super.translate()
}
object PeerReportedError : Error("on_error_reply") {
fun translate(username: MinecraftText): MinecraftText =
super.translate(username)
}
}
}
}
当然,这里需要注意的就是不要引用错父类,不然整个路径就都变了。
此外,定义sealed class不一定要定义在父类里面,但这样有一个好处,使得我们可以用类似键的语法来使用:
Lang.ChatMessage.I2PMessage.Outgoing.Text.translate(......)
这就像是chat.mca.i2p.outgoing
,我觉得很好。
消息注入
有了这个方便的i18n接口,我们就可以考虑消息注入了。经过一番摸索,可以使用如下代码插入一条消息:
fun addRawMessage(message: Text, narrateMessage: Text) {
val minecraft = MinecraftClient.getInstance()!!
minecraft.inGameHud?.chatHud?.addMessage(message)
minecraft.narratorManager?.narrateChatMessage { narrateMessage }
}
当然,narrator是可选的,不过既然原版MC都做了,我们也不应该忽略。每个人都应当能够享受游戏的乐趣,对吧?
这个方法只是将一条消息插入到聊天框中,为了应用格式,我们可以针对不同的消息类型使用不同的方法,例如:
fun addSystemMessageToChat(content: Text) {
val textChatComponent = Lang.ChatMessage.SystemMessage.Text
.translate(content)
val narrateChatComponent = Lang.ChatMessage.SystemMessage.Narrate
.translate(content)
addRawMessage(textChatComponent, narrateChatComponent)
}
fun addBroadcastMessageToChat(username: String, content: Text) {
val textChatComponent = Lang.ChatMessage.I2PMessage.Broadcast.Text.translate(
Text.literal(username).fillStyle(
Style.EMPTY.withClickEvent(
ClickEvent(
ClickEvent.Action.SUGGEST_COMMAND,
String.format("!dm %s ", username)
)
)
), content
)
val narrateChatComponent = Lang.ChatMessage.I2PMessage.Broadcast.Narrate.translate(
Text.literal(username), content
)
addRawMessage(textChatComponent, narrateChatComponent)
}
诸如此类的。
启动节点
好了,现在可以考虑启动节点了。我们可以在mod加载时启动节点并启动各种定时任务:
@Environment(EnvType.CLIENT)
object ClientModEntry : ClientModInitializer {
private val logger = KotlinLogging.logger { }
private val json = Json {
ignoreUnknownKeys = true
serializersModule = getSerializersModule()
}
private val threadPool = I2PHelper.createThreadPool(Runtime.getRuntime().availableProcessors())
private var peer: Peer<MCAContext>? = null
@JvmStatic
fun getPeer(): Peer<MCAContext> = peer ?: error("Peer not initialized")
override fun onInitializeClient() {
logger.info { "Hello!" }
logger.info { "Creating peer..." }
peer = createPeer(
json = json, username = MinecraftHelper.getCurrentUsername()!!,
protocolName = "minecraft-chat-alternative-protocol-20221121",
destinationKey = ConfigFile.ModConfig.use { i2pDestinationKey },
threadPool = threadPool, pexInterval = ConfigFile.ModConfig.use { pexInterval }
)
logger.info { "Peer created!" }
// start doing things with that peer
// update last seen
runTask({ lastSeenUpdateInterval }) { peer ->
ConfigFile.PeerRepo.updateLastSeenFromSessions(peer.dumpSessions())
ConfigFile.PeerRepo.save()
}
// remove invalid session
runTask({ sessionRemoverInterval }) { peer ->
peer.disconnect("not in same server") {
val username = it.useContextSync { username } ?: return@disconnect false
username !in MinecraftHelper.getPlayerList()
}
}
// connect from peer repo
runTask({ autoConnectInterval }) { peer ->
val alreadyConnected = peer.dumpSessions().mapNotNull { it.useContextSync { username } }
ConfigFile.PeerRepo.use {
entries.filter {
it.key in MinecraftHelper.getPlayerList()
&& it.key !in alreadyConnected
}.forEach { entry ->
I2PHelper.runThread {
try {
peer.connect(entry.value.destination)
} catch (_: Throwable) {
}
}
}
}
}
}
private fun runTask(duration: MCAConfig.() -> Duration, block: (Peer<MCAContext>) -> Unit) {
I2PHelper.runThread {
val peer = getPeer()
val interval = ConfigFile.ModConfig.use { duration() }.toMillis()
while (!peer.isClosed()) {
MinecraftHelper.sleep(interval)
block(peer)
}
logger.warn { "Peer stopped! Trigger crash" }
MinecraftHelper.crash(IllegalStateException(Lang.TextMessage.SystemMessage.Error.PeerStopped.translate().string))
}
}
}
这里我们启动节点,并拉起了一系列定时运行的任务,例如每隔一段时间就记录当前已经连接的会话;每隔一段时间与不在玩家列表中的玩家断开连接;定时从缓存的列表中寻找玩家地址并连接。
在创建节点时,主要的Handler有这些:
// for AuthRequest
createMessageHandlerWithReply { _, session, messageId, payload: AuthRequest, _ ->
if (verifyPeerInfo(payload.peerInfo)) {
val username = payload.peerInfo.getUsername()
if (username in MinecraftHelper.getPlayerList()) {
logger.info { "Authed ${session.getDisplayName()} as $username" }
ChatHelper.addSystemMessageToChat(
Lang.TextMessage.SystemMessage.Connection.Incoming
.translate(Text.literal(payload.peerInfo.getUsername()))
)
AuthAcceptedReply(messageId)
} else {
// not in the same server
AuthenticationFailedError("Not in the same server", messageId)
}
} else {
AuthenticationFailedError("Verification failed", messageId)
}
},
// for AuthAcceptedReply
createMessageHandlerNoReply { _, session, _: AuthAcceptedReply, _ ->
ChatHelper.addSystemMessageToChat(
Lang.TextMessage.SystemMessage.Connection.Outgoing
.translate(Text.literal(session.useContextSync { username }))
)
},
// for TextRequest
createMessageHandlerWithReply { _, session, messageId, payload: TextMessageRequest, _ ->
val username = session.useContextSync { username } ?: error("Unauthorized message")
when (payload.scope) {
"broadcast" -> ChatHelper.addBroadcastMessageToChat(
username, Text.literal(payload.content)
)
"whisper" -> ChatHelper.addIncomingMessageToChat(
username, Text.literal(payload.content)
)
else -> logger.warn { "Unknown message scope '${payload.scope}', content: ${payload.content}" }
}
NoContentReply(messageId)
},
// for ByeRequest
createMessageHandlerNoReply { _, session, payload: ByeRequest, _ ->
ChatHelper.addSystemMessageToChat(
Lang.TextMessage.SystemMessage.Connection.Disconnected
.translate(
Text.literal(session.getDisplayName()),
Text.literal(payload.reason)
)
)
},
大多数回复都是在聊天框中打印对应的提示信息,比如收到了谁的消息,谁断开了连接,我们连上了谁,谁连上了我们,之类的。在验证部分,除了确保对方是正版账号之外,还得确认他确实和我们处于同一个世界/服务器,不然真就跨服聊天了。
后续
考虑到篇幅,这一篇文章中引用了不少代码,而余下的内容还有不少。主要是拦截消息和一套指令系统,还有一些提升体验的mixin,这些我打算留到P5再写。
那么这篇文章先到此为止,我们下一篇再见。
-全文完-
【代码札记】从零开始的点对点聊天系统 P4 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。