580 lines
25 KiB
Swift
580 lines
25 KiB
Swift
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()
|
||
}
|
||
}
|
||
}
|
||
}
|