Files
cinder27/Sources/ModToolsView.swift
T

580 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UniformTypeIdentifiers
import CoreText
// MARK: - IPA surgery
struct IPAPane: View {
@State private var source: URL?
@State private var picking = false
@State private var bundleID = ""
@State private var displayName = ""
@State private var version = ""
@State private var buildVersion = ""
@State private var minOS = ""
@State private var dylib = ""
@State private var patchFind = ""
@State private var patchReplace = ""
@State private var stripSig = true
@State private var busy = false
@State private var log: [String] = []
@State private var output: URL?
private var workdir: URL {
let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let dir = base.appendingPathComponent("Cinder27/IPA", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("源 IPA"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "shippingbox.fill").font(.system(size: 12))
Text(source?.lastPathComponent ?? tl("选择一个 .ipa")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(source == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
Text(tl("工具会解包、改 Info.plist、往主二进制里插 LC_LOAD_DYLIB、做字节级字符串替换、去掉 LC_CODE_SIGNATURE,然后重新打成 zip。产出仍然是未签名的 —— 你用同一套证书签它。"))
.font(T.tiny).foregroundColor(.white.opacity(0.35))
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: "Info.plist")
field("Bundle ID", $bundleID, "com.example.app")
field(tl("显示名称"), $displayName, tl("新名字"))
field(tl("版本"), $version, "2.0")
field(tl("构建号"), $buildVersion, "42")
field(tl("最低系统"), $minOS, "17.0")
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("二进制"))
field(tl("注入 dylib 路径"), $dylib, "@executable_path/Frameworks/Hook.dylib")
field(tl("搜索字符串"), $patchFind, "com.old.bundleid")
field(tl("替换为"), $patchReplace, "com.new.bundleid")
Toggle(isOn: $stripSig) {
Text(tl("移除 LC_CODE_SIGNATURE")).font(T.body).foregroundColor(.white.opacity(0.75))
}.tint(T.ember)
Text(tl("替换串不能比原串长 —— 这是原地改写,不改文件布局,所以不会破坏任何偏移。注入 LC_LOAD_DYLIB 需要头部有空隙,没有就会明确报出来。"))
.font(T.tiny).foregroundColor(.white.opacity(0.32))
.fixedSize(horizontal: false, vertical: true)
}
}
RunButton(title: busy ? tl("处理中…") : tl("生成改机 IPA"),
systemImage: "wand.and.stars", tint: T.ember, disabled: busy || source == nil) {
run()
}
if !log.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("过程"))
ForEach(Array(log.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
if let output = output {
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: tl("产出"))
Text(output.lastPathComponent).font(T.mono).foregroundColor(T.good)
Text(output.path).font(T.monoSm).foregroundColor(.white.opacity(0.35))
.textSelection(.enabled)
if let size = (try? FileManager.default.attributesOfItem(atPath: output.path))?[.size] as? NSNumber {
Text("\(size.int64Value) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
source = url
log = [tl("已选择 ") + "\(url.lastPathComponent)"]
}
}
}
private func field(_ label: String, _ binding: Binding<String>, _ placeholder: String) -> some View {
HStack(spacing: 10) {
Text(label).font(T.body).foregroundColor(.white.opacity(0.45)).frame(width: 96, alignment: .leading)
TextField(placeholder, text: binding)
.font(T.mono)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(8)
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
}
}
private func run() {
guard let source = source else { return }
busy = true
log = []
output = nil
var recipe = IPARecipe()
recipe.bundleID = bundleID.isEmpty ? nil : bundleID
recipe.displayName = displayName.isEmpty ? nil : displayName
recipe.version = version.isEmpty ? nil : version
recipe.buildVersion = buildVersion.isEmpty ? nil : buildVersion
recipe.minOS = minOS.isEmpty ? nil : minOS
recipe.injectedDylib = dylib.isEmpty ? nil : dylib
recipe.patchedString = patchFind.isEmpty ? nil : patchFind
recipe.patchedReplacement = patchReplace.isEmpty ? nil : patchReplace
recipe.stripSignature = stripSig
let dir = workdir
DispatchQueue.global(qos: .userInitiated).async {
var lines: [String] = []
var result: URL?
do {
let r = try IPASurgeon.patch(source: source, recipe: recipe, workdir: dir)
lines = r.notes
lines.append(tl("产出 ") + "\(r.entries)" + tl(" 个文件"))
result = r.outputURL
} catch {
lines.append(tl("失败: ") + "\(error.localizedDescription)")
}
DispatchQueue.main.async {
log = lines
output = result
busy = false
}
}
}
}
// MARK: - Hex editor
struct HexPane: View {
@State private var target: URL?
@State private var picking = false
@State private var dump = ""
@State private var find = ""
@State private var replaceWith = ""
@State private var hits: [Int] = []
@State private var status = ""
@State private var offsetLimit = 8192
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("目标"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "doc.text.magnifyingglass").font(.system(size: 12))
Text(target?.lastPathComponent ?? tl("选择一个文件")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("查找 / 替换"))
TextField(tl("文本,或 41 42 43 / 0x414243"), text: $find)
.font(T.mono).textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(8).background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
TextField(tl("替换(可选,不能更长)"), text: $replaceWith)
.font(T.mono).textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(8).background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
HStack(spacing: 8) {
RunButton(title: tl("查找"), systemImage: "magnifyingglass", tint: T.info,
disabled: target == nil || find.isEmpty) { search() }
RunButton(title: tl("替换并保存"), systemImage: "arrow.triangle.2.circlepath",
tint: T.ember,
disabled: target == nil || find.isEmpty || replaceWith.isEmpty) { replace() }
}
if !hits.isEmpty {
Text(tl("命中 ") + "\(hits.count)" + tl(" 处: ") + hits.prefix(12).map { String(format: "0x%x", $0) }.joined(separator: ", "))
.font(T.monoSm).foregroundColor(T.good)
.fixedSize(horizontal: false, vertical: true)
}
}
}
if !dump.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
HStack {
SectionLabel(text: tl("十六进制"))
Spacer()
Button(tl("更多")) { offsetLimit += 16384; load() }
.font(T.tiny).tint(T.ember)
}
Text(dump)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.72))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
hits = []
status = ""
load()
}
}
}
private func load() {
guard let target = target else { return }
guard let data = try? Data(contentsOf: target) else {
status = tl("读取失败")
return
}
dump = hexdump(data, limit: offsetLimit)
status = "\(data.count) B"
}
private func search() {
guard let target = target, let data = try? Data(contentsOf: target) else { return }
hits = HexTool.search(data, pattern: find)
status = hits.isEmpty ? tl("未命中") : tl("命中 ") + "\(hits.count)" + tl(" 处")
}
private func replace() {
guard let target = target, let data = try? Data(contentsOf: target) else { return }
let backup = target.path + ".cinder27.bak"
if !FileManager.default.fileExists(atPath: backup) {
try? FileManager.default.copyItem(atPath: target.path, toPath: backup)
}
let (patched, count) = HexTool.replace(data, pattern: find, with: replaceWith)
if count == 0 {
status = tl("未命中,未写入")
return
}
do {
try patched.write(to: target)
status = tl("替换 ") + "\(count)" + tl(" 处并已保存(备份 ") + "\(URL(fileURLWithPath: backup).lastPathComponent)" + tl(")")
load()
hits = HexTool.search(patched, pattern: replaceWith)
} catch {
status = tl("写入失败: ") + "\(error.localizedDescription)"
}
}
}
// MARK: - xattr / decmpfs
struct XattrPane: View {
@State private var target: URL?
@State private var picking = false
@State private var entries: [(String, Int)] = []
@State private var detail: [String] = []
@State private var status = ""
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("目标文件"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "tag.fill").font(.system(size: 12))
Text(target?.lastPathComponent ?? tl("选择文件")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
}
}
}
Card {
VStack(alignment: .leading, spacing: 6) {
SectionLabel(text: tl("扩展属性"))
if entries.isEmpty {
Text(tl("无")).font(T.monoSm).foregroundColor(.white.opacity(0.3))
} else {
ForEach(Array(entries.enumerated()), id: \.offset) { _, e in
HStack {
Text(e.0).font(T.monoSm).foregroundColor(.white.opacity(0.75))
Spacer()
Text("\(e.1) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
}
}
}
}
}
if !detail.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("详情"))
ForEach(Array(detail.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("decmpfs 操作"))
Text(tl("com.apple.decmpfs 是内核压缩文件系统用的属性。改它等于直接改内核解压的输入 —— CVE-2026-113910 就是这条路径上「解压产出少于预期」时内核把未写满的页缓冲交给用户态。这里可以手动设一个、读回来、再删掉。"))
.font(T.tiny).foregroundColor(.white.opacity(0.38))
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
RunButton(title: tl("写入探测属性"), systemImage: "square.and.arrow.down",
tint: T.ember, disabled: target == nil) { plantDecmpfs() }
RunButton(title: tl("移除"), systemImage: "trash", tint: T.warn,
disabled: target == nil) { removeDecmpfs() }
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
reload()
}
}
}
private func reload() {
guard let target = target else { return }
entries = XattrTool.list(target.path)
detail = []
if let data = XattrTool.read(target.path, name: "com.apple.decmpfs") {
detail.append(XattrTool.decmpfsHeader(data))
detail.append(hexdump(data, limit: 128))
}
status = "\(entries.count)" + tl(" 个属性")
}
private func plantDecmpfs() {
guard let target = target else { return }
let payload = Decmpfs.chunkedXattr(uncompressedSize: 65536, chunkCount: 1, producePerChunk: 4096)
if let err = XattrTool.write(target.path, name: "com.apple.decmpfs", value: payload) {
status = tl("写入失败: ") + "\(err)"
} else {
status = tl("已写入 ") + "\(payload.count)" + tl(" B 的 decmpfs 属性")
}
reload()
}
private func removeDecmpfs() {
guard let target = target else { return }
if let err = XattrTool.remove(target.path, name: "com.apple.decmpfs") {
status = tl("移除失败: ") + "\(err)"
} else {
status = tl("已移除")
}
reload()
}
}
// MARK: - SQLite
struct SQLitePane: View {
@State private var target: URL?
@State private var picking = false
@State private var tables: [String] = []
@State private var sql = "SELECT * FROM sqlite_master LIMIT 20;"
@State private var columns: [String] = []
@State private var rows: [[String]] = []
@State private var status = ""
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("数据库"))
Button { picking = true } label: {
HStack(spacing: 8) {
Image(systemName: "cylinder.split.1x2").font(.system(size: 12))
Text(target?.lastPathComponent ?? tl("选择 .sqlite / .db")).font(T.body)
Spacer()
Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold))
}
.foregroundColor(target == nil ? .white.opacity(0.4) : T.ember)
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(T.panelHi))
}
if !tables.isEmpty {
Text(tl("表: ") + tables.prefix(12).joined(separator: ", "))
.font(T.monoSm).foregroundColor(.white.opacity(0.45))
.fixedSize(horizontal: false, vertical: true)
}
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
SectionLabel(text: tl("查询(只读)"))
TextEditor(text: $sql)
.font(.system(size: 11.5, design: .monospaced))
.frame(height: 90)
.scrollContentBackground(.hidden)
.background(T.panelHi)
.cornerRadius(8)
RunButton(title: tl("执行"), systemImage: "play.fill", tint: T.info,
disabled: target == nil) { runQuery() }
if !status.isEmpty {
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.55))
}
}
}
if !columns.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
Text(columns.joined(separator: " | "))
.font(.system(size: 10, weight: .semibold, design: .monospaced))
.foregroundColor(T.emberSoft)
.fixedSize(horizontal: false, vertical: true)
Divider().overlay(T.stroke)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
Text(row.joined(separator: " | "))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
.padding(16)
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
target = url
open()
}
}
}
private func open() {
guard let target = target else { return }
#if canImport(SQLite3)
tables = SQLiteBrowser.tables(target.path)
status = "\(tables.count)" + tl(" 张表")
#else
status = tl("SQLite3 不可用")
#endif
}
private func runQuery() {
guard let target = target else { return }
#if canImport(SQLite3)
let result = SQLiteBrowser.query(target.path, sql)
columns = result.columns
rows = result.rows
status = result.error ?? "\(result.rows.count)" + tl(" 行")
#endif
}
}
// MARK: - Fonts
struct FontPane: View {
@State private var picking = false
@State private var log: [String] = []
@State private var names: [String] = []
var body: some View {
ScrollView {
VStack(spacing: 14) {
Card {
VStack(alignment: .leading, spacing: 10) {
SectionLabel(text: tl("字体安装"))
Text(tl("把字体文件注册进本进程的字体域。iOS 允许应用注册用户字体,注册后 CoreText 立刻可见 —— 这是改机里唯一一条完全不需要任何越权就能改「系统观感」的路径,代价是只在本进程生效。"))
.font(T.body).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
RunButton(title: tl("选择字体文件"), systemImage: "textformat", tint: T.ember) {
picking = true
}
}
}
if !log.isEmpty {
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("结果"))
ForEach(Array(log.enumerated()), id: \.offset) { _, line in
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.65))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
Card {
VStack(alignment: .leading, spacing: 4) {
SectionLabel(text: tl("系统可用字体(") + "\(names.count)" + tl(")"))
ForEach(names.prefix(80), id: \.self) { n in
Text(n).font(T.monoSm).foregroundColor(.white.opacity(0.55))
}
}
}
}
.padding(16)
}
.onAppear {
if names.isEmpty {
DispatchQueue.global(qos: .userInitiated).async {
let list = FontInstaller.installed()
DispatchQueue.main.async { names = list }
}
}
}
.sheet(isPresented: $picking) {
FilePickerSheet(isPresented: $picking) { url in
let result = FontInstaller.install(url)
log.append(result)
names = FontInstaller.installed()
}
}
}
}