feat: embed standalone local server runtime with Chaquopy Python, auto-installer, and Android 4.6.53 hotfix

This commit is contained in:
CAtlas0784 committed 2026-11-01 13:58:07 +07:00
1 parent 17bcc1adbf
commit 1f88f6ea38
40 files changed
+6036 -32

No files matched your search

@@ -4,6 +4,8 @@ import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
class GaCyApplication : Application() {
@@ -18,6 +20,11 @@ class GaCyApplication : Application() {
super.onCreate()
instance = this
createNotificationChannel()
// Initialize embedded Python runtime
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
}
private fun createNotificationChannel() {
@@ -16,7 +16,7 @@ import java.nio.charset.StandardCharsets
class PersistentRepository(private val context: Context) {
val serverDataDir: File
get() = File(context.filesDir, "gacy_server")
get() = File(context.filesDir, "server/build/build")
val miscDir: File
get() = File(serverDataDir, "misc")
@@ -29,7 +29,41 @@ class PersistentRepository(private val context: Context) {
}
fun isServerInstalled(): Boolean {
return File(serverDataDir, "gameserver").exists() || File(serverDataDir, "ppsr.pyc").exists()
return File(serverDataDir, "ppsr.pyc").exists() && File(serverDataDir, "ppsr.pyc").length() > 0
}
fun ensureValidPersistentJsonSync() {
try {
ensureDataStructure()
if (persistentFile.exists()) {
val text = persistentFile.readText(StandardCharsets.UTF_8)
val json = JSONObject(text)
var modified = false
if (!json.has("send_hotfix")) {
json.put("send_hotfix", true)
modified = true
}
if (!json.has("key")) {
json.put("key", "")
modified = true
}
if (!json.has("scene") || json.optJSONObject("scene") == null) {
val defaultObj = JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
json.put("scene", defaultObj.getJSONObject("scene"))
modified = true
}
if (!json.has("avatar") || json.optJSONObject("avatar") == null) {
val defaultObj = JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
json.put("avatar", defaultObj.getJSONObject("avatar"))
modified = true
}
if (modified) {
persistentFile.writeText(json.toString(2), StandardCharsets.UTF_8)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun ensureDataStructure() {
@@ -0,0 +1,361 @@
package com.gacy.android.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
data class UpdateInfo(
val currentVersion: String,
val latestVersion: String,
val availableVersions: List<String>,
val hasUpdate: Boolean
)
class UpdateRepository(private val context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("gacy_updater_prefs", Context.MODE_PRIVATE)
companion object {
const val BASE_URL = "https://file.garden/aewBviVxx2XDWgM-/"
const val VERSIONS_URL = "https://file.garden/aewBviVxx2XDWgM-/VERSIONS"
const val PREF_INSTALLED_VERSION = "installed_server_version"
const val CONNECT_TIMEOUT_MS = 15000
const val READ_TIMEOUT_MS = 15000
}
fun getInstalledVersion(): String {
return prefs.getString(PREF_INSTALLED_VERSION, "4.6.53-embedded") ?: "4.6.53-embedded"
}
fun setInstalledVersion(version: String) {
prefs.edit().putString(PREF_INSTALLED_VERSION, version).apply()
}
fun isServerInstalled(): Boolean {
val ppsrFile = File(context.filesDir, "server/build/build/ppsr.pyc")
return ppsrFile.exists() && ppsrFile.length() > 0
}
suspend fun ensureServerInstalled(): Boolean = withContext(Dispatchers.IO) {
val ppsrFile = File(context.filesDir, "server/build/build/ppsr.pyc")
if (ppsrFile.exists() && ppsrFile.length() > 0) {
return@withContext true
}
try {
val stagingDir = File(context.filesDir, "server/staging")
val targetServerDir = File(context.filesDir, "server/build/build")
if (stagingDir.exists()) stagingDir.deleteRecursively()
stagingDir.mkdirs()
// 1. Extract build.zip from bundled assets
extractAssetZip("build.zip", stagingDir)
val stagedBuildDir = File(stagingDir, "build")
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
// 2. Extract runtime packages into effective directory
try {
extractAssetZip("packages.zip", effectiveStagedDir)
} catch (e: Exception) {
e.printStackTrace()
}
// 3. Move/Rename into place
if (targetServerDir.exists()) {
targetServerDir.deleteRecursively()
}
targetServerDir.parentFile?.mkdirs()
val success = effectiveStagedDir.renameTo(targetServerDir)
if (!success) {
copyDirectory(effectiveStagedDir, targetServerDir)
effectiveStagedDir.deleteRecursively()
}
if (stagingDir.exists()) stagingDir.deleteRecursively()
setInstalledVersion("4.6.53-embedded")
return@withContext ppsrFile.exists()
} catch (e: Exception) {
e.printStackTrace()
return@withContext false
}
}
suspend fun checkUpdate(): Result<UpdateInfo> = withContext(Dispatchers.IO) {
try {
val versions = fetchAvailableVersions().getOrThrow()
if (versions.isEmpty()) {
return@withContext Result.failure(IllegalStateException("No versions found from server"))
}
val latestVersion = versions.last()
val installed = getInstalledVersion()
val hasUpdate = installed.isBlank() || installed != latestVersion
Result.success(
UpdateInfo(
currentVersion = installed,
latestVersion = latestVersion,
availableVersions = versions,
hasUpdate = hasUpdate
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchAvailableVersions(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val url = URL(VERSIONS_URL)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("User-Agent", "GaCy-Updater/1.0")
}
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
return@withContext Result.failure(
IllegalStateException("HTTP error ${conn.responseCode}: ${conn.responseMessage}")
)
}
val rawText = conn.inputStream.bufferedReader().use { it.readText() }
val list = rawText.split(Regex("[\\r\\n,]+"))
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
Result.success(list)
} catch (e: Exception) {
Result.failure(e)
}
}
fun buildDownloadUrl(version: String, filename: String = "build.zip"): String {
val sanitizedVersion = version.trim()
if (sanitizedVersion.contains("/") || sanitizedVersion.contains("\\") || sanitizedVersion.contains("..")) {
throw IllegalArgumentException("Invalid version format")
}
return "$BASE_URL$sanitizedVersion/$filename"
}
suspend fun downloadAndInstall(
version: String,
onProgress: (Float, String) -> Unit
): Result<Unit> = withContext(Dispatchers.IO) {
val tempZip = File(context.cacheDir, "_temp_build_${System.currentTimeMillis()}.zip")
val stagingDir = File(context.filesDir, "server/staging")
val targetServerDir = File(context.filesDir, "server/build/build")
val miscBackupFile = File(context.filesDir, "server/persistent_backup.json")
val configBackupFile = File(context.filesDir, "server/config_backup.json")
try {
onProgress(0.05f, "Connecting to download server...")
val downloadUrl = buildDownloadUrl(version, "build.zip")
val url = URL(downloadUrl)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("User-Agent", "GaCy-Updater/1.0")
}
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
return@withContext Result.failure(
IllegalStateException("Failed to download package: HTTP ${conn.responseCode}")
)
}
val contentLength = conn.contentLengthLong
val inputStream = BufferedInputStream(conn.inputStream)
val outputStream = FileOutputStream(tempZip)
val buffer = ByteArray(8192)
var totalBytesRead = 0L
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
val progress = 0.1f + (totalBytesRead.toFloat() / contentLength.toFloat()) * 0.5f
val mbRead = totalBytesRead / (1024 * 1024f)
val mbTotal = contentLength / (1024 * 1024f)
onProgress(progress, String.format("Downloading: %.1f MB / %.1f MB", mbRead, mbTotal))
} else {
onProgress(0.3f, "Downloading package...")
}
}
outputStream.flush()
outputStream.close()
inputStream.close()
onProgress(0.65f, "Backing up user configuration...")
val existingPersistent = File(targetServerDir, "misc/persistent.json")
if (existingPersistent.exists()) {
existingPersistent.copyTo(miscBackupFile, overwrite = true)
}
val existingConfig = File(targetServerDir, "misc/config.json")
if (existingConfig.exists()) {
existingConfig.copyTo(configBackupFile, overwrite = true)
}
onProgress(0.70f, "Preparing staging directory...")
if (stagingDir.exists()) {
stagingDir.deleteRecursively()
}
stagingDir.mkdirs()
onProgress(0.75f, "Extracting server archive...")
extractZipSafely(tempZip, stagingDir)
val stagedBuildDir = File(stagingDir, "build")
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
onProgress(0.80f, "Installing server runtime dependencies...")
try {
extractAssetZip("packages.zip", effectiveStagedDir)
} catch (e: Exception) {
// Non-fatal if asset missing
}
onProgress(0.85f, "Restoring user data...")
val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() }
if (miscBackupFile.exists()) {
try {
val defaultTemplate = JSONObject(PersistentRepository.DEFAULT_PERSISTENT_JSON_STRING)
val backedUpText = miscBackupFile.readText()
val backedUpJson = JSONObject(backedUpText)
if (backedUpJson.has("send_hotfix")) defaultTemplate.put("send_hotfix", backedUpJson.getBoolean("send_hotfix"))
if (backedUpJson.has("key")) defaultTemplate.put("key", backedUpJson.getString("key"))
if (backedUpJson.has("scene") && backedUpJson.optJSONObject("scene") != null) {
defaultTemplate.put("scene", backedUpJson.getJSONObject("scene"))
}
val backedUpAvatar = backedUpJson.optJSONObject("avatar")
if (backedUpAvatar != null) {
val avatar = defaultTemplate.getJSONObject("avatar")
val keys = backedUpAvatar.keys()
while (keys.hasNext()) {
val k = keys.next()
avatar.put(k, backedUpAvatar.get(k))
}
}
File(stagedMiscDir, "persistent.json").writeText(defaultTemplate.toString(4))
} catch (e: Exception) {
miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true)
}
}
if (configBackupFile.exists()) {
configBackupFile.copyTo(File(stagedMiscDir, "config.json"), overwrite = true)
}
onProgress(0.92f, "Finalizing installation...")
if (targetServerDir.exists()) {
targetServerDir.deleteRecursively()
}
targetServerDir.parentFile?.mkdirs()
val success = effectiveStagedDir.renameTo(targetServerDir)
if (!success) {
copyDirectory(effectiveStagedDir, targetServerDir)
effectiveStagedDir.deleteRecursively()
}
setInstalledVersion(version)
onProgress(1.0f, "Installation complete!")
Result.success(Unit)
} catch (e: Exception) {
onProgress(0f, "Installation error: ${e.message}")
Result.failure(e)
} finally {
if (tempZip.exists()) tempZip.delete()
if (stagingDir.exists()) stagingDir.deleteRecursively()
}
}
fun extractAssetZip(assetName: String, outputDir: File) {
context.assets.open(assetName).use { inputStream ->
ZipInputStream(BufferedInputStream(inputStream)).use { zis ->
var entry: ZipEntry? = zis.nextEntry
while (entry != null) {
val newFile = File(outputDir, entry.name)
val canonicalDestPath = outputDir.canonicalPath
val canonicalNewFilePath = newFile.canonicalPath
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
canonicalNewFilePath != canonicalDestPath
) {
throw SecurityException("Zip Slip detected: ${entry.name}")
}
if (entry.isDirectory) {
newFile.mkdirs()
} else {
newFile.parentFile?.mkdirs()
FileOutputStream(newFile).use { fos ->
zis.copyTo(fos)
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}
}
private fun extractZipSafely(zipFile: File, outputDir: File) {
ZipInputStream(zipFile.inputStream().buffered()).use { zis ->
var entry: ZipEntry? = zis.nextEntry
while (entry != null) {
val newFile = File(outputDir, entry.name)
val canonicalDestPath = outputDir.canonicalPath
val canonicalNewFilePath = newFile.canonicalPath
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
canonicalNewFilePath != canonicalDestPath
) {
throw SecurityException("Zip Slip detected: ${entry.name}")
}
if (entry.isDirectory) {
newFile.mkdirs()
} else {
newFile.parentFile?.mkdirs()
FileOutputStream(newFile).use { fos ->
zis.copyTo(fos)
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}
private fun copyDirectory(source: File, target: File) {
if (source.isDirectory) {
if (!target.exists()) target.mkdirs()
source.list()?.forEach { file ->
copyDirectory(File(source, file), File(target, file))
}
} else {
source.copyTo(target, overwrite = true)
}
}
}
@@ -9,6 +9,7 @@ enum class ServerStatus {
STOPPED,
STARTING,
RUNNING,
STOPPING,
ERROR
}
@@ -4,25 +4,43 @@ import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.chaquo.python.Python
import com.gacy.android.GaCyApplication
import com.gacy.android.MainActivity
import com.gacy.android.R
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.data.UpdateRepository
import com.gacy.android.model.ServerExecutionMode
import com.gacy.android.model.ServerStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.net.InetSocketAddress
import java.net.Socket
class ServerLogReceiver {
fun onServerLog(msg: String) {
LogBuffer.append(msg)
}
}
class ServerService : LifecycleService() {
private var serverThread: Thread? = null
private var probeJob: Job? = null
private var serverJob: Job? = null
override fun onCreate() {
@@ -52,56 +70,150 @@ class ServerService : LifecycleService() {
val config = prefs.getServerConfig()
_serverStatus.value = ServerStatus.STARTING
startForeground(NOTIFICATION_ID, createNotification("GaCy Server is starting..."))
startForegroundCompat("GaCy Server is starting...")
LogBuffer.append("--- GaCy Server Starting ---")
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
serverJob = lifecycleScope.launch {
try {
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
LogBuffer.append("Initializing local server runtime...")
delay(500)
LogBuffer.append("Loading resources and protocol definitions...")
delay(500)
LogBuffer.append("Dispatch service listening on 0.0.0.0:${config.dispatchPort}")
LogBuffer.append("Gateway KCP socket bound on 0.0.0.0:${config.gameserverPort}")
_serverStatus.value = ServerStatus.RUNNING
updateNotification("GaCy Server is RUNNING on :${config.dispatchPort}")
LogBuffer.append("GaCy Server is ready! Connect client via dispatch redirect.")
} else {
LogBuffer.append("Connecting to remote GaCy host at ${config.remoteHost}...")
delay(500)
_serverStatus.value = ServerStatus.RUNNING
updateNotification("Monitoring Remote GaCy Host at ${config.remoteHost}")
LogBuffer.append("Connected to remote GaCy server monitor.")
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
startLocalServerExecution(config.dispatchPort)
} else {
startRemoteMonitor(config.remoteHost, config.dispatchPort)
}
}
private fun startLocalServerExecution(dispatchPort: Int) {
serverJob = lifecycleScope.launch(Dispatchers.IO) {
val updateRepo = UpdateRepository(this@ServerService)
if (!updateRepo.isServerInstalled()) {
LogBuffer.append("[Setup] Installing GaCy embedded server binaries from assets...")
val success = updateRepo.ensureServerInstalled()
if (!success) {
LogBuffer.append("[Error] Failed to install server binaries from assets.")
_serverStatus.value = ServerStatus.ERROR
stopForegroundCompat()
stopSelf()
return@launch
}
} catch (e: Exception) {
LogBuffer.append("Error starting server: ${e.message}")
_serverStatus.value = ServerStatus.ERROR
updateNotification("GaCy Server encountered an error")
LogBuffer.append("[Setup] Server binaries installed successfully.")
}
val ppsrFile = File(filesDir, "server/build/build/ppsr.pyc")
if (!ppsrFile.exists()) {
LogBuffer.append("[Error] Server bytecode missing at: ${ppsrFile.absolutePath}")
_serverStatus.value = ServerStatus.ERROR
stopForegroundCompat()
stopSelf()
return@launch
}
try {
val persistentRepo = PersistentRepository(this@ServerService)
persistentRepo.ensureValidPersistentJsonSync()
} catch (e: Exception) {
LogBuffer.append("[Warning] Persistent validation: ${e.message}")
}
LogBuffer.append("[System] Launching embedded GaCy Python server...")
// Background TCP port readiness probe
probeJob?.cancel()
probeJob = lifecycleScope.launch(Dispatchers.IO) {
var attempts = 0
while (isActive && _serverStatus.value == ServerStatus.STARTING && attempts < 35) {
delay(1000)
attempts++
if (isPortOpen("127.0.0.1", dispatchPort)) {
_serverStatus.value = ServerStatus.RUNNING
updateNotification("GaCy Server is RUNNING on :$dispatchPort")
LogBuffer.append("[System] GaCy Dispatch server is ready on 127.0.0.1:$dispatchPort")
break
}
}
}
serverThread = Thread({
try {
val py = Python.getInstance()
val runPpsr = py.getModule("run_ppsr")
val logReceiver = ServerLogReceiver()
runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver)
} catch (e: Exception) {
LogBuffer.append("[Python Error] ${e.localizedMessage ?: e.message}")
} finally {
probeJob?.cancel()
_serverStatus.value = ServerStatus.STOPPED
LogBuffer.append("[System] Server stopped.")
stopForegroundCompat()
stopSelf()
}
}, "GaCy-PythonServer").apply {
start()
}
}
}
private fun startRemoteMonitor(remoteHost: String, dispatchPort: Int) {
serverJob = lifecycleScope.launch(Dispatchers.IO) {
LogBuffer.append("[Remote] Connecting to remote GaCy host at $remoteHost:$dispatchPort...")
delay(600)
_serverStatus.value = ServerStatus.RUNNING
updateNotification("Monitoring Remote GaCy Host at $remoteHost")
LogBuffer.append("[Remote] Connected to remote GaCy server monitor.")
}
}
private fun stopServer() {
LogBuffer.append("--- GaCy Server Stopping ---")
_serverStatus.value = ServerStatus.STOPPING
updateNotification("Stopping GaCy Server...")
probeJob?.cancel()
probeJob = null
serverJob?.cancel()
serverJob = null
_serverStatus.value = ServerStatus.STOPPED
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
lifecycleScope.launch(Dispatchers.IO) {
try {
if (Python.isStarted()) {
val py = Python.getInstance()
val runPpsr = py.getModule("run_ppsr")
runPpsr.callAttr("kill_server_process")
}
} catch (e: Exception) {
LogBuffer.append("[Stop Error] ${e.localizedMessage ?: e.message}")
}
delay(1200)
serverThread?.interrupt()
serverThread = null
_serverStatus.value = ServerStatus.STOPPED
stopForegroundCompat()
stopSelf()
}
}
private fun restartServer() {
LogBuffer.append("Restarting GaCy Server...")
stopServer()
lifecycleScope.launch {
delay(800)
delay(1500)
startServer()
}
}
private fun isPortOpen(host: String, port: Int): Boolean {
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 400)
true
}
} catch (_: Exception) {
false
}
}
private fun createNotification(contentText: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -133,14 +245,38 @@ class ServerService : LifecycleService() {
.build()
}
private fun startForegroundCompat(text: String) {
val notification = createNotification(text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateNotification(contentText: String) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, createNotification(contentText))
}
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
override fun onDestroy() {
super.onDestroy()
probeJob?.cancel()
serverThread?.interrupt()
serverJob?.cancel()
super.onDestroy()
_serverStatus.value = ServerStatus.STOPPED
}
@@ -160,7 +296,11 @@ class ServerService : LifecycleService() {
fun start(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
context.startForegroundService(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
@@ -170,7 +310,11 @@ class ServerService : LifecycleService() {
fun restart(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
context.startForegroundService(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
}
@@ -109,6 +109,7 @@ fun StatusBadge(status: ServerStatus, lang: AppLanguage) {
val (color, text) = when (status) {
ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中"))
ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中..."))
ServerStatus.STOPPING -> Pair(GaCyAmber, tr(lang, "STOPPING...", "กำลังหยุด...", "停止中..."))
ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止"))
ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误"))
}