Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a4b4f545 | ||
|
|
1f88f6ea38 | ||
|
|
17bcc1adbf |
No files matched your search
@@ -16,3 +16,6 @@ local.properties
|
|||||||
app/build/
|
app/build/
|
||||||
*.apk
|
*.apk
|
||||||
*.aab
|
*.aab
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 📱 GaCy Android
|
# 📱 GaCy Android (if you have client and this ps is working pls tell me)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
jniLibs.srcDirs("src/main/jniLibs")
|
||||||
|
assets.srcDirs("src/main/assets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -46,6 +53,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(files("libs/chaquopy_java.jar"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
implementation("androidx.lifecycle:lifecycle-service:2.8.6")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"assets": {
|
||||||
|
"bootstrap-native/arm64-v8a/_struct.cpython-314-aarch64-linux-android.so": "9b083e7b97e92291f73d80d7495c9cee8351596b",
|
||||||
|
"requirements-arm64-v8a.imy": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33",
|
||||||
|
"bootstrap-native/arm64-v8a/java/chaquopy.so": "1284a42a1b74c8fc5c8705a4f6f309134ecef4a8",
|
||||||
|
"bootstrap.imy": "6c6d7c85e4764e96924001f9d6290501bfbfc704",
|
||||||
|
"bootstrap-native/arm64-v8a/math.cpython-314-aarch64-linux-android.so": "dc859c93d3fc01f2d087471052895f230f732436",
|
||||||
|
"cacert.pem": "fec7b8c0a17c1c555fd33baee53678f31b7645ee",
|
||||||
|
"bootstrap-native/arm64-v8a/_bz2.cpython-314-aarch64-linux-android.so": "b48b9abf1f87d587a5f5beb4b38bb17acf29c4d3",
|
||||||
|
"bootstrap-native/arm64-v8a/_ctypes.cpython-314-aarch64-linux-android.so": "50a6a36dab6a9ebe4fca5797872e8d2c3538db56",
|
||||||
|
"bootstrap-native/arm64-v8a/mmap.cpython-314-aarch64-linux-android.so": "b571492fdceac1279bedc8163e27ac205e104b14",
|
||||||
|
"app.imy": "8e998f6e20dc7152ab075f8edf0644d411cd7f76",
|
||||||
|
"stdlib-common.imy": "8715635411fe145000fbf3b7ce4c3a085646d300",
|
||||||
|
"bootstrap-native/arm64-v8a/_random.cpython-314-aarch64-linux-android.so": "1c6c12f9f43546da0e108031f824b7fd6e79a36a",
|
||||||
|
"bootstrap-native/arm64-v8a/_lzma.cpython-314-aarch64-linux-android.so": "6f3f86f607b4d775452c128858fa26d19c7da56a",
|
||||||
|
"bootstrap-native/arm64-v8a/zlib.cpython-314-aarch64-linux-android.so": "2d164f648a0f97c1d8710d41cb66754e9daff44e",
|
||||||
|
"bootstrap-native/arm64-v8a/binascii.cpython-314-aarch64-linux-android.so": "ff4d2c47c081af6518c8a6a6fa07edcc077b1b6b",
|
||||||
|
"stdlib-arm64-v8a.imy": "ebebf5dc919d7e686936904604fe9a29fede81e9",
|
||||||
|
"requirements-common.imy": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33"
|
||||||
|
},
|
||||||
|
"extract_packages": [],
|
||||||
|
"python_version": "3.14"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,8 @@ import android.app.Application
|
|||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import com.chaquo.python.Python
|
||||||
|
import com.chaquo.python.android.AndroidPlatform
|
||||||
|
|
||||||
class GaCyApplication : Application() {
|
class GaCyApplication : Application() {
|
||||||
|
|
||||||
@@ -18,6 +20,15 @@ class GaCyApplication : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
|
// Initialize embedded Python runtime
|
||||||
|
try {
|
||||||
|
if (!Python.isStarted()) {
|
||||||
|
Python.start(AndroidPlatform(this))
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
t.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import java.nio.charset.StandardCharsets
|
|||||||
class PersistentRepository(private val context: Context) {
|
class PersistentRepository(private val context: Context) {
|
||||||
|
|
||||||
val serverDataDir: File
|
val serverDataDir: File
|
||||||
get() = File(context.filesDir, "gacy_server")
|
get() = File(context.filesDir, "server/build/build")
|
||||||
|
|
||||||
val miscDir: File
|
val miscDir: File
|
||||||
get() = File(serverDataDir, "misc")
|
get() = File(serverDataDir, "misc")
|
||||||
@@ -29,7 +29,41 @@ class PersistentRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isServerInstalled(): Boolean {
|
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() {
|
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,
|
STOPPED,
|
||||||
STARTING,
|
STARTING,
|
||||||
RUNNING,
|
RUNNING,
|
||||||
|
STOPPING,
|
||||||
ERROR
|
ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,62 @@
|
|||||||
package com.gacy.android.service
|
package com.gacy.android.service
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import com.chaquo.python.Python
|
||||||
import androidx.lifecycle.lifecycleScope
|
import com.chaquo.python.android.AndroidPlatform
|
||||||
import com.gacy.android.GaCyApplication
|
import com.gacy.android.GaCyApplication
|
||||||
import com.gacy.android.MainActivity
|
import com.gacy.android.MainActivity
|
||||||
import com.gacy.android.R
|
import com.gacy.android.R
|
||||||
|
import com.gacy.android.data.PersistentRepository
|
||||||
import com.gacy.android.data.PreferencesRepository
|
import com.gacy.android.data.PreferencesRepository
|
||||||
|
import com.gacy.android.data.UpdateRepository
|
||||||
import com.gacy.android.model.ServerExecutionMode
|
import com.gacy.android.model.ServerExecutionMode
|
||||||
import com.gacy.android.model.ServerStatus
|
import com.gacy.android.model.ServerStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
class ServerService : LifecycleService() {
|
class ServerLogReceiver {
|
||||||
|
fun onServerLog(msg: String) {
|
||||||
|
LogBuffer.append(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var serverJob: Job? = null
|
class ServerService : Service() {
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private var serverThread: Thread? = null
|
||||||
|
private var probeJob: Job? = null
|
||||||
|
private var executionJob: Job? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
_serverStatus.value = ServerStatus.STOPPED
|
_serverStatus.value = ServerStatus.STOPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
super.onStartCommand(intent, flags, startId)
|
|
||||||
val action = intent?.action ?: ACTION_START
|
val action = intent?.action ?: ACTION_START
|
||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
@@ -48,61 +73,194 @@ class ServerService : LifecycleService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val prefs = PreferencesRepository(this)
|
try {
|
||||||
val config = prefs.getServerConfig()
|
val prefs = PreferencesRepository(this)
|
||||||
|
val config = prefs.getServerConfig()
|
||||||
|
|
||||||
_serverStatus.value = ServerStatus.STARTING
|
_serverStatus.value = ServerStatus.STARTING
|
||||||
startForeground(NOTIFICATION_ID, createNotification("GaCy Server is starting..."))
|
startForegroundCompat("GaCy Server is starting...")
|
||||||
LogBuffer.append("--- GaCy Server Starting ---")
|
LogBuffer.append("--- GaCy Server Starting ---")
|
||||||
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
|
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
|
||||||
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
|
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
|
||||||
|
|
||||||
serverJob = lifecycleScope.launch {
|
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||||
try {
|
startLocalServerExecution(config.dispatchPort)
|
||||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
} else {
|
||||||
LogBuffer.append("Initializing local server runtime...")
|
startRemoteMonitor(config.remoteHost, config.dispatchPort)
|
||||||
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.")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogBuffer.append("Error starting server: ${e.message}")
|
|
||||||
_serverStatus.value = ServerStatus.ERROR
|
|
||||||
updateNotification("GaCy Server encountered an error")
|
|
||||||
}
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
LogBuffer.append("[Start Error] ${t.localizedMessage ?: t.message}")
|
||||||
|
t.printStackTrace()
|
||||||
|
_serverStatus.value = ServerStatus.ERROR
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocalServerExecution(dispatchPort: Int) {
|
||||||
|
executionJob?.cancel()
|
||||||
|
executionJob = serviceScope.launch {
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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: Throwable) {
|
||||||
|
LogBuffer.append("[Warning] Persistent validation: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
LogBuffer.append("[System] Launching embedded GaCy Python server...")
|
||||||
|
|
||||||
|
// Background TCP port readiness probe
|
||||||
|
probeJob?.cancel()
|
||||||
|
probeJob = serviceScope.launch {
|
||||||
|
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 {
|
||||||
|
if (!Python.isStarted()) {
|
||||||
|
Python.start(AndroidPlatform(applicationContext))
|
||||||
|
}
|
||||||
|
val py = Python.getInstance()
|
||||||
|
val runPpsr = py.getModule("run_ppsr")
|
||||||
|
val logReceiver = ServerLogReceiver()
|
||||||
|
runPpsr.callAttr("run_server", ppsrFile.absolutePath, logReceiver)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
LogBuffer.append("[Python Error] ${t.localizedMessage ?: t.message}")
|
||||||
|
t.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
probeJob?.cancel()
|
||||||
|
_serverStatus.value = ServerStatus.STOPPED
|
||||||
|
LogBuffer.append("[System] Server stopped.")
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}, "GaCy-PythonServer").apply {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
LogBuffer.append("[Execution Error] ${t.localizedMessage ?: t.message}")
|
||||||
|
t.printStackTrace()
|
||||||
|
_serverStatus.value = ServerStatus.ERROR
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRemoteMonitor(remoteHost: String, dispatchPort: Int) {
|
||||||
|
executionJob?.cancel()
|
||||||
|
executionJob = serviceScope.launch {
|
||||||
|
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() {
|
private fun stopServer() {
|
||||||
LogBuffer.append("--- GaCy Server Stopping ---")
|
LogBuffer.append("--- GaCy Server Stopping ---")
|
||||||
serverJob?.cancel()
|
_serverStatus.value = ServerStatus.STOPPING
|
||||||
serverJob = null
|
updateNotification("Stopping GaCy Server...")
|
||||||
_serverStatus.value = ServerStatus.STOPPED
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
probeJob?.cancel()
|
||||||
stopSelf()
|
probeJob = null
|
||||||
|
|
||||||
|
executionJob?.cancel()
|
||||||
|
executionJob = null
|
||||||
|
|
||||||
|
serviceScope.launch {
|
||||||
|
try {
|
||||||
|
if (Python.isStarted()) {
|
||||||
|
val py = Python.getInstance()
|
||||||
|
val runPpsr = py.getModule("run_ppsr")
|
||||||
|
runPpsr.callAttr("kill_server_process")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
LogBuffer.append("[Stop Error] ${e.localizedMessage ?: e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(1200)
|
||||||
|
serverThread?.interrupt()
|
||||||
|
serverThread = null
|
||||||
|
_serverStatus.value = ServerStatus.STOPPED
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restartServer() {
|
private fun restartServer() {
|
||||||
LogBuffer.append("Restarting GaCy Server...")
|
LogBuffer.append("Restarting GaCy Server...")
|
||||||
stopServer()
|
stopServer()
|
||||||
lifecycleScope.launch {
|
serviceScope.launch {
|
||||||
delay(800)
|
delay(1500)
|
||||||
startServer()
|
startServer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isPortOpen(host: String, port: Int): Boolean {
|
||||||
|
return try {
|
||||||
|
Socket().use { socket ->
|
||||||
|
socket.connect(InetSocketAddress(host, port), 400)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
GaCyApplication.CHANNEL_ID,
|
||||||
|
GaCyApplication.CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Background service for GaCy Game Server & Proxy"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotification(contentText: String): Notification {
|
private fun createNotification(contentText: String): Notification {
|
||||||
|
createNotificationChannel()
|
||||||
|
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
@@ -126,29 +284,63 @@ class ServerService : LifecycleService() {
|
|||||||
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID)
|
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID)
|
||||||
.setContentTitle("GaCy Android")
|
.setContentTitle("GaCy Android")
|
||||||
.setContentText(contentText)
|
.setContentText(contentText)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.drawable.app_icon)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
|
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(text: String) {
|
||||||
|
try {
|
||||||
|
createNotificationChannel()
|
||||||
|
val notification = createNotification(text)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// API 26..33
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
try {
|
||||||
|
val notification = createNotification(text)
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e2: Throwable) {
|
||||||
|
LogBuffer.append("[Foreground Warning] ${e2.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateNotification(contentText: String) {
|
private fun updateNotification(contentText: String) {
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
try {
|
||||||
manager.notify(NOTIFICATION_ID, createNotification(contentText))
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
|
manager?.notify(NOTIFICATION_ID, createNotification(contentText))
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopForegroundCompat() {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
probeJob?.cancel()
|
||||||
|
serverThread?.interrupt()
|
||||||
|
executionJob?.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
serverJob?.cancel()
|
|
||||||
_serverStatus.value = ServerStatus.STOPPED
|
_serverStatus.value = ServerStatus.STOPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
super.onBind(intent)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_START = "com.gacy.android.ACTION_START"
|
const val ACTION_START = "com.gacy.android.ACTION_START"
|
||||||
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
|
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
|
||||||
@@ -160,17 +352,45 @@ class ServerService : LifecycleService() {
|
|||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
|
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
|
||||||
context.startForegroundService(intent)
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
try {
|
||||||
|
context.startService(intent)
|
||||||
|
} catch (t2: Throwable) {
|
||||||
|
LogBuffer.append("[Service Start Error] ${t2.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_STOP }
|
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_STOP }
|
||||||
context.startService(intent)
|
try {
|
||||||
|
context.startService(intent)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
LogBuffer.append("[Service Stop Error] ${t.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restart(context: Context) {
|
fun restart(context: Context) {
|
||||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
|
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
|
||||||
context.startForegroundService(intent)
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
try {
|
||||||
|
context.startService(intent)
|
||||||
|
} catch (t2: Throwable) {
|
||||||
|
LogBuffer.append("[Service Restart Error] ${t2.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@ fun StatusBadge(status: ServerStatus, lang: AppLanguage) {
|
|||||||
val (color, text) = when (status) {
|
val (color, text) = when (status) {
|
||||||
ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中"))
|
ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中"))
|
||||||
ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中..."))
|
ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中..."))
|
||||||
|
ServerStatus.STOPPING -> Pair(GaCyAmber, tr(lang, "STOPPING...", "กำลังหยุด...", "停止中..."))
|
||||||
ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止"))
|
ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止"))
|
||||||
ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误"))
|
ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误"))
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
def load_pyc(path, module_name):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(f"PYC file not found at absolute path: {path}")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Could not load spec for module {module_name} from {path}")
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
import tarfile
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import traceback
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Primary PyPI base URL and mirrors for China fallback
|
||||||
|
PYPI_BASES = [
|
||||||
|
"https://pypi.org/pypi", # Official PyPI
|
||||||
|
"https://pypi.tuna.tsinghua.edu.cn/pypi", # Tsinghua (TUNA)
|
||||||
|
"https://mirrors.aliyun.com/pypi", # Aliyun
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_package_spec(package_spec):
|
||||||
|
match = re.match(r"^([a-zA-Z0-9_\-\.]+)(.*)$", package_spec.strip())
|
||||||
|
if match:
|
||||||
|
return match.group(1), match.group(2).strip()
|
||||||
|
return package_spec, ""
|
||||||
|
|
||||||
|
def _fetch_pypi_data(clean_name):
|
||||||
|
"""Attempt to fetch metadata JSON from PyPI and mirrors."""
|
||||||
|
for base_url in PYPI_BASES:
|
||||||
|
url = f"{base_url}/{clean_name}/json"
|
||||||
|
try:
|
||||||
|
print(f"Fetching metadata from: {base_url}…")
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to fetch metadata from {base_url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download_file(file_info_urls, dest_path):
|
||||||
|
"""Download package file with mirror URL rewriting if official PyPI fails."""
|
||||||
|
# List of candidate download URLs (original URL + mirror URL replacements)
|
||||||
|
for file_info in file_info_urls:
|
||||||
|
original_url = file_info.get("url")
|
||||||
|
if not original_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate candidate download URLs for this file
|
||||||
|
candidate_urls = [original_url]
|
||||||
|
|
||||||
|
# Rewrite official PyPI file URLs to mirror locations if needed
|
||||||
|
if "files.pythonhosted.org" in original_url:
|
||||||
|
candidate_urls.append(
|
||||||
|
original_url.replace("https://files.pythonhosted.org", "https://pypi.tuna.tsinghua.edu.cn")
|
||||||
|
)
|
||||||
|
candidate_urls.append(
|
||||||
|
original_url.replace("https://files.pythonhosted.org", "https://mirrors.aliyun.com/pypi")
|
||||||
|
)
|
||||||
|
|
||||||
|
for dl_url in candidate_urls:
|
||||||
|
try:
|
||||||
|
print(f"Downloading from {dl_url}…")
|
||||||
|
dl_req = urllib.request.Request(dl_url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||||
|
with urllib.request.urlopen(dl_req, timeout=30) as dl_resp, open(dest_path, "wb") as out_file:
|
||||||
|
out_file.write(dl_resp.read())
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Download failed from {dl_url}: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def install_package(package_name, target_dir):
|
||||||
|
try:
|
||||||
|
clean_name, version_spec = _parse_package_spec(package_name)
|
||||||
|
print(f"Resolving PyPI metadata for {clean_name} (spec: '{version_spec}')…")
|
||||||
|
|
||||||
|
data = _fetch_pypi_data(clean_name)
|
||||||
|
if not data:
|
||||||
|
print(f"Could not retrieve PyPI metadata for {clean_name} from any mirror.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
urls = data.get("urls", [])
|
||||||
|
|
||||||
|
if version_spec.startswith("=="):
|
||||||
|
req_version = version_spec.lstrip("=").strip()
|
||||||
|
releases = data.get("releases", {})
|
||||||
|
if req_version in releases:
|
||||||
|
urls = releases[req_version]
|
||||||
|
else:
|
||||||
|
print(f"Version {req_version} not found on PyPI for {clean_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
target_file_infos = []
|
||||||
|
|
||||||
|
# 1. Prefer pure-Python wheels
|
||||||
|
for file_info in urls:
|
||||||
|
filename = file_info.get("filename", "")
|
||||||
|
if filename.endswith("py3-none-any.whl") or filename.endswith("py2.py3-none-any.whl"):
|
||||||
|
target_file_infos.append(file_info)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2. Fallback to source distribution
|
||||||
|
if not target_file_infos:
|
||||||
|
for file_info in urls:
|
||||||
|
if file_info.get("packagetype") == "sdist":
|
||||||
|
target_file_infos.append(file_info)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_file_infos:
|
||||||
|
print(f"No compatible pure-Python release found for {clean_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.path.exists(target_dir):
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
|
||||||
|
safe_filename = clean_name.replace("/", "_")
|
||||||
|
dest_path = os.path.join(target_dir, f"_dl_{safe_filename}")
|
||||||
|
|
||||||
|
if not _download_file(target_file_infos, dest_path):
|
||||||
|
print(f"Failed to download payload for {clean_name} from all sources.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Extract package files
|
||||||
|
if dest_path.endswith(".whl") or dest_path.endswith(".zip") or target_file_infos[0].get("filename", "").endswith((".whl", ".zip")):
|
||||||
|
with zipfile.ZipFile(dest_path, "r") as zip_ref:
|
||||||
|
zip_ref.extractall(target_dir)
|
||||||
|
else:
|
||||||
|
with tarfile.open(dest_path, "r:gz") as tar_ref:
|
||||||
|
tar_ref.extractall(target_dir)
|
||||||
|
|
||||||
|
if os.path.exists(dest_path):
|
||||||
|
os.remove(dest_path)
|
||||||
|
|
||||||
|
# Handle 'src' layout if present
|
||||||
|
for root, dirs, files in os.walk(target_dir):
|
||||||
|
if os.path.basename(root) == "src":
|
||||||
|
for d in dirs:
|
||||||
|
src_pkg = os.path.join(root, d)
|
||||||
|
dest_pkg = os.path.join(target_dir, d)
|
||||||
|
if not os.path.exists(dest_pkg):
|
||||||
|
shutil.move(src_pkg, dest_pkg)
|
||||||
|
|
||||||
|
# Cleanup __pycache__
|
||||||
|
for root, dirs, files in os.walk(target_dir):
|
||||||
|
for d in list(dirs):
|
||||||
|
if d == "__pycache__":
|
||||||
|
shutil.rmtree(os.path.join(root, d), ignore_errors=True)
|
||||||
|
|
||||||
|
if target_dir not in sys.path:
|
||||||
|
sys.path.insert(0, target_dir)
|
||||||
|
|
||||||
|
print(f"Successfully installed {clean_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to install {package_name}: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import builtins
|
||||||
|
import time
|
||||||
|
import _thread
|
||||||
|
import gc
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
# === GLOBAL WARNING SUPPRESSION & METADATA SHIM (very early) ===
|
||||||
|
try:
|
||||||
|
import importlib.metadata
|
||||||
|
_orig_metadata_version = importlib.metadata.version
|
||||||
|
_orig_metadata_distribution = importlib.metadata.distribution
|
||||||
|
|
||||||
|
_KNOWN_VERSIONS = {
|
||||||
|
"werkzeug": "3.1.9",
|
||||||
|
"flask": "3.1.3",
|
||||||
|
"marshmallow": "3.20.0",
|
||||||
|
"requests": "2.34.2",
|
||||||
|
"urllib3": "2.8.0",
|
||||||
|
"protobuf": "7.36.2",
|
||||||
|
"click": "8.5.0",
|
||||||
|
"jinja2": "3.1.6",
|
||||||
|
"itsdangerous": "2.2.0",
|
||||||
|
"blinker": "1.9.0",
|
||||||
|
"markupsafe": "3.0.4",
|
||||||
|
"dataclasses-json": "0.6.7",
|
||||||
|
"dataclasses_json": "0.6.7",
|
||||||
|
"packaging": "26.3"
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShimedDistribution:
|
||||||
|
def __init__(self, name, version):
|
||||||
|
self.name = name
|
||||||
|
self.version = version
|
||||||
|
self.metadata = {"Name": name, "Version": version}
|
||||||
|
|
||||||
|
def _safe_metadata_version(name):
|
||||||
|
try:
|
||||||
|
return _orig_metadata_version(name)
|
||||||
|
except Exception:
|
||||||
|
clean = name.lower().replace("-", "_")
|
||||||
|
return _KNOWN_VERSIONS.get(name.lower(), _KNOWN_VERSIONS.get(clean, "1.0.0"))
|
||||||
|
|
||||||
|
def _safe_metadata_distribution(name):
|
||||||
|
try:
|
||||||
|
return _orig_metadata_distribution(name)
|
||||||
|
except Exception:
|
||||||
|
ver = _safe_metadata_version(name)
|
||||||
|
return _ShimedDistribution(name, ver)
|
||||||
|
|
||||||
|
importlib.metadata.version = _safe_metadata_version
|
||||||
|
importlib.metadata.distribution = _safe_metadata_distribution
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||||
|
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||||
|
warnings.filterwarnings("ignore", module="marshmallow.*")
|
||||||
|
warnings.filterwarnings("ignore", module="dataclasses_json.*")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from marshmallow.warnings import RemovedInMarshmallow4Warning
|
||||||
|
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||||
|
warnings.simplefilter("ignore", category=RemovedInMarshmallow4Warning)
|
||||||
|
print("[Shim] Global warning suppression activated")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_active_server_instance = None
|
||||||
|
|
||||||
|
# --- 1. Stream Redirector for Android UI ---
|
||||||
|
class AndroidLogStream:
|
||||||
|
def __init__(self, activity_context, original_stream):
|
||||||
|
self.activity_context = activity_context
|
||||||
|
self.original_stream = original_stream
|
||||||
|
self.buffer = ""
|
||||||
|
self._in_write = False
|
||||||
|
|
||||||
|
def write(self, message):
|
||||||
|
if self._in_write:
|
||||||
|
if self.original_stream:
|
||||||
|
try:
|
||||||
|
self.original_stream.write(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
self._in_write = True
|
||||||
|
try:
|
||||||
|
if self.original_stream:
|
||||||
|
try:
|
||||||
|
self.original_stream.write(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.buffer += message
|
||||||
|
while "\n" in self.buffer:
|
||||||
|
line, self.buffer = self.buffer.split("\n", 1)
|
||||||
|
clean_line = line.strip()
|
||||||
|
if clean_line and self.activity_context:
|
||||||
|
try:
|
||||||
|
self.activity_context.onServerLog(clean_line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._in_write = False
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
if self.original_stream:
|
||||||
|
try:
|
||||||
|
self.original_stream.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.buffer.strip() and self.activity_context:
|
||||||
|
try:
|
||||||
|
self.activity_context.onServerLog(self.buffer.strip())
|
||||||
|
self.buffer = ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- 2. Headless Input Mock ---
|
||||||
|
def _mock_input(prompt=""):
|
||||||
|
if prompt:
|
||||||
|
print(f"[CLI]: {prompt}")
|
||||||
|
time.sleep(2)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
builtins.input = _mock_input
|
||||||
|
|
||||||
|
# --- 3. Minimal Marshmallow Info ---
|
||||||
|
try:
|
||||||
|
import marshmallow
|
||||||
|
print(f"[Shim] marshmallow {marshmallow.__version__}")
|
||||||
|
except Exception:
|
||||||
|
print("[Shim] marshmallow not available")
|
||||||
|
|
||||||
|
# --- 3.1 Persistent Template & SafeDict Protection ---
|
||||||
|
DEFAULT_PERSISTENT = {
|
||||||
|
"send_hotfix": True,
|
||||||
|
"scene": {
|
||||||
|
"plane_id": 20313,
|
||||||
|
"x": 40748,
|
||||||
|
"y": 192819,
|
||||||
|
"z": 439218,
|
||||||
|
"calyx_x": 40748,
|
||||||
|
"calyx_y": 192810,
|
||||||
|
"calyx_z": 441000,
|
||||||
|
"map_layer": 2,
|
||||||
|
"calyx_group_id": 186,
|
||||||
|
"calyx_inst_id": 300001,
|
||||||
|
"calyx_entity_id": 1337,
|
||||||
|
"calyx_prop_id": 808
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"tb_gender": "Female",
|
||||||
|
"tb_path": "Elation",
|
||||||
|
"march_path": "Preservation",
|
||||||
|
"skin": {
|
||||||
|
"1001": 1100101,
|
||||||
|
"1310": 1131001,
|
||||||
|
"1415": 1141501,
|
||||||
|
"1407": 1140701,
|
||||||
|
"1501": 1150101
|
||||||
|
},
|
||||||
|
"lineup": [
|
||||||
|
1511,
|
||||||
|
1502,
|
||||||
|
1506,
|
||||||
|
1503
|
||||||
|
],
|
||||||
|
"global_passive": [
|
||||||
|
{
|
||||||
|
"name": "Castorice Global Passive",
|
||||||
|
"id": 140703,
|
||||||
|
"active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SW999 Global Passsive",
|
||||||
|
"id": 150602,
|
||||||
|
"active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aha Global Passive",
|
||||||
|
"id": 151104,
|
||||||
|
"active": True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ex_rank": [
|
||||||
|
{
|
||||||
|
"name": "JingYuan",
|
||||||
|
"avatar_id": 1204,
|
||||||
|
"active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DanHeng IL",
|
||||||
|
"avatar_id": 1213,
|
||||||
|
"active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Acheron",
|
||||||
|
"avatar_id": 1308,
|
||||||
|
"active": True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buff": [
|
||||||
|
1000121
|
||||||
|
],
|
||||||
|
"leader": 0
|
||||||
|
},
|
||||||
|
"key": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
class SafeDict(dict):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for k, v in list(self.items()):
|
||||||
|
if isinstance(v, dict) and not isinstance(v, SafeDict):
|
||||||
|
self[k] = SafeDict(v)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
import copy
|
||||||
|
if key not in self:
|
||||||
|
if key == 'avatar':
|
||||||
|
self['avatar'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']))
|
||||||
|
return self['avatar']
|
||||||
|
elif key == 'scene':
|
||||||
|
self['scene'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['scene']))
|
||||||
|
return self['scene']
|
||||||
|
elif key == 'skin':
|
||||||
|
self['skin'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']['skin']))
|
||||||
|
return self['skin']
|
||||||
|
elif key == 'lineup':
|
||||||
|
self['lineup'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['lineup'])
|
||||||
|
return self['lineup']
|
||||||
|
elif key == 'buff':
|
||||||
|
self['buff'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['buff'])
|
||||||
|
return self['buff']
|
||||||
|
elif key == 'global_passive':
|
||||||
|
self['global_passive'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['global_passive'])
|
||||||
|
return self['global_passive']
|
||||||
|
elif key == 'ex_rank':
|
||||||
|
self['ex_rank'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['ex_rank'])
|
||||||
|
return self['ex_rank']
|
||||||
|
elif key == 'tb_gender':
|
||||||
|
return 'Female'
|
||||||
|
elif key == 'tb_path':
|
||||||
|
return 'Elation'
|
||||||
|
elif key == 'march_path':
|
||||||
|
return 'Preservation'
|
||||||
|
elif key == 'leader':
|
||||||
|
return 0
|
||||||
|
elif key == 'send_hotfix':
|
||||||
|
return True
|
||||||
|
elif key == 'key':
|
||||||
|
return ''
|
||||||
|
val = super().__getitem__(key)
|
||||||
|
if isinstance(val, dict) and not isinstance(val, SafeDict):
|
||||||
|
val = SafeDict(val)
|
||||||
|
self[key] = val
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _sanitize_persistent_file(persistent_file):
|
||||||
|
try:
|
||||||
|
import json, os, copy
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(persistent_file)), exist_ok=True)
|
||||||
|
data = {}
|
||||||
|
if os.path.exists(persistent_file):
|
||||||
|
try:
|
||||||
|
with open(persistent_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
for k, v in DEFAULT_PERSISTENT.items():
|
||||||
|
if k not in data:
|
||||||
|
data[k] = copy.deepcopy(v)
|
||||||
|
modified = True
|
||||||
|
elif isinstance(v, dict) and isinstance(data[k], dict):
|
||||||
|
for sub_k, sub_v in v.items():
|
||||||
|
if sub_k not in data[k]:
|
||||||
|
data[k][sub_k] = copy.deepcopy(sub_v)
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified or not os.path.exists(persistent_file):
|
||||||
|
with open(persistent_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
print("[Patch] persistent.json validated and auto-healed with complete avatar/scene defaults")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Patch Warning] sanitize persistent: {e}")
|
||||||
|
|
||||||
|
# --- 4. Shutdown Logic ---
|
||||||
|
def kill_server_process():
|
||||||
|
global _active_server_instance
|
||||||
|
print("[Shutdown]: Force-stopping server execution...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import dispatch.dispatchserver as _ds
|
||||||
|
if hasattr(_ds, 'DispatchServer') and hasattr(_ds.DispatchServer, 'stop'):
|
||||||
|
_ds.DispatchServer.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import game.gameserver as _gs
|
||||||
|
if hasattr(_gs, 'GameServer') and hasattr(_gs.GameServer, 'stop'):
|
||||||
|
_gs.GameServer.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _active_server_instance is not None:
|
||||||
|
try:
|
||||||
|
if hasattr(_active_server_instance, 'shutdown'):
|
||||||
|
_active_server_instance.shutdown()
|
||||||
|
if hasattr(_active_server_instance, 'server_close'):
|
||||||
|
_active_server_instance.server_close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock = getattr(_active_server_instance, 'socket', None)
|
||||||
|
if sock:
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_active_server_instance = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for obj in gc.get_objects():
|
||||||
|
if isinstance(obj, socket.socket):
|
||||||
|
try:
|
||||||
|
obj.shutdown(socket.SHUT_RDWR)
|
||||||
|
obj.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(1.2)
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
try:
|
||||||
|
_thread.interrupt_main()
|
||||||
|
time.sleep(0.2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- 5. Main Execution Entrypoint ---
|
||||||
|
def run_server(ppsr_path, activity_context=None):
|
||||||
|
if activity_context is not None:
|
||||||
|
sys.stdout = AndroidLogStream(activity_context, sys.__stdout__)
|
||||||
|
sys.stderr = AndroidLogStream(activity_context, sys.__stderr__)
|
||||||
|
|
||||||
|
# Extra safety - re-apply on every start
|
||||||
|
try:
|
||||||
|
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ppsr_dir = os.path.dirname(os.path.abspath(ppsr_path))
|
||||||
|
misc_dir = os.path.join(ppsr_dir, "misc")
|
||||||
|
pkg_zip = os.path.join(ppsr_dir, "packages.zip")
|
||||||
|
|
||||||
|
if os.path.exists(ppsr_dir):
|
||||||
|
os.chdir(ppsr_dir)
|
||||||
|
print(f"Working directory locked: {os.getcwd()}")
|
||||||
|
|
||||||
|
for path in [ppsr_dir, misc_dir, pkg_zip]:
|
||||||
|
if os.path.exists(path) and path not in sys.path:
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||||
|
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||||
|
warnings.filterwarnings("ignore", module="marshmallow.*")
|
||||||
|
warnings.filterwarnings("ignore", module="dataclasses_json.*")
|
||||||
|
|
||||||
|
# Verify and auto-recover critical packages if needed
|
||||||
|
for pkg_name in ["requests", "werkzeug", "flask"]:
|
||||||
|
try:
|
||||||
|
__import__(pkg_name)
|
||||||
|
except ImportError:
|
||||||
|
print(f"[Shim] Package '{pkg_name}' missing, attempting auto-install...")
|
||||||
|
try:
|
||||||
|
import package_fetcher
|
||||||
|
package_fetcher.install_package(pkg_name, ppsr_dir)
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"[Shim Warning] Fallback failed for {pkg_name}: {ex}")
|
||||||
|
|
||||||
|
# --- Werkzeug Patch ---
|
||||||
|
try:
|
||||||
|
import werkzeug.serving
|
||||||
|
if hasattr(werkzeug.serving, "ThreadedWSGIServer"):
|
||||||
|
try:
|
||||||
|
werkzeug.serving.ThreadedWSGIServer._server_version = "Werkzeug/3.1.9"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_orig_make_server = werkzeug.serving.make_server
|
||||||
|
|
||||||
|
def _patched_make_server(*args, **kwargs):
|
||||||
|
global _active_server_instance
|
||||||
|
kwargs.pop('options', None)
|
||||||
|
server = _orig_make_server(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server._server_version = "Werkzeug/3.1.9"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(server, 'socket') and server.socket:
|
||||||
|
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_active_server_instance = server
|
||||||
|
print("[Server] Server instance patched and ready")
|
||||||
|
return server
|
||||||
|
|
||||||
|
werkzeug.serving.make_server = _patched_make_server
|
||||||
|
print("[Patch] Werkzeug make_server patched successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Patch Warning] Werkzeug patch: {e}")
|
||||||
|
|
||||||
|
# --- Persistent & Avatar Auto-healing Patch ---
|
||||||
|
_persistent_file = os.path.join(misc_dir, "persistent.json")
|
||||||
|
_sanitize_persistent_file(_persistent_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import game.services.manager as _mgr_mod
|
||||||
|
_orig_load_persistent = _mgr_mod.Manager.load_persistent
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _patched_load_persistent(cls):
|
||||||
|
_orig_load_persistent()
|
||||||
|
if hasattr(cls, 'persistent') and isinstance(cls.persistent, dict):
|
||||||
|
cls.persistent = SafeDict(cls.persistent)
|
||||||
|
|
||||||
|
_mgr_mod.Manager.load_persistent = _patched_load_persistent
|
||||||
|
if hasattr(_mgr_mod.Manager, 'persistent') and isinstance(_mgr_mod.Manager.persistent, dict):
|
||||||
|
_mgr_mod.Manager.persistent = SafeDict(_mgr_mod.Manager.persistent)
|
||||||
|
print("[Patch] Manager.load_persistent patched with SafeDict avatar protection")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Patch Warning] Manager persistent patch: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import game.router as _router_mod
|
||||||
|
_orig_router_handle = _router_mod.handle
|
||||||
|
|
||||||
|
def _safe_router_handle(data):
|
||||||
|
try:
|
||||||
|
return _orig_router_handle(data)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[Router Warning] Handled exception in router.handle: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
return b''
|
||||||
|
|
||||||
|
_router_mod.handle = _safe_router_handle
|
||||||
|
print("[Patch] game.router.handle wrapped with crash-guard")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Patch Warning] Router crash-guard: {e}")
|
||||||
|
|
||||||
|
print(f"Loading bytecode target: {ppsr_path}...")
|
||||||
|
|
||||||
|
with open(ppsr_path, "rb") as f:
|
||||||
|
code_bytes = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import marshal
|
||||||
|
code_obj = marshal.loads(code_bytes[16:])
|
||||||
|
except Exception:
|
||||||
|
code_obj = compile(code_bytes, ppsr_path, "exec")
|
||||||
|
|
||||||
|
main_globals = {
|
||||||
|
"__name__": "__main__",
|
||||||
|
"__file__": ppsr_path,
|
||||||
|
"__doc__": None,
|
||||||
|
"sys": sys,
|
||||||
|
"os": os,
|
||||||
|
"input": _mock_input
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
exec(code_obj, main_globals)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[System]: Server loop interrupted cleanly.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[System]: Server terminated: {e}")
|
||||||
Reference in new issue
Block a user