This commit is contained in:
2026-10-29 16:36:43 +08:00
parent 0cf352cabb
commit 2582f9ba3e
11 changed files with 428 additions and 49 deletions
+1 -2
View File
@@ -6,8 +6,7 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
- 安装并打开应用 - 安装并打开应用
- 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。 - 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。
- 在右上角选择需要的配置 - 选择需要的配置,可以取消勾选不需要的抓包步骤
- 可以取消勾选不需要的抓包步骤
- 点击开始,如果跳出建立 VPN 的提示,请允许 - 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束 - 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」 - 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk" applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk minSdk = appMinSdk
targetSdk = 37 targetSdk = 37
versionCode = 4 versionCode = 5
versionName = "1.3" versionName = "1.4"
} }
@@ -15,6 +15,7 @@ data class CaptureProfileStep(
val path: String, val path: String,
val fileName: String, val fileName: String,
var postAction: suspend (CaptureResult) -> Unit = {}, var postAction: suspend (CaptureResult) -> Unit = {},
val uploadType: String? = null,
) )
data class CaptureResult( data class CaptureResult(
@@ -1,7 +1,6 @@
package net.bluemangoo.indpjsk.data package net.bluemangoo.indpjsk.data
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
data class AppEntry( data class AppEntry(
@@ -16,10 +15,6 @@ object InstalledApps {
val pm = context.packageManager val pm = context.packageManager
return pm.getInstalledApplications(0) return pm.getInstalledApplications(0)
.asSequence() .asSequence()
.filter { info ->
(info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 ||
(info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
}
.mapNotNull { info -> .mapNotNull { info ->
runCatching { runCatching {
AppEntry( AppEntry(
@@ -0,0 +1,168 @@
package net.bluemangoo.indpjsk.data
import net.bluemangoo.indpjsk.upload.UploadMixin
import net.bluemangoo.indpjsk.upload.UploadProfileCustom
import net.bluemangoo.indpjsk.upload.UploadStore
import net.bluemangoo.indpjsk.upload.Uploader
object MixinProfiles {
fun merged(): List<CaptureProfile> {
val accs = collect()
if (accs.isEmpty()) return PredefinedProfiles.all
val builtIns = PredefinedProfiles.all
val builtInNames = builtIns.map { it.name }.toSet()
val usedProfileIds = builtIns.map { it.id }.toMutableSet()
val mergedBuiltIns = builtIns.map { base ->
val acc = accs[base.name] ?: return@map base
mergeInto(base, acc)
}
val mixinOnly = accs.values
.filter { it.name !in builtInNames }
.mapNotNull { acc ->
toProfile(acc, uniqueKey(usedProfileIds, acc.name, sanitize = true))
}
return mergedBuiltIns + mixinOnly
}
fun validate(mixin: UploadMixin): String? {
for ((name, steps) in mixin.profiles) {
if (steps.isEmpty()) {
return "配置「${name}」没有 steps"
}
for ((name1, hosts, target) in steps) {
if (target !in PredefinedProfiles.stepTargets &&
runCatching { Regex(target) }.isFailure
) {
return "步骤「${name1}」的 target 既不是内置类型,也不是合法的路径正则"
}
if (hosts.isEmpty()) {
return "步骤「${name1}」的 hosts 不能为空"
}
}
}
return null
}
private fun collect(): LinkedHashMap<String, ProfileAcc> {
val profiles = linkedMapOf<String, ProfileAcc>()
for ((_, profile) in UploadStore.load()) {
val custom = profile as? UploadProfileCustom ?: continue
val mixin = custom.mixin ?: continue
for ((name, steps) in mixin.profiles) {
val key = canonicalName(name)
if (key.isBlank()) continue
val profileAcc = profiles.getOrPut(key) { ProfileAcc(key) }
for ((name1, hosts, target1) in steps) {
val target = resolveTarget(target1) ?: continue
val stepKey = name1.trim().lowercase()
if (stepKey.isEmpty()) continue
val stepAcc = profileAcc.steps.getOrPut(stepKey) {
StepAcc(name1.trim(), target)
}
for (host in expandHosts(hosts)) {
if (stepAcc.hosts.none { it.equals(host, ignoreCase = true) }) {
stepAcc.hosts.add(host)
}
}
stepAcc.uploadProfileIds.add(custom.id)
}
}
}
return profiles
}
private fun mergeInto(base: CaptureProfile, acc: ProfileAcc): CaptureProfile {
val builtInStepKeys = base.steps.map { it.name.trim().lowercase() }.toSet()
val usedFileNames = base.steps.map { it.fileName }.toMutableSet()
val usedIds = base.steps.map { it.id }.toMutableSet()
val steps = base.steps.map { step ->
val extra = acc.steps[step.name.trim().lowercase()] ?: return@map step
step.copy(hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() })
} + acc.steps.values
.filter { it.name.trim().lowercase() !in builtInStepKeys }
.mapNotNull { buildStep(it, usedFileNames, usedIds) }
return base.copy(steps = steps)
}
private fun toProfile(acc: ProfileAcc, id: String): CaptureProfile? {
val usedFileNames = mutableSetOf<String>()
val usedIds = mutableSetOf<String>()
val steps = acc.steps.values.mapNotNull { buildStep(it, usedFileNames, usedIds) }
if (steps.isEmpty()) return null
return CaptureProfile(id = id, name = acc.name, steps = steps)
}
private fun buildStep(
step: StepAcc,
usedFileNames: MutableSet<String>,
usedIds: MutableSet<String>,
): CaptureProfileStep? {
if (step.hosts.isEmpty()) return null
val uploadIds = step.uploadProfileIds.toSet()
return CaptureProfileStep(
id = uniqueKey(usedIds, step.name, sanitize = false),
name = step.name,
hosts = step.hosts.toList(),
path = step.target.path,
fileName = uniqueKey(usedFileNames, step.name, sanitize = true),
postAction = { result -> Uploader.tryUpload(result, uploadIds) },
uploadType = step.target.uploadType,
)
}
private fun resolveTarget(raw: String): PredefinedProfiles.StepTarget? {
PredefinedProfiles.stepTargets[raw]?.let { return it }
if (raw.isBlank()) return null
return runCatching { Regex(raw) }
.map { PredefinedProfiles.StepTarget(path = raw, uploadType = raw) }
.getOrNull()
}
private fun expandHosts(hosts: List<String>): List<String> =
hosts.flatMap { host ->
PredefinedProfiles.hostAliases[host.trim().lowercase()] ?: listOf(host)
}.map { it.trim() }.filter { it.isNotEmpty() }
private fun canonicalName(name: String): String =
PredefinedProfiles.all
.firstOrNull {
it.name.equals(name, ignoreCase = true) || it.id.equals(name, ignoreCase = true)
}
?.name
?: name
private fun uniqueKey(used: MutableSet<String>, base: String, sanitize: Boolean): String {
val clean = if (sanitize) {
base.filter { it.isLetterOrDigit() || it == '_' || it == '-' }.ifEmpty { "step" }
} else {
base.ifBlank { "step" }
}
var candidate = clean
var index = 2
while (!used.add(candidate)) {
candidate = "${clean}_$index"
index++
}
return candidate
}
private class ProfileAcc(val name: String) {
val steps = linkedMapOf<String, StepAcc>()
}
private class StepAcc(
val name: String,
val target: PredefinedProfiles.StepTarget,
) {
val hosts = mutableListOf<String>()
val uploadProfileIds = linkedSetOf<String>()
}
}
@@ -6,6 +6,25 @@ object PredefinedProfiles {
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$" private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$"
private const val MYSEKAI_PATH = private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True" "/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
private const val MYSEKAI_BIRTHDAY_PATH =
"/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery"
data class StepTarget(val path: String, val uploadType: String)
val stepTargets: Map<String, StepTarget> = mapOf(
"suite" to StepTarget(SUITE_PATH, "suite"),
"mysekai" to StepTarget(MYSEKAI_PATH, "mysekai"),
"birthday" to StepTarget(MYSEKAI_BIRTHDAY_PATH, "birthday"),
)
val hostAliases: Map<String, List<String>>
get() = mapOf(
"cn" to CN_HOSTS,
"jp" to JP_HOSTS,
"en" to EN_HOSTS,
"tw" to TW_HOSTS,
"kr" to KR_HOSTS
)
private fun suiteStep(hosts: List<String>) = CaptureProfileStep( private fun suiteStep(hosts: List<String>) = CaptureProfileStep(
id = "suite", id = "suite",
@@ -33,7 +52,7 @@ object PredefinedProfiles {
id = "mysekai_birthday", id = "mysekai_birthday",
name = "烤森生日双叶", name = "烤森生日双叶",
hosts = hosts, hosts = hosts,
path = "/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery", path = MYSEKAI_BIRTHDAY_PATH,
fileName = "mysekai_birthday", fileName = "mysekai_birthday",
postAction = { result -> postAction = { result ->
Uploader.tryUpload(result) Uploader.tryUpload(result)
@@ -108,5 +127,6 @@ object PredefinedProfiles {
), ),
) )
fun byId(id: String): CaptureProfile? = all.firstOrNull { it.id == id } fun byId(id: String): CaptureProfile? =
MixinProfiles.merged().firstOrNull { it.id == id }
} }
@@ -5,15 +5,17 @@ import android.content.Intent
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import net.bluemangoo.indpjsk.data.CaptureProfile import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.data.PredefinedProfiles import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.state.CaptureStatus import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.upload.UploadStore
import net.bluemangoo.indpjsk.vpn.CaptureVpnService import net.bluemangoo.indpjsk.vpn.CaptureVpnService
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.io.File import java.io.File
@@ -27,24 +29,28 @@ data class CaptureUiState(
class CaptureViewModel : ViewModel() { class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state private fun allProfiles(): List<CaptureProfile> = MixinProfiles.merged()
.map { s ->
CaptureUiState( val uiState: StateFlow<CaptureUiState> = combine(
profiles = PredefinedProfiles.all, CaptureStateHolder.state,
selectedProfile = s.profile, UploadStore.revision,
status = s.status, ) { s, _ ->
lastCapturedFile = s.lastCapturedFile, val profiles = allProfiles()
errorMessage = s.errorMessage CaptureUiState(
) profiles = profiles,
} selectedProfile = profiles.firstOrNull { it.id == s.profile.id } ?: s.profile,
.stateIn( status = s.status,
scope = viewModelScope, lastCapturedFile = s.lastCapturedFile,
started = SharingStarted.WhileSubscribed(5_000), errorMessage = s.errorMessage
initialValue = CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
) )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = allProfiles(),
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
)
fun selectProfile(profile: CaptureProfile) { fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile) CaptureStateHolder.selectProfile(profile)
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.upload.* import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject import org.json.JSONObject
@@ -216,10 +217,6 @@ private fun UploadTableRow(
} }
} }
/**
* 添加 / 编辑共用的界面。
* 只有构造函数需要的字段需要用户填写;额外信息(extraInfo)只在下方展示。
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun UploadEditScreen( private fun UploadEditScreen(
@@ -246,7 +243,7 @@ private fun UploadEditScreen(
} }
var urlMode by remember(initial) { var urlMode by remember(initial) {
mutableStateOf( mutableStateOf(
when (val url = (original as? UploadProfileCustom)?.uploadUrl) { when ((original as? UploadProfileCustom)?.uploadUrl) {
is UploadUrlEach -> UploadStore.URL_MODE_EACH is UploadUrlEach -> UploadStore.URL_MODE_EACH
is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL
null -> UploadStore.URL_MODE_GENERAL null -> UploadStore.URL_MODE_GENERAL
@@ -270,6 +267,13 @@ private fun UploadEditScreen(
(original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList() (original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList()
) )
} }
var mixinText by remember(initial) {
mutableStateOf(
(original as? UploadProfileCustom)?.mixin
?.let { UploadStore.encodeMixin(it).toString() }
?: ""
)
}
var typeExpanded by remember { mutableStateOf(false) } var typeExpanded by remember { mutableStateOf(false) }
var urlModeExpanded by remember { mutableStateOf(false) } var urlModeExpanded by remember { mutableStateOf(false) }
@@ -291,7 +295,13 @@ private fun UploadEditScreen(
else -> null else -> null
} }
val canSave = idError == null && codeError == null
val parsedMixin = runCatching { UploadStore.parseMixin(mixinText) }
val mixinError = parsedMixin.exceptionOrNull()
?.let { "Mixin JSON 解析失败:${it.message}" }
?: parsedMixin.getOrNull()?.let { MixinProfiles.validate(it) }
val canSave = idError == null && codeError == null && mixinError == null
fun buildProfile(): UploadProfile = when (type) { fun buildProfile(): UploadProfile = when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim()) UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim())
@@ -312,6 +322,7 @@ private fun UploadEditScreen(
.map { it.first.trim() to it.second.trim() } .map { it.first.trim() to it.second.trim() }
.filter { it.first.isNotEmpty() } .filter { it.first.isNotEmpty() }
.toMap(), .toMap(),
mixin = parsedMixin.getOrNull(),
) )
} }
@@ -340,7 +351,6 @@ private fun UploadEditScreen(
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// ID 和类型放在同一行
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -481,6 +491,12 @@ private fun UploadEditScreen(
HeadersEditor(headers = headers, onChange = { headers = it }) HeadersEditor(headers = headers, onChange = { headers = it })
MixinEditor(
value = mixinText,
onValueChange = { mixinText = it.prependIndent().replace("\n", " ") },
error = mixinError,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
@@ -550,7 +566,14 @@ private fun UploadEditScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 160.dp), .heightIn(min = 160.dp),
placeholder = { Text("{ ... }") }, placeholder = {
Text(
text = "{\"type\":\"CUSTOM\",\"id\":\"my-upload\"," +
"\"uploadUrl\":{\"mode\":\"general\",\"url\":\"https://example.com/upload\"}," +
"\"headers\":{}}",
style = MaterialTheme.typography.bodySmall
)
},
textStyle = MaterialTheme.typography.bodySmall textStyle = MaterialTheme.typography.bodySmall
) )
importError?.let { importError?.let {
@@ -585,6 +608,9 @@ private fun UploadEditScreen(
} }
} }
headers = imported.customHeaders.map { it.key to it.value } headers = imported.customHeaders.map { it.key to it.value }
mixinText = imported.mixin
?.let { UploadStore.encodeMixin(it).toString() }
?: ""
importDialog = false importDialog = false
} }
@@ -702,6 +728,31 @@ private fun HeadersEditor(
} }
} }
@Composable
private fun MixinEditor(
value: String,
onValueChange: (String) -> Unit,
error: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
FormRow("Mixin") {
CompactTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
isError = error != null,
)
}
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable @Composable
private fun FormRow( private fun FormRow(
label: String, label: String,
@@ -803,7 +854,7 @@ private fun CompactDropdown(
} }
} }
private val helpLinkRegex = Regex("""\[([^\]]+)]\(([^)]+)\)""") private val helpLinkRegex = Regex("""\[([^]]+)]\(([^)]+)\)""")
@Composable @Composable
private fun HelpText(text: String) { private fun HelpText(text: String) {
@@ -1,5 +1,8 @@
package net.bluemangoo.indpjsk.upload package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONArray import org.json.JSONArray
@@ -14,6 +17,9 @@ object UploadStore {
private const val VERSION = 1 private const val VERSION = 1
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
private const val KEY_VERSION = "version" private const val KEY_VERSION = "version"
private const val KEY_PROFILES = "profiles" private const val KEY_PROFILES = "profiles"
private const val KEY_ENABLED = "enabled" private const val KEY_ENABLED = "enabled"
@@ -25,6 +31,11 @@ object UploadStore {
private const val KEY_MODE = "mode" private const val KEY_MODE = "mode"
private const val KEY_URL = "url" private const val KEY_URL = "url"
private const val KEY_HEADERS = "headers" private const val KEY_HEADERS = "headers"
private const val KEY_MIXIN = "mixin"
private const val KEY_NAME = "name"
private const val KEY_STEPS = "steps"
private const val KEY_HOSTS = "hosts"
private const val KEY_TARGET = "target"
private const val KEY_SUITE = "suite" private const val KEY_SUITE = "suite"
private const val KEY_MYSEKAI = "mysekai" private const val KEY_MYSEKAI = "mysekai"
@@ -42,6 +53,7 @@ object UploadStore {
fun save(entries: List<UploadEntry>) { fun save(entries: List<UploadEntry>) {
SettingsStore.uploadProfilesJson = encode(entries) SettingsStore.uploadProfilesJson = encode(entries)
_revision.value += 1
} }
fun encode(entries: List<UploadEntry>): String { fun encode(entries: List<UploadEntry>): String {
@@ -85,6 +97,7 @@ object UploadStore {
is UploadProfileCustom -> { is UploadProfileCustom -> {
put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl)) put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl))
put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>)) put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>))
profile.mixin?.let { put(KEY_MIXIN, encodeMixin(it)) }
} }
} }
} }
@@ -101,10 +114,71 @@ object UploadStore {
id = id, id = id,
uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)), uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)),
customHeaders = decodeHeaders(obj.optJSONObject(KEY_HEADERS)), customHeaders = decodeHeaders(obj.optJSONObject(KEY_HEADERS)),
mixin = decodeMixin(obj.optJSONObject(KEY_MIXIN)),
) )
} }
} }
fun parseMixin(text: String): UploadMixin? {
if (text.isBlank()) return null
return decodeMixin(JSONObject(text))
}
fun encodeMixin(mixin: UploadMixin): JSONObject = JSONObject().apply {
put(KEY_PROFILES, JSONArray().apply {
mixin.profiles.forEach { profile ->
put(JSONObject().apply {
put(KEY_NAME, profile.name)
put(KEY_STEPS, JSONArray().apply {
profile.steps.forEach { step ->
put(JSONObject().apply {
put(KEY_NAME, step.name)
put(KEY_HOSTS, JSONArray(step.hosts))
put(KEY_TARGET, step.target)
})
}
})
})
}
})
}
fun decodeMixin(obj: JSONObject?): UploadMixin? {
if (obj == null) return null
val profileArray = obj.optJSONArray(KEY_PROFILES) ?: return null
val profiles = mutableListOf<UploadMixinProfile>()
for (i in 0 until profileArray.length()) {
val profileObj = profileArray.optJSONObject(i) ?: continue
val name = profileObj.optString(KEY_NAME).trim()
if (name.isEmpty()) continue
val steps = mutableListOf<UploadMixinStep>()
val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray()
for (j in 0 until stepArray.length()) {
val stepObj = stepArray.optJSONObject(j) ?: continue
val stepName = stepObj.optString(KEY_NAME).trim()
if (stepName.isEmpty()) continue
val hosts = mutableListOf<String>()
val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray()
for (k in 0 until hostArray.length()) {
val host = hostArray.optString(k).trim()
if (host.isNotEmpty()) hosts.add(host)
}
steps.add(
UploadMixinStep(
name = stepName,
hosts = hosts,
target = stepObj.optString(KEY_TARGET).trim(),
)
)
}
profiles.add(UploadMixinProfile(name, steps))
}
return UploadMixin(profiles)
}
private fun encodeUrl(url: UploadUrl): JSONObject = when (url) { private fun encodeUrl(url: UploadUrl): JSONObject = when (url) {
is UploadUrlGeneral -> JSONObject().apply { is UploadUrlGeneral -> JSONObject().apply {
put(KEY_MODE, URL_MODE_GENERAL) put(KEY_MODE, URL_MODE_GENERAL)
@@ -17,14 +17,16 @@ import kotlin.random.Random
object Uploader { object Uploader {
private const val CHUNK_SIZE = 1024 * 1024 private const val CHUNK_SIZE = 1024 * 1024
suspend fun tryUpload(result: CaptureResult) { suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set<String>? = null) {
val entries = UploadStore.load().filter { it.enabled } val entries = UploadStore.load()
.filter { it.enabled }
.filter { onlyProfileIds == null || it.profile.id in onlyProfileIds }
if (entries.isEmpty()) return if (entries.isEmpty()) return
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)") VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)")
for ((_, profile) in entries) { for ((_, profile) in entries) {
try { try {
val uploaded = uploadChunked(profile, result.url, result.file) val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType)
if (uploaded) { if (uploaded) {
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成") VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
} else { } else {
@@ -43,18 +45,20 @@ object Uploader {
profile: UploadProfile, profile: UploadProfile,
originalUrl: String, originalUrl: String,
file: File, file: File,
type: String? = null,
): Boolean = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" } require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val type = when (file.name) { val uploadType = type ?: when (file.name) {
"suite" -> "suite" "suite" -> "suite"
"mysekai" -> "mysekai" "mysekai" -> "mysekai"
"mysekai_birthday" -> "birthday" "mysekai_birthday" -> "birthday"
else -> { else -> null
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
} }
val url = profile.getUploadUrl(type)?.takeIf { it.isNotBlank() } if (uploadType == null) {
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
val url = profile.getUploadUrl(uploadType)?.takeIf { it.isNotBlank() }
?: return@withContext false ?: return@withContext false
val uploadId = randomUploadId() val uploadId = randomUploadId()
@@ -160,6 +164,27 @@ enum class UploadProfileType(val displayName: String) {
CUSTOM("自定义") CUSTOM("自定义")
} }
/** mixin:在自定义上传配置里顺带定义抓包配置。 */
data class UploadMixin(
val profiles: List<UploadMixinProfile>,
)
data class UploadMixinProfile(
val name: String,
val steps: List<UploadMixinStep>,
)
/**
* name: 步骤显示名(同一 profile 内同名步骤会合并)
* hosts: 域名列表,可以填 `cn` 这样的预设别名
* target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则
*/
data class UploadMixinStep(
val name: String,
val hosts: List<String>,
val target: String,
)
sealed interface UploadProfile { sealed interface UploadProfile {
val id: String val id: String
val type: UploadProfileType val type: UploadProfileType
@@ -274,8 +299,12 @@ class UploadProfile8823(override val id: String) : UploadProfile {
override val extraInfo: Map<String, String> = emptyMap() override val extraInfo: Map<String, String> = emptyMap()
} }
class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map<String, String>) : class UploadProfileCustom(
UploadProfile { override val id: String,
val uploadUrl: UploadUrl,
val customHeaders: Map<String, String>,
val mixin: UploadMixin? = null,
) : UploadProfile {
override val type: UploadProfileType = UploadProfileType.CUSTOM override val type: UploadProfileType = UploadProfileType.CUSTOM
override val helpText: String? = null override val helpText: String? = null
+36
View File
@@ -0,0 +1,36 @@
// noinspection JSUnusedGlobalSymbols
interface UploadUrlGeneral {
mode: "general";
url: string;
}
interface UploadUrlEach {
mode: "each";
suite: string | null;
mysekai: string | null;
birthday: string | null;
}
interface UploadMixinStep {
name: string;
hosts: ("cn" | "jp" | "en" | "tw" | "kr" | string)[];
target: "suite" | "mysekai" | "birthday" | "" | string;
}
interface UploadMixinProfile {
name: string;
steps: UploadMixinStep[];
}
interface UploadMixin {
profiles: UploadMixinProfile[];
}
export interface UploadProfileCustom {
type: "CUSTOM";
id: string;
uploadUrl: UploadUrlGeneral | UploadUrlEach;
headers: Record<string, "$original_url" | "$file_name" | "$user_id" | string>;
mixin?: UploadMixin;
}