1 Commits
Author SHA1 Message Date
Bluemangoo 544a68bbd4 修自定义配置
null、导入、mixin ignore
2026-10-30 23:40:05 +08:00
9 changed files with 429 additions and 79 deletions
+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 = 5 versionCode = 6
versionName = "1.4" versionName = "1.5"
} }
@@ -16,6 +16,7 @@ data class CaptureProfileStep(
val fileName: String, val fileName: String,
var postAction: suspend (CaptureResult) -> Unit = {}, var postAction: suspend (CaptureResult) -> Unit = {},
val uploadType: String? = null, val uploadType: String? = null,
val explicitUploadIds: Set<String> = emptySet(),
) )
data class CaptureResult( data class CaptureResult(
@@ -45,9 +45,24 @@ object MixinProfiles {
} }
} }
} }
val upload = mixin.upload
if (upload != null && !upload.ignoreAll) {
for ((region, steps) in upload.ignore) {
if (region.lowercase() !in REGION_IDS) {
return "\"$region\" 不是内置配置"
}
val invalidStep = steps.firstOrNull { it.lowercase() !in PredefinedProfiles.stepTargets }
if (invalidStep != null) {
return "\"$invalidStep\" 不是内置类型"
}
}
}
return null return null
} }
private val REGION_IDS = setOf("cn", "jp", "en", "tw", "kr")
private fun collect(): LinkedHashMap<String, ProfileAcc> { private fun collect(): LinkedHashMap<String, ProfileAcc> {
val profiles = linkedMapOf<String, ProfileAcc>() val profiles = linkedMapOf<String, ProfileAcc>()
@@ -84,7 +99,10 @@ object MixinProfiles {
val steps = base.steps.map { step -> val steps = base.steps.map { step ->
val extra = acc.steps[step.name.trim().lowercase()] ?: return@map step val extra = acc.steps[step.name.trim().lowercase()] ?: return@map step
step.copy(hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() }) step.copy(
hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() },
explicitUploadIds = step.explicitUploadIds + extra.uploadProfileIds,
)
} + acc.steps.values } + acc.steps.values
.filter { it.name.trim().lowercase() !in builtInStepKeys } .filter { it.name.trim().lowercase() !in builtInStepKeys }
.mapNotNull { buildStep(it, usedFileNames, usedIds) } .mapNotNull { buildStep(it, usedFileNames, usedIds) }
@@ -5,7 +5,10 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,10 +30,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink 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 androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.MixinProfiles import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.upload.* import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicReference
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -43,6 +54,7 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
if (target != null) { if (target != null) {
UploadEditScreen( UploadEditScreen(
initial = target.initial, initial = target.initial,
isNew = target.index == null,
takenIds = entries takenIds = entries
.filterIndexed { index, _ -> index != target.index } .filterIndexed { index, _ -> index != target.index }
.map { it.profile.id } .map { it.profile.id }
@@ -142,6 +154,17 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
) )
} }
} }
}
@Composable
private fun ImportAction(text: String, onClick: () -> Unit) {
ListItem(
headlineContent = {
Text(text, style = MaterialTheme.typography.bodyMedium)
},
modifier = Modifier.clickable(onClick = onClick)
)
} }
private data class UploadEditTarget( private data class UploadEditTarget(
@@ -221,6 +244,7 @@ private fun UploadTableRow(
@Composable @Composable
private fun UploadEditScreen( private fun UploadEditScreen(
initial: UploadEntry?, initial: UploadEntry?,
isNew: Boolean,
takenIds: Set<String>, takenIds: Set<String>,
onCancel: () -> Unit, onCancel: () -> Unit,
onSave: (UploadEntry) -> Unit, onSave: (UploadEntry) -> Unit,
@@ -282,6 +306,91 @@ private fun UploadEditScreen(
var importText by remember { mutableStateOf("") } var importText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) } var importError by remember { mutableStateOf<String?>(null) }
// 从 JSON 导入:URL / 文件 / 直接打开 JSON 编辑器
var showImportMenu by remember { mutableStateOf(false) }
var showUrlDialog by remember { mutableStateOf(false) }
var urlText by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
var loadError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val importJob = remember { AtomicReference<Job?>(null) }
val importConnection = remember { AtomicReference<HttpURLConnection?>(null) }
fun openJsonEditor(text: String) {
importText = text
importError = null
importDialog = true
}
fun finishLoad(text: String) {
if (text.isBlank()) {
loadError = "内容为空"
} else {
openJsonEditor(text)
}
}
fun cancelImport() {
importConnection.getAndSet(null)?.disconnect()
importJob.getAndSet(null)?.cancel()
loading = false
}
fun loadFromUrl() {
val url = urlText.trim()
if (url.isEmpty()) {
loadError = "请填写 URL"
return
}
loading = true
importJob.set(scope.launch {
try {
val result = withContext(Dispatchers.IO) {
runCatching {
val conn = URL(url).openConnection() as HttpURLConnection
importConnection.set(conn)
conn.instanceFollowRedirects = true
conn.connectTimeout = 15_000
conn.readTimeout = 20_000
conn.inputStream.bufferedReader().use { it.readText() }
}
}
result.fold(
onSuccess = { finishLoad(it) },
onFailure = { loadError = "从 URL 读取失败:${it.message}" },
)
} finally {
importConnection.getAndSet(null)?.disconnect()
loading = false
}
})
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
loading = true
importJob.set(scope.launch {
try {
val result = withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(uri)
?.bufferedReader()
?.use { it.readText() }
?: error("无法打开文件")
}
}
result.fold(
onSuccess = { finishLoad(it) },
onFailure = { loadError = "读取文件失败:${it.message}" },
)
} finally {
loading = false
}
})
}
}
val trimmedId = id.trim() val trimmedId = id.trim()
val idError = when { val idError = when {
trimmedId.isEmpty() -> "ID 不能为空" trimmedId.isEmpty() -> "ID 不能为空"
@@ -331,7 +440,7 @@ private fun UploadEditScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (initial == null) "添加上传配置" else "编辑上传配置") }, title = { Text(if (isNew) "添加上传配置" else "编辑上传配置") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onCancel) { IconButton(onClick = onCancel) {
Icon( Icon(
@@ -499,11 +608,7 @@ private fun UploadEditScreen(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton( OutlinedButton(
onClick = { onClick = { showImportMenu = true },
importText = ""
importError = null
importDialog = true
},
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(40.dp), .height(40.dp),
@@ -566,14 +671,6 @@ private fun UploadEditScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 160.dp), .heightIn(min = 160.dp),
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 {
@@ -625,6 +722,93 @@ private fun UploadEditScreen(
) )
} }
if (showImportMenu) {
AlertDialog(
onDismissRequest = { showImportMenu = false },
title = { Text("导入 JSON") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
ImportAction("从 URL 导入") {
showImportMenu = false
urlText = ""
showUrlDialog = true
}
ImportAction("选择文件") {
showImportMenu = false
filePicker.launch(
arrayOf("application/json", "text/plain", "application/octet-stream")
)
}
ImportAction("打开编辑器") {
showImportMenu = false
openJsonEditor("")
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showImportMenu = false }) { Text("取消") }
}
)
}
if (showUrlDialog) {
AlertDialog(
onDismissRequest = { showUrlDialog = false },
title = { Text("从 URL 导入") },
text = {
OutlinedTextField(
value = urlText,
onValueChange = { urlText = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("URL") },
placeholder = { Text("") },
textStyle = MaterialTheme.typography.bodySmall
)
},
confirmButton = {
TextButton(onClick = {
showUrlDialog = false
loadFromUrl()
}) { Text("导入") }
},
dismissButton = {
TextButton(onClick = { showUrlDialog = false }) { Text("取消") }
}
)
}
if (loading) {
AlertDialog(
onDismissRequest = { cancelImport() },
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
title = { Text("正在导入") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
TextButton(onClick = { cancelImport() }) { Text("取消") }
}
)
}
loadError?.let { message ->
AlertDialog(
onDismissRequest = { loadError = null },
title = { Text("加载失败") },
text = { Text(message) },
confirmButton = {
TextButton(onClick = { loadError = null }) { Text("确定") }
}
)
}
if (exportDialog) { if (exportDialog) {
val json = runCatching { val json = runCatching {
UploadStore.encodeProfile(buildProfile()).toString(2) UploadStore.encodeProfile(buildProfile()).toString(2)
@@ -36,6 +36,8 @@ object UploadStore {
private const val KEY_STEPS = "steps" private const val KEY_STEPS = "steps"
private const val KEY_HOSTS = "hosts" private const val KEY_HOSTS = "hosts"
private const val KEY_TARGET = "target" private const val KEY_TARGET = "target"
private const val KEY_UPLOAD = "upload"
private const val KEY_IGNORE = "ignore"
private const val KEY_SUITE = "suite" private const val KEY_SUITE = "suite"
private const val KEY_MYSEKAI = "mysekai" private const val KEY_MYSEKAI = "mysekai"
@@ -79,7 +81,7 @@ object UploadStore {
val obj = item.optJSONObject(KEY_PROFILE) ?: continue val obj = item.optJSONObject(KEY_PROFILE) ?: continue
val profile = decodeProfile(obj) val profile = decodeProfile(obj)
if (profile == null) { if (profile == null) {
VpnLog.warn("跳过无法识别的上传配置: ${obj.optString(KEY_TYPE)}") VpnLog.warn("跳过无法识别的上传配置: ${obj.textOrNull(KEY_TYPE).orEmpty()}")
continue continue
} }
out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile) out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile)
@@ -103,12 +105,14 @@ object UploadStore {
} }
fun decodeProfile(obj: JSONObject): UploadProfile? { fun decodeProfile(obj: JSONObject): UploadProfile? {
val id = obj.optString(KEY_ID) val id = obj.textOrNull(KEY_ID).orEmpty()
val type = UploadProfileType.entries.firstOrNull { it.name == obj.optString(KEY_TYPE) } val typeName = obj.textOrNull(KEY_TYPE).orEmpty()
val type = UploadProfileType.entries.firstOrNull { it.name == typeName }
?: return null ?: return null
val uploadCode = obj.textOrNull(KEY_UPLOAD_CODE).orEmpty()
return when (type) { return when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(id, obj.optString(KEY_UPLOAD_CODE)) UploadProfileType.HARUKI -> UploadProfileHaruki(id, uploadCode)
UploadProfileType.REASONA -> UploadProfileReasona(id, obj.optString(KEY_UPLOAD_CODE)) UploadProfileType.REASONA -> UploadProfileReasona(id, uploadCode)
UploadProfileType._8823 -> UploadProfile8823(id) UploadProfileType._8823 -> UploadProfile8823(id)
UploadProfileType.CUSTOM -> UploadProfileCustom( UploadProfileType.CUSTOM -> UploadProfileCustom(
id = id, id = id,
@@ -125,6 +129,7 @@ object UploadStore {
} }
fun encodeMixin(mixin: UploadMixin): JSONObject = JSONObject().apply { fun encodeMixin(mixin: UploadMixin): JSONObject = JSONObject().apply {
if (mixin.profiles.isNotEmpty()) {
put(KEY_PROFILES, JSONArray().apply { put(KEY_PROFILES, JSONArray().apply {
mixin.profiles.forEach { profile -> mixin.profiles.forEach { profile ->
put(JSONObject().apply { put(JSONObject().apply {
@@ -142,27 +147,31 @@ object UploadStore {
} }
}) })
} }
mixin.upload?.let { put(KEY_UPLOAD, encodeUpload(it)) }
}
fun decodeMixin(obj: JSONObject?): UploadMixin? { fun decodeMixin(obj: JSONObject?): UploadMixin? {
if (obj == null) return null if (obj == null) return null
val profileArray = obj.optJSONArray(KEY_PROFILES) ?: return null
val profiles = mutableListOf<UploadMixinProfile>() val profiles = mutableListOf<UploadMixinProfile>()
val profileArray = obj.optJSONArray(KEY_PROFILES)
if (profileArray != null) {
for (i in 0 until profileArray.length()) { for (i in 0 until profileArray.length()) {
val profileObj = profileArray.optJSONObject(i) ?: continue val profileObj = profileArray.optJSONObject(i) ?: continue
val name = profileObj.optString(KEY_NAME).trim() val name = profileObj.textOrNull(KEY_NAME).orEmpty().trim()
if (name.isEmpty()) continue if (name.isEmpty()) continue
val steps = mutableListOf<UploadMixinStep>() val steps = mutableListOf<UploadMixinStep>()
val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray() val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray()
for (j in 0 until stepArray.length()) { for (j in 0 until stepArray.length()) {
val stepObj = stepArray.optJSONObject(j) ?: continue val stepObj = stepArray.optJSONObject(j) ?: continue
val stepName = stepObj.optString(KEY_NAME).trim() val stepName = stepObj.textOrNull(KEY_NAME).orEmpty().trim()
if (stepName.isEmpty()) continue if (stepName.isEmpty()) continue
val hosts = mutableListOf<String>() val hosts = mutableListOf<String>()
val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray() val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray()
for (k in 0 until hostArray.length()) { for (k in 0 until hostArray.length()) {
val host = hostArray.optString(k).trim() val host = hostArray.textOrNull(k).orEmpty().trim()
if (host.isNotEmpty()) hosts.add(host) if (host.isNotEmpty()) hosts.add(host)
} }
@@ -170,13 +179,76 @@ object UploadStore {
UploadMixinStep( UploadMixinStep(
name = stepName, name = stepName,
hosts = hosts, hosts = hosts,
target = stepObj.optString(KEY_TARGET).trim(), target = stepObj.textOrNull(KEY_TARGET).orEmpty().trim(),
) )
) )
} }
profiles.add(UploadMixinProfile(name, steps)) profiles.add(UploadMixinProfile(name, steps))
} }
return UploadMixin(profiles) }
val upload = decodeUpload(obj.optJSONObject(KEY_UPLOAD))
if (profiles.isEmpty() && upload == null) return null
return UploadMixin(profiles, upload)
}
private fun encodeUpload(upload: UploadMixinUpload): JSONObject = JSONObject().apply {
when {
upload.ignoreAll -> put(KEY_IGNORE, "*")
upload.ignore.isNotEmpty() -> put(KEY_IGNORE, JSONArray().apply {
upload.ignore.forEach { item ->
if (item.steps.isEmpty()) {
put(item.region)
} else {
put(JSONObject().apply {
put(KEY_NAME, item.region)
put(KEY_STEPS, JSONArray(item.steps))
})
}
}
})
}
}
private fun decodeUpload(obj: JSONObject?): UploadMixinUpload? {
if (obj == null) return null
@Suppress("UNEXHAUSTIVE_WHEN_BASED_ON_JAVA_ANNOTATIONS")
return when (val raw = obj.opt(KEY_IGNORE)) {
is String -> when {
raw == "*" -> UploadMixinUpload(ignoreAll = true)
raw.isBlank() -> null
else -> UploadMixinUpload(ignore = listOf(UploadIgnoreProfile(raw.trim())))
}
is JSONArray -> {
val items = mutableListOf<UploadIgnoreProfile>()
for (i in 0 until raw.length()) {
when (val item = raw.opt(i)) {
is String -> {
if (item.isNotBlank()) {
items.add(UploadIgnoreProfile(item.trim()))
}
}
is JSONObject -> {
val region = item.textOrNull(KEY_NAME).orEmpty().trim()
if (region.isEmpty()) continue
val steps = mutableListOf<String>()
val stepArray = item.optJSONArray(KEY_STEPS) ?: JSONArray()
for (j in 0 until stepArray.length()) {
val step = stepArray.textOrNull(j).orEmpty().trim()
if (step.isNotEmpty()) steps.add(step)
}
items.add(UploadIgnoreProfile(region, steps))
}
}
}
UploadMixinUpload(ignore = items)
}
else -> null
}
} }
private fun encodeUrl(url: UploadUrl): JSONObject = when (url) { private fun encodeUrl(url: UploadUrl): JSONObject = when (url) {
@@ -195,19 +267,24 @@ object UploadStore {
private fun decodeUrl(obj: JSONObject?): UploadUrl { private fun decodeUrl(obj: JSONObject?): UploadUrl {
if (obj == null) return UploadUrlGeneral("") if (obj == null) return UploadUrlGeneral("")
return when (obj.optString(KEY_MODE, URL_MODE_GENERAL)) { val mode = obj.textOrNull(KEY_MODE)?.trim()?.takeIf { it.isNotEmpty() } ?: URL_MODE_GENERAL
return when (mode) {
URL_MODE_EACH -> UploadUrlEach( URL_MODE_EACH -> UploadUrlEach(
suite = obj.optUrlOrNull(KEY_SUITE), suite = obj.optUrlOrNull(KEY_SUITE),
mysekai = obj.optUrlOrNull(KEY_MYSEKAI), mysekai = obj.optUrlOrNull(KEY_MYSEKAI),
birthday = obj.optUrlOrNull(KEY_BIRTHDAY), birthday = obj.optUrlOrNull(KEY_BIRTHDAY),
) )
else -> UploadUrlGeneral(obj.optString(KEY_URL)) else -> UploadUrlGeneral(
obj.textOrNull(KEY_URL)?.trim()?.takeIf {
it.isNotEmpty() && !it.equals("null", ignoreCase = true)
} ?: ""
)
} }
} }
private fun JSONObject.optUrlOrNull(key: String): String? = private fun JSONObject.optUrlOrNull(key: String): String? =
optString(key).trim().takeIf { it.isNotEmpty() } textOrNull(key)?.trim()?.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) }
private fun decodeHeaders(obj: JSONObject?): Map<String, String> { private fun decodeHeaders(obj: JSONObject?): Map<String, String> {
if (obj == null) return emptyMap() if (obj == null) return emptyMap()
@@ -215,8 +292,14 @@ object UploadStore {
val keys = obj.keys() val keys = obj.keys()
while (keys.hasNext()) { while (keys.hasNext()) {
val key = keys.next() val key = keys.next()
map[key] = obj.optString(key) map[key] = obj.textOrNull(key) ?: continue
} }
return map return map
} }
private fun JSONObject.textOrNull(key: String): String? =
if (isNull(key)) null else optString(key)
private fun JSONArray.textOrNull(index: Int): String? =
if (isNull(index)) null else optString(index)
} }
@@ -21,16 +21,25 @@ object Uploader {
val entries = UploadStore.load() val entries = UploadStore.load()
.filter { it.enabled } .filter { it.enabled }
.filter { onlyProfileIds == null || it.profile.id in onlyProfileIds } .filter { onlyProfileIds == null || it.profile.id in onlyProfileIds }
.filter { entry ->
val definedHere =
entry.profile.id in result.step.explicitUploadIds ||
(onlyProfileIds != null && entry.profile.id in onlyProfileIds)
if (definedHere) {
true
} else {
val upload = (entry.profile as? UploadProfileCustom)?.mixin?.upload
upload == null || !upload.ignores(result.profile.id, result.step.uploadType)
}
}
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, result.step.uploadType) 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 {
VpnLog.info("[${profile.id}] ${result.step.name} 没有对应的上传地址,跳过")
} }
} catch (c: CancellationException) { } catch (c: CancellationException) {
throw c throw c
@@ -166,7 +175,8 @@ enum class UploadProfileType(val displayName: String) {
/** mixin:在自定义上传配置里顺带定义抓包配置。 */ /** mixin:在自定义上传配置里顺带定义抓包配置。 */
data class UploadMixin( data class UploadMixin(
val profiles: List<UploadMixinProfile>, val profiles: List<UploadMixinProfile> = emptyList(),
val upload: UploadMixinUpload? = null,
) )
data class UploadMixinProfile( data class UploadMixinProfile(
@@ -176,7 +186,7 @@ data class UploadMixinProfile(
/** /**
* name: 步骤显示名(同一 profile 内同名步骤会合并) * name: 步骤显示名(同一 profile 内同名步骤会合并)
* hosts: 域名列表,可以填 `cn` 这样的预设别名 * hosts: 域名列表,可以填 `cn` / `jp` / `en` / `tw` / `kr` 这样的预设别名
* target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则 * target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则
*/ */
data class UploadMixinStep( data class UploadMixinStep(
@@ -185,6 +195,32 @@ data class UploadMixinStep(
val target: String, val target: String,
) )
/**
* `upload.ignore`:这个上传配置对哪些"不是自己 mixin 定义"的步骤不生效。
* 自己 mixin 定义出来的步骤永远会上传(见 [ignores] 的调用处)。
*/
data class UploadMixinUpload(
/** ignore: "*" */
val ignoreAll: Boolean = false,
val ignore: List<UploadIgnoreProfile> = emptyList(),
) {
fun ignores(region: String, stepType: String?): Boolean {
if (ignoreAll) return true
return ignore.any { item ->
item.region.equals(region, ignoreCase = true) &&
(item.steps.isEmpty() ||
(stepType != null && item.steps.any { it.equals(stepType, ignoreCase = true) }))
}
}
}
data class UploadIgnoreProfile(
/** Region:cn / jp / en / tw / kr */
val region: String,
/** 空表示整个配置 */
val steps: List<String> = emptyList(),
)
sealed interface UploadProfile { sealed interface UploadProfile {
val id: String val id: String
val type: UploadProfileType val type: UploadProfileType
@@ -266,10 +302,10 @@ class UploadProfileReasona(override val id: String, val uploadCode: String) : Up
get() { get() {
val payload = parseJwt() ?: return emptyMap() val payload = parseJwt() ?: return emptyMap()
val map = mutableMapOf<String, String>() val map = mutableMapOf<String, String>()
if (payload.has("qqnum")) { if (payload.has("qqnum") && !payload.isNull("qqnum")) {
map["QQ 号"] = payload.optString("qqnum") map["QQ 号"] = payload.optString("qqnum")
} }
if (payload.has("sync_8823")) { if (payload.has("sync_8823") && !payload.isNull("sync_8823")) {
map["同步给 8823"] = payload.optBoolean("sync_8823").toString() map["同步给 8823"] = payload.optBoolean("sync_8823").toString()
} }
return map return map
@@ -123,12 +123,11 @@ class CaptureVpnService : VpnService() {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
allDone = latch allDone = latch
// 持续运行:抓到后不自动停,并且同一类型的包可以反复抓(Rust 侧 waiter 不摘除)
val continueRun = SettingsStore.continueRun val continueRun = SettingsStore.continueRun
VpnLog.info("==== 开始抓包 ====") VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}") VpnLog.info("配置: ${profile.name}")
VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}") VpnLog.info("启用: ${enabledSteps.joinToString(" / ") { it.name }}")
if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)") if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)")
VpnBridge.stepListener = { stepId, url -> VpnBridge.stepListener = { stepId, url ->
@@ -254,7 +253,6 @@ class CaptureVpnService : VpnService() {
VpnLog.success("${step.name} 抓到目标请求") VpnLog.success("${step.name} 抓到目标请求")
val result = CaptureResult(profile, step, url, target) val result = CaptureResult(profile, step, url, target)
VpnLog.info("${step.name} 立即执行后处理(不等其他步骤)")
postScope.launch { postScope.launch {
try { try {
step.postAction(result) step.postAction(result)
+17
View File
@@ -24,6 +24,15 @@ use tun_rs::BorrowedAsyncDevice;
static ROOT_STORE: OnceLock<Arc<RootCertStore>> = OnceLock::new(); static ROOT_STORE: OnceLock<Arc<RootCertStore>> = OnceLock::new();
const BLOCKED_HOSTS: &[&str] = &["submit.backtrace.io"];
fn is_blocked_host(host: &str) -> bool {
let host = host.trim_end_matches('.');
BLOCKED_HOSTS
.iter()
.any(|blocked| host.eq_ignore_ascii_case(blocked))
}
const SNIFF_TIMEOUT: Duration = Duration::from_secs(5); const SNIFF_TIMEOUT: Duration = Duration::from_secs(5);
const TEARDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); const TEARDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(3);
const TEARDOWN_SETTLE: Duration = Duration::from_millis(50); const TEARDOWN_SETTLE: Duration = Duration::from_millis(50);
@@ -136,6 +145,14 @@ where
return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await; return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await;
}; };
// 崩溃上报之类的域名直接拒绝,不建立上游连接
if let Some(host) = sni.as_deref() {
if is_blocked_host(host) {
warn!("blocked host {host} ({dst:?}), connection rejected");
return Ok(());
}
}
let host_matched = sni let host_matched = sni
.as_deref() .as_deref()
.map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h))) .map(|s| target_hosts.iter().any(|h| s.eq_ignore_ascii_case(h)))
+16 -3
View File
@@ -1,5 +1,8 @@
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
type Region = "cn" | "jp" | "en" | "tw" | "kr";
type Step = "suite" | "mysekai" | "birthday";
interface UploadUrlGeneral { interface UploadUrlGeneral {
mode: "general"; mode: "general";
url: string; url: string;
@@ -14,8 +17,8 @@ interface UploadUrlEach {
interface UploadMixinStep { interface UploadMixinStep {
name: string; name: string;
hosts: ("cn" | "jp" | "en" | "tw" | "kr" | string)[]; hosts: (Region | string)[];
target: "suite" | "mysekai" | "birthday" | "" | string; target: Step | string;
} }
interface UploadMixinProfile { interface UploadMixinProfile {
@@ -23,8 +26,18 @@ interface UploadMixinProfile {
steps: UploadMixinStep[]; steps: UploadMixinStep[];
} }
interface UploadMixinUploadIgnoreProfile {
name: Region;
steps: Step[];
}
interface UploadMixinUpload {
ignore?: "*" | (Region | UploadMixinUploadIgnoreProfile)[]; // never ignore defined profile in mixin context
}
interface UploadMixin { interface UploadMixin {
profiles: UploadMixinProfile[]; profiles?: UploadMixinProfile[];
upload?: UploadMixinUpload;
} }
export interface UploadProfileCustom { export interface UploadProfileCustom {