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 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk
targetSdk = 37
versionCode = 4
versionName = "1.3"
versionCode = 5
versionName = "1.4"
}
@@ -15,6 +15,7 @@ data class CaptureProfileStep(
val path: String,
val fileName: String,
var postAction: suspend (CaptureResult) -> Unit = {},
val uploadType: String? = null,
)
data class CaptureResult(
@@ -1,7 +1,6 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import android.content.pm.ApplicationInfo
import android.graphics.drawable.Drawable
data class AppEntry(
@@ -16,10 +15,6 @@ object InstalledApps {
val pm = context.packageManager
return pm.getInstalledApplications(0)
.asSequence()
.filter { info ->
(info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 ||
(info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0
}
.mapNotNull { info ->
runCatching {
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 MYSEKAI_PATH =
"/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(
id = "suite",
@@ -33,7 +52,7 @@ object PredefinedProfiles {
id = "mysekai_birthday",
name = "烤森生日双叶",
hosts = hosts,
path = "/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery",
path = MYSEKAI_BIRTHDAY_PATH,
fileName = "mysekai_birthday",
postAction = { 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.viewModelScope
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.upload.UploadStore
import net.bluemangoo.indpjsk.vpn.CaptureVpnService
import net.bluemangoo.indpjsk.vpn.VpnLog
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import java.io.File
@@ -27,24 +29,28 @@ data class CaptureUiState(
class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state
.map { s ->
CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = s.profile,
status = s.status,
lastCapturedFile = s.lastCapturedFile,
errorMessage = s.errorMessage
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
private fun allProfiles(): List<CaptureProfile> = MixinProfiles.merged()
val uiState: StateFlow<CaptureUiState> = combine(
CaptureStateHolder.state,
UploadStore.revision,
) { s, _ ->
val profiles = allProfiles()
CaptureUiState(
profiles = profiles,
selectedProfile = profiles.firstOrNull { it.id == s.profile.id } ?: s.profile,
status = s.status,
lastCapturedFile = s.lastCapturedFile,
errorMessage = s.errorMessage
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = allProfiles(),
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
)
fun selectProfile(profile: CaptureProfile) {
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 net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.MixinProfiles
import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject
@@ -216,10 +217,6 @@ private fun UploadTableRow(
}
}
/**
* 添加 / 编辑共用的界面。
* 只有构造函数需要的字段需要用户填写;额外信息(extraInfo)只在下方展示。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UploadEditScreen(
@@ -246,7 +243,7 @@ private fun UploadEditScreen(
}
var urlMode by remember(initial) {
mutableStateOf(
when (val url = (original as? UploadProfileCustom)?.uploadUrl) {
when ((original as? UploadProfileCustom)?.uploadUrl) {
is UploadUrlEach -> UploadStore.URL_MODE_EACH
is UploadUrlGeneral -> 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()
)
}
var mixinText by remember(initial) {
mutableStateOf(
(original as? UploadProfileCustom)?.mixin
?.let { UploadStore.encodeMixin(it).toString() }
?: ""
)
}
var typeExpanded by remember { mutableStateOf(false) }
var urlModeExpanded by remember { mutableStateOf(false) }
@@ -291,7 +295,13 @@ private fun UploadEditScreen(
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) {
UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim())
@@ -312,6 +322,7 @@ private fun UploadEditScreen(
.map { it.first.trim() to it.second.trim() }
.filter { it.first.isNotEmpty() }
.toMap(),
mixin = parsedMixin.getOrNull(),
)
}
@@ -340,7 +351,6 @@ private fun UploadEditScreen(
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ID 和类型放在同一行
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -481,6 +491,12 @@ private fun UploadEditScreen(
HeadersEditor(headers = headers, onChange = { headers = it })
MixinEditor(
value = mixinText,
onValueChange = { mixinText = it.prependIndent().replace("\n", " ") },
error = mixinError,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
@@ -550,7 +566,14 @@ private fun UploadEditScreen(
modifier = Modifier
.fillMaxWidth()
.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
)
importError?.let {
@@ -585,6 +608,9 @@ private fun UploadEditScreen(
}
}
headers = imported.customHeaders.map { it.key to it.value }
mixinText = imported.mixin
?.let { UploadStore.encodeMixin(it).toString() }
?: ""
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
private fun FormRow(
label: String,
@@ -803,7 +854,7 @@ private fun CompactDropdown(
}
}
private val helpLinkRegex = Regex("""\[([^\]]+)]\(([^)]+)\)""")
private val helpLinkRegex = Regex("""\[([^]]+)]\(([^)]+)\)""")
@Composable
private fun HelpText(text: String) {
@@ -1,5 +1,8 @@
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.vpn.VpnLog
import org.json.JSONArray
@@ -14,6 +17,9 @@ object UploadStore {
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_PROFILES = "profiles"
private const val KEY_ENABLED = "enabled"
@@ -25,6 +31,11 @@ object UploadStore {
private const val KEY_MODE = "mode"
private const val KEY_URL = "url"
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_MYSEKAI = "mysekai"
@@ -42,6 +53,7 @@ object UploadStore {
fun save(entries: List<UploadEntry>) {
SettingsStore.uploadProfilesJson = encode(entries)
_revision.value += 1
}
fun encode(entries: List<UploadEntry>): String {
@@ -85,6 +97,7 @@ object UploadStore {
is UploadProfileCustom -> {
put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl))
put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>))
profile.mixin?.let { put(KEY_MIXIN, encodeMixin(it)) }
}
}
}
@@ -101,10 +114,71 @@ object UploadStore {
id = id,
uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)),
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) {
is UploadUrlGeneral -> JSONObject().apply {
put(KEY_MODE, URL_MODE_GENERAL)
@@ -17,14 +17,16 @@ import kotlin.random.Random
object Uploader {
private const val CHUNK_SIZE = 1024 * 1024
suspend fun tryUpload(result: CaptureResult) {
val entries = UploadStore.load().filter { it.enabled }
suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set<String>? = null) {
val entries = UploadStore.load()
.filter { it.enabled }
.filter { onlyProfileIds == null || it.profile.id in onlyProfileIds }
if (entries.isEmpty()) return
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)")
for ((_, profile) in entries) {
try {
val uploaded = uploadChunked(profile, result.url, result.file)
val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType)
if (uploaded) {
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
} else {
@@ -43,18 +45,20 @@ object Uploader {
profile: UploadProfile,
originalUrl: String,
file: File,
type: String? = null,
): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val type = when (file.name) {
val uploadType = type ?: when (file.name) {
"suite" -> "suite"
"mysekai" -> "mysekai"
"mysekai_birthday" -> "birthday"
else -> {
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
else -> null
}
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
val uploadId = randomUploadId()
@@ -160,6 +164,27 @@ enum class UploadProfileType(val displayName: String) {
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 {
val id: String
val type: UploadProfileType
@@ -274,8 +299,12 @@ class UploadProfile8823(override val id: String) : UploadProfile {
override val extraInfo: Map<String, String> = emptyMap()
}
class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map<String, String>) :
UploadProfile {
class UploadProfileCustom(
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 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;
}