Files

760 lines
33 KiB
Swift
Raw Permalink 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 Foundation
import Darwin
import CoreText
import UIKit
// MARK: - ZIP writer (stored entries, no external dependency)
/// A minimal but correct ZIP writer. iOS installation only needs a valid archive, and
/// stored entries keep the whole thing deterministic — no compressor to get wrong.
struct ZipWriter {
struct Entry {
let name: String
let data: Data
}
private var entries: [Entry] = []
mutating func add(_ name: String, _ data: Data) {
entries.append(Entry(name: name, data: data))
}
mutating func add(url: URL, as name: String) {
guard let data = try? Data(contentsOf: url) else { return }
add(name, data)
}
static func crc32(_ bytes: [UInt8]) -> UInt32 {
var table = [UInt32](repeating: 0, count: 256)
for i in 0..<256 {
var c = UInt32(i)
for _ in 0..<8 { c = (c & 1) != 0 ? (0xEDB88320 ^ (c >> 1)) : (c >> 1) }
table[i] = c
}
var crc: UInt32 = 0xFFFFFFFF
for b in bytes { crc = table[Int((crc ^ UInt32(b)) & 0xFF)] ^ (crc >> 8) }
return crc ^ 0xFFFFFFFF
}
func build() -> Data {
var out = Data()
var central = Data()
let now = Date()
let cal = Calendar(identifier: .gregorian)
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: now)
let dosTime = UInt16(((comps.hour ?? 0) << 11) | ((comps.minute ?? 0) << 5) | ((comps.second ?? 0) / 2))
let dosDate = UInt16((((comps.year ?? 2026) - 1980) << 9) | ((comps.month ?? 1) << 5) | (comps.day ?? 1))
var count: UInt16 = 0
for entry in entries {
let nameBytes = Array(entry.name.utf8)
let payload = [UInt8](entry.data)
let crc = Self.crc32(payload)
let offset = UInt32(out.count)
let size = UInt32(payload.count)
out.append(contentsOf: [0x50, 0x4B, 0x03, 0x04])
out.append(contentsOf: B.le16(20))
out.append(contentsOf: B.le16(0x0800)) // UTF-8 flag
out.append(contentsOf: B.le16(0)) // stored
out.append(contentsOf: B.le16(dosTime))
out.append(contentsOf: B.le16(dosDate))
out.append(contentsOf: B.le32(crc))
out.append(contentsOf: B.le32(size))
out.append(contentsOf: B.le32(size))
out.append(contentsOf: B.le16(UInt16(nameBytes.count)))
out.append(contentsOf: B.le16(0))
out.append(contentsOf: nameBytes)
out.append(contentsOf: payload)
central.append(contentsOf: [0x50, 0x4B, 0x01, 0x02])
central.append(contentsOf: B.le16(20)) // version made by
central.append(contentsOf: B.le16(20)) // version needed
central.append(contentsOf: B.le16(0x0800))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(dosTime))
central.append(contentsOf: B.le16(dosDate))
central.append(contentsOf: B.le32(crc))
central.append(contentsOf: B.le32(size))
central.append(contentsOf: B.le32(size))
central.append(contentsOf: B.le16(UInt16(nameBytes.count)))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le16(0))
central.append(contentsOf: B.le32(0))
central.append(contentsOf: B.le32(offset))
central.append(contentsOf: nameBytes)
count += 1
}
let centralOffset = UInt32(out.count)
out.append(central)
out.append(contentsOf: [0x50, 0x4B, 0x05, 0x06])
out.append(contentsOf: B.le16(0))
out.append(contentsOf: B.le16(0))
out.append(contentsOf: B.le16(count))
out.append(contentsOf: B.le16(count))
out.append(contentsOf: B.le32(UInt32(central.count)))
out.append(contentsOf: B.le32(centralOffset))
out.append(contentsOf: B.le16(0))
return out
}
}
// MARK: - Mach-O surgery
enum MachO {
struct Header {
var magic: UInt32
var cputype: UInt32
var cpusubtype: UInt32
var filetype: UInt32
var ncmds: UInt32
var sizeofcmds: UInt32
var flags: UInt32
var reserved: UInt32
var is64: Bool
var headerSize: Int { is64 ? 32 : 28 }
}
struct LoadCommand {
var cmd: UInt32
var size: UInt32
var offset: Int
}
static func header(_ data: Data) -> Header? {
guard data.count >= 32 else { return nil }
let magic = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
guard magic == 0xFEEDFACF || magic == 0xFEEDFACE || magic == 0xCFFAEDFE else { return nil }
let is64 = magic == 0xFEEDFACF || magic == 0xCFFAEDFE
return Header(
magic: magic,
cputype: data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) },
cpusubtype: data.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt32.self) },
filetype: data.withUnsafeBytes { $0.load(fromByteOffset: 12, as: UInt32.self) },
ncmds: data.withUnsafeBytes { $0.load(fromByteOffset: 16, as: UInt32.self) },
sizeofcmds: data.withUnsafeBytes { $0.load(fromByteOffset: 20, as: UInt32.self) },
flags: data.withUnsafeBytes { $0.load(fromByteOffset: 24, as: UInt32.self) },
reserved: is64 ? data.withUnsafeBytes { $0.load(fromByteOffset: 28, as: UInt32.self) } : 0,
is64: is64)
}
static func commands(_ data: Data) -> [LoadCommand] {
guard let h = header(data) else { return [] }
var out: [LoadCommand] = []
var offset = h.headerSize
for _ in 0..<min(Int(h.ncmds), 8192) {
guard offset + 8 <= data.count else { break }
let cmd = data.withUnsafeBytes { $0.load(fromByteOffset: offset, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 4, as: UInt32.self) }
guard size >= 8, offset + Int(size) <= data.count else { break }
out.append(LoadCommand(cmd: cmd, size: size, offset: offset))
offset += Int(size)
}
return out
}
/// Lowest file offset occupied by real segment content — the padding window we may
/// write into without moving anything.
static func firstSectionOffset(_ data: Data) -> Int? {
guard header(data) != nil else { return nil }
var lowest: Int?
for c in commands(data) where c.cmd == 0x19 || c.cmd == 0x1 { // LC_SEGMENT_64 / LC_SEGMENT
let is64 = c.cmd == 0x19
var sectionOffset = c.offset + (is64 ? 72 : 56)
let nsectsOffset = c.offset + (is64 ? 64 : 48)
guard nsectsOffset + 4 <= data.count else { continue }
let nsects = data.withUnsafeBytes { $0.load(fromByteOffset: nsectsOffset, as: UInt32.self) }
let sectSize = is64 ? 80 : 68
for _ in 0..<min(Int(nsects), 4096) {
guard sectionOffset + sectSize <= data.count else { break }
let offsetField = is64 ? sectionOffset + 48 : sectionOffset + 40
let fileOffset = data.withUnsafeBytes { $0.load(fromByteOffset: offsetField, as: UInt32.self) }
if fileOffset != 0 {
let v = Int(fileOffset)
if lowest == nil || v < lowest! { lowest = v }
}
sectionOffset += sectSize
}
}
return lowest
}
/// Append an LC_LOAD_DYLIB to a thin Mach-O. Returns the patched image, or throws when
/// there is no room in the header padding (which is the honest failure mode).
static func injectLoad(_ data: Data, dylibPath: String) throws -> Data {
guard let h = header(data) else { throw LabError(message: tl("不是 Mach-O")) }
guard h.is64 else { throw LabError(message: tl("只支持 64 位 Mach-O")) }
let pathBytes = Array(dylibPath.utf8)
let rawSize = 24 + pathBytes.count + 1
let cmdSize = (rawSize + 7) & ~7
let insertAt = h.headerSize + Int(h.sizeofcmds)
let limit = firstSectionOffset(data) ?? data.count
guard insertAt + cmdSize <= limit else {
throw LabError(message: tl("头部空隙不足:需要 ") + "\(cmdSize)" + tl(" B,可用 ") + "\(max(0, limit - insertAt))" + tl(" B"))
}
var out = data
var cmd = Data()
cmd.append(contentsOf: B.le32(0x0C)) // LC_LOAD_DYLIB
cmd.append(contentsOf: B.le32(UInt32(cmdSize)))
cmd.append(contentsOf: B.le32(24)) // name offset
cmd.append(contentsOf: B.le32(2)) // timestamp
cmd.append(contentsOf: B.le32(0x00010000)) // current version
cmd.append(contentsOf: B.le32(0x00010000)) // compat version
cmd.append(contentsOf: pathBytes)
cmd.append(0)
while cmd.count < cmdSize { cmd.append(0) }
out.replaceSubrange(insertAt..<(insertAt + cmdSize), with: cmd)
// bump ncmds / sizeofcmds
var ncmds = h.ncmds + 1
var sizeofcmds = h.sizeofcmds + UInt32(cmdSize)
withUnsafeBytes(of: &ncmds) { out.replaceSubrange(16..<20, with: $0) }
withUnsafeBytes(of: &sizeofcmds) { out.replaceSubrange(20..<24, with: $0) }
return out
}
/// Remove LC_CODE_SIGNATURE from the load-command stream so a re-sign starts clean.
///
/// The command is spliced out and the remaining commands are shifted down — zeroing it
/// in place would leave a cmdsize==0 hole and desync every loader that walks the list.
static func stripSignature(_ data: Data) throws -> (Data, Int) {
guard let h = header(data) else { throw LabError(message: tl("不是 Mach-O")) }
var out = data
var removed = 0
var offset = h.headerSize
var kept = Data()
for _ in 0..<min(Int(h.ncmds), 8192) {
guard offset + 8 <= data.count else { break }
let cmd = data.withUnsafeBytes { $0.load(fromByteOffset: offset, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: offset + 4, as: UInt32.self) }
guard size >= 8, offset + Int(size) <= data.count else { break }
if cmd == 0x1D { // LC_CODE_SIGNATURE
removed += 1
} else {
kept.append(data[offset..<(offset + Int(size))])
}
offset += Int(size)
}
guard removed > 0 else { return (data, 0) }
// rewrite the command stream in place, then zero the tail of the old region
let streamStart = h.headerSize
let oldEnd = streamStart + Int(h.sizeofcmds)
out.replaceSubrange(streamStart..<(streamStart + kept.count), with: kept)
if streamStart + kept.count < oldEnd {
for i in (streamStart + kept.count)..<oldEnd { out[i] = 0 }
}
var ncmds = h.ncmds - UInt32(removed)
var sizeofcmds = UInt32(kept.count)
withUnsafeBytes(of: &ncmds) { out.replaceSubrange(16..<20, with: $0) }
withUnsafeBytes(of: &sizeofcmds) { out.replaceSubrange(20..<24, with: $0) }
return (out, removed)
}
static func patchStrings(_ data: Data, find: String, replace: String) throws -> (Data, Int) {
let needle = Array(find.utf8)
let replacement = Array(replace.utf8)
guard !needle.isEmpty else { throw LabError(message: tl("搜索串为空")) }
guard replacement.count <= needle.count else {
throw LabError(message: tl("替换串不能比原串长(") + "\(replacement.count)" + tl(" > ") + "\(needle.count)" + tl(")"))
}
var haystack = [UInt8](data)
var hits = 0
var i = 0
while i + needle.count <= haystack.count {
var matched = true
for k in 0..<needle.count where haystack[i + k] != needle[k] { matched = false; break }
if matched {
for k in 0..<replacement.count { haystack[i + k] = replacement[k] }
for k in replacement.count..<needle.count { haystack[i + k] = 0 }
hits += 1
i += needle.count
} else {
i += 1
}
}
return (Data(haystack), hits)
}
}
// MARK: - IPA surgery
struct IPARecipe {
var bundleID: String?
var displayName: String?
var version: String?
var buildVersion: String?
var minOS: String?
var injectedDylib: String?
var patchedString: String?
var patchedReplacement: String?
var stripSignature: Bool = true
}
struct IPAResult {
var outputURL: URL
var entries: Int
var notes: [String]
}
enum IPASurgeon {
static func patch(source: URL, recipe: IPARecipe, workdir: URL) throws -> IPAResult {
let fm = FileManager.default
let stage = workdir.appendingPathComponent("ipa-\(Int(Date().timeIntervalSince1970))", isDirectory: true)
try? fm.removeItem(at: stage)
try fm.createDirectory(at: stage, withIntermediateDirectories: true)
guard let archive = try? Data(contentsOf: source) else {
throw LabError(message: tl("读取 IPA 失败"))
}
let extracted = Runners.Archive.extractAll(archive, to: stage)
guard extracted.count > 0 else {
throw LabError(message: tl("解包失败:") + "\(extracted.status)")
}
var notes: [String] = [tl("解包 ") + "\(extracted.count)" + tl(" 项")]
let payload = stage.appendingPathComponent("Payload")
guard let appName = (try? fm.contentsOfDirectory(atPath: payload.path))?
.first(where: { $0.hasSuffix(".app") }) else {
throw LabError(message: tl("Payload/ 下没有 .app"))
}
let app = payload.appendingPathComponent(appName)
notes.append(tl("目标 ") + "\(appName)")
// ---- Info.plist rewrite
let infoURL = app.appendingPathComponent("Info.plist")
if var info = (try? Data(contentsOf: infoURL))
.flatMap({ try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] }) {
if let v = recipe.bundleID { info["CFBundleIdentifier"] = v; notes.append("bundle id → \(v)") }
if let v = recipe.displayName { info["CFBundleDisplayName"] = v; notes.append(tl("名称 → ") + "\(v)") }
if let v = recipe.version { info["CFBundleShortVersionString"] = v }
if let v = recipe.buildVersion { info["CFBundleVersion"] = v }
if let v = recipe.minOS { info["MinimumOSVersion"] = v }
if let data = try? PropertyListSerialization.data(fromPropertyList: info, format: .binary, options: 0) {
try? data.write(to: infoURL)
}
} else {
notes.append(tl("Info.plist 读取失败,跳过字段改写"))
}
// ---- executable surgery
guard let execName = (try? Data(contentsOf: infoURL))
.flatMap({ try? PropertyListSerialization.propertyList(from: $0, format: nil) as? [String: Any] })?["CFBundleExecutable"] as? String
?? (try? fm.contentsOfDirectory(atPath: app.path))?.first(where: { $0 == appName.replacingOccurrences(of: ".app", with: "") }) else {
throw LabError(message: tl("找不到主二进制名"))
}
let execURL = app.appendingPathComponent(execName)
if let binary = try? Data(contentsOf: execURL) {
var current = binary
if let dylib = recipe.injectedDylib, !dylib.isEmpty {
do {
current = try MachO.injectLoad(current, dylibPath: dylib)
notes.append(tl("注入 LC_LOAD_DYLIB → ") + "\(dylib)")
} catch {
notes.append(tl("注入失败: ") + "\(error.localizedDescription)")
}
}
if let find = recipe.patchedString, !find.isEmpty {
do {
let (patched, hits) = try MachO.patchStrings(current,
find: find,
replace: recipe.patchedReplacement ?? "")
current = patched
notes.append(tl("字符串替换 ") + "\(hits)" + tl(" 处"))
} catch {
notes.append(tl("替换失败: ") + "\(error.localizedDescription)")
}
}
if recipe.stripSignature {
if let (stripped, removed) = try? MachO.stripSignature(current) {
current = stripped
if removed > 0 { notes.append(tl("移除 LC_CODE_SIGNATURE ×") + "\(removed)") }
}
}
try? current.write(to: execURL)
} else {
notes.append(tl("主二进制读取失败(可能已加密)"))
}
// ---- repack
var zip = ZipWriter()
let enumerator = fm.enumerator(at: stage, includingPropertiesForKeys: [.isDirectoryKey])
var files = 0
while let url = enumerator?.nextObject() as? URL {
let values = try? url.resourceValues(forKeys: [.isDirectoryKey])
if values?.isDirectory == true { continue }
let relative = url.path.replacingOccurrences(of: stage.path + "/", with: "")
if let data = try? Data(contentsOf: url) {
zip.add(relative, data)
files += 1
}
}
notes.append(tl("重打包 ") + "\(files)" + tl(" 个文件"))
let outURL = workdir.appendingPathComponent("patched-\(appName.replacingOccurrences(of: ".app", with: "")).ipa")
try zip.build().write(to: outURL)
try? fm.removeItem(at: stage)
return IPAResult(outputURL: outURL, entries: files, notes: notes)
}
}
// MARK: - Hex editing
enum HexTool {
static func search(_ data: Data, pattern: String, limit: Int = 500) -> [Int] {
let needle: [UInt8]
if pattern.contains(" ") || pattern.hasPrefix("0x") {
let cleaned = pattern.replacingOccurrences(of: "0x", with: "")
.replacingOccurrences(of: " ", with: "")
guard cleaned.count % 2 == 0 else { return [] }
var bytes: [UInt8] = []
var i = cleaned.startIndex
while i < cleaned.endIndex {
let next = cleaned.index(i, offsetBy: 2)
guard let b = UInt8(cleaned[i..<next], radix: 16) else { return [] }
bytes.append(b)
i = next
}
needle = bytes
} else {
needle = Array(pattern.utf8)
}
guard !needle.isEmpty else { return [] }
var hits: [Int] = []
let hay = [UInt8](data)
var i = 0
while i + needle.count <= hay.count && hits.count < limit {
var matched = true
for k in 0..<needle.count where hay[i + k] != needle[k] { matched = false; break }
if matched { hits.append(i); i += needle.count } else { i += 1 }
}
return hits
}
static func replace(_ data: Data, pattern: String, with replacement: String) -> (Data, Int) {
var bytes = [UInt8](data)
let needle: [UInt8]
let replacementBytes: [UInt8]
if pattern.contains(" ") || pattern.hasPrefix("0x") {
let cleaned = pattern.replacingOccurrences(of: "0x", with: "").replacingOccurrences(of: " ", with: "")
var out: [UInt8] = []
var i = cleaned.startIndex
while i < cleaned.endIndex {
// odd trailing hex digit: malformed input, refuse instead of trapping
guard cleaned.distance(from: i, to: cleaned.endIndex) >= 2,
let next = Optional(cleaned.index(i, offsetBy: 2)),
let b = UInt8(cleaned[i..<next], radix: 16) else { return (data, 0) }
out.append(b)
i = next
}
needle = out
} else {
needle = Array(pattern.utf8)
}
if replacement.contains(" ") || replacement.hasPrefix("0x") {
let cleaned = replacement.replacingOccurrences(of: "0x", with: "").replacingOccurrences(of: " ", with: "")
var out: [UInt8] = []
var i = cleaned.startIndex
while i < cleaned.endIndex {
// odd trailing hex digit: malformed input, refuse instead of trapping
guard cleaned.distance(from: i, to: cleaned.endIndex) >= 2,
let next = Optional(cleaned.index(i, offsetBy: 2)),
let b = UInt8(cleaned[i..<next], radix: 16) else { return (data, 0) }
out.append(b)
i = next
}
replacementBytes = out
} else {
replacementBytes = Array(replacement.utf8)
}
guard !needle.isEmpty, replacementBytes.count <= needle.count else { return (data, 0) }
var hits = 0
var i = 0
while i + needle.count <= bytes.count {
var matched = true
for k in 0..<needle.count where bytes[i + k] != needle[k] { matched = false; break }
if matched {
for k in 0..<replacementBytes.count { bytes[i + k] = replacementBytes[k] }
for k in replacementBytes.count..<needle.count { bytes[i + k] = 0 }
hits += 1
i += needle.count
} else {
i += 1
}
}
return (Data(bytes), hits)
}
}
// MARK: - xattr / decmpfs surgery
enum XattrTool {
static func list(_ path: String) -> [(String, Int)] {
var size = listxattr(path, nil, 0, 0)
guard size > 0 else { return [] }
var buffer = [CChar](repeating: 0, count: size)
size = listxattr(path, &buffer, size, 0)
guard size > 0 else { return [] }
var names: [String] = []
var current: [CChar] = []
for c in buffer {
if c == 0 {
if !current.isEmpty { names.append(String(cString: current)); current = [] }
} else { current.append(c) }
}
return names.map { name in
var vlen = getxattr(path, name, nil, 0, 0, 0)
if vlen < 0 { vlen = 0 }
return (name, vlen)
}
}
static func read(_ path: String, name: String) -> Data? {
let len = getxattr(path, name, nil, 0, 0, 0)
guard len > 0 else { return nil }
var buffer = [UInt8](repeating: 0, count: len)
let got = getxattr(path, name, &buffer, len, 0, 0)
guard got > 0 else { return nil }
return Data(buffer.prefix(got))
}
@discardableResult
static func write(_ path: String, name: String, value: Data) -> String? {
let rc = value.withUnsafeBytes { raw -> Int32 in
guard let base = raw.baseAddress else { return -1 }
return setxattr(path, name, base, value.count, 0, 0)
}
return rc == 0 ? nil : "setxattr errno=\(errno)"
}
@discardableResult
static func remove(_ path: String, name: String) -> String? {
removexattr(path, name, 0) == 0 ? nil : "removexattr errno=\(errno)"
}
static func decmpfsHeader(_ data: Data) -> String {
guard data.count >= 16 else { return tl("数据过短") }
let magic = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
guard magic == 0x636D_7066 else { return tl("不是 decmpfs(magic 0x") + "\(String(magic, radix: 16))" + tl(")") }
let type = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
let size = data.withUnsafeBytes { $0.load(fromByteOffset: 8, as: UInt64.self) }
let names: [UInt32: String] = [3: "zlib", 4: "zlib-chunked", 7: "lzvn", 8: "lzfse", 9: "lzbitmap", 11: "lzfse-chunked", 12: "lzvn-chunked"]
return tl("magic cmpf · 类型 ") + "\(type)" + tl(" (") + "\(names[type] ?? tl("未知"))" + tl(") · 原始大小 ") + "\(size)" + tl(" B · xattr ") + "\(data.count)" + tl(" B")
}
}
// MARK: - SQLite
#if canImport(SQLite3)
import SQLite3
enum SQLiteBrowser {
struct Result {
var columns: [String]
var rows: [[String]]
var error: String?
}
static func tables(_ path: String) -> [String] {
query(path, "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;").rows.compactMap { $0.first }
}
static func query(_ path: String, _ sql: String) -> Result {
var db: OpaquePointer?
guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let handle = db else {
return Result(columns: [], rows: [], error: tl("打开失败"))
}
defer { sqlite3_close(handle) }
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(handle, sql, -1, &stmt, nil) == SQLITE_OK, let s = stmt else {
return Result(columns: [], rows: [], error: String(cString: sqlite3_errmsg(handle)))
}
defer { sqlite3_finalize(s) }
var columns: [String] = []
for i in 0..<sqlite3_column_count(s) {
columns.append(String(cString: sqlite3_column_name(s, i)))
}
var rows: [[String]] = []
while sqlite3_step(s) == SQLITE_ROW {
var row: [String] = []
for i in 0..<sqlite3_column_count(s) {
if let cstr = sqlite3_column_text(s, i) {
row.append(String(cString: cstr))
} else {
row.append("NULL")
}
}
rows.append(row)
if rows.count >= 2000 { break }
}
return Result(columns: columns, rows: rows, error: nil)
}
}
#endif
// MARK: - Font installer
enum FontInstaller {
static func install(_ url: URL) -> String {
var error: Unmanaged<CFError>?
let ok = CTFontManagerRegisterFontsForURL(url as CFURL, .user, &error)
if ok { return tl("已注册到用户字体域:") + "\(url.lastPathComponent)" }
if let e = error?.takeRetainedValue() {
return tl("注册失败:") + "\(e)"
}
return tl("注册失败(无错误信息)")
}
static func installed() -> [String] {
let descriptors = CTFontManagerCopyAvailablePostScriptNames() as? [String] ?? []
return descriptors.sorted()
}
}
// MARK: - System telemetry
enum Telemetry {
static func battery() -> [(String, String)] {
var rows: [(String, String)] = []
guard let sym = DyLib.shared.symbol("IOPSCopyPowerSourcesInfo") else {
return [(tl("电源"), tl("IOPSCopyPowerSourcesInfo 不可用"))]
}
typealias CopyFn = @convention(c) () -> Unmanaged<CFTypeRef>?
let fn = unsafeBitCast(sym, to: CopyFn.self)
guard let info = fn()?.takeRetainedValue() else { return [(tl("电源"), tl("无数据"))] }
let arr = info as? [[String: Any]] ?? []
for source in arr {
if let cap = source["Current Capacity"] as? Int,
let max = source["Max Capacity"] as? Int, max > 0 {
rows.append((tl("电量"), "\(cap * 100 / max)%"))
}
if let charging = source["Is Charging"] as? Bool {
rows.append((tl("充电中"), charging ? tl("是") : tl("否")))
}
if let name = source["Name"] as? String { rows.append((tl("电源"), name)) }
if let temp = source["Temperature"] as? Int { rows.append((tl("温度"), "\(Double(temp) / 10.0) °C")) }
}
if rows.isEmpty { rows.append((tl("电源"), tl("未返回数据(沙盒或接口不可用)"))) }
return rows
}
static func interfaces() -> [(String, String)] {
var rows: [(String, String)] = []
var addrs: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrs) == 0, let first = addrs else { return [(tl("网络"), tl("getifaddrs 失败"))] }
defer { freeifaddrs(addrs) }
var seen = Set<String>()
var cursor: UnsafeMutablePointer<ifaddrs>? = first
while let p = cursor {
let ifa = p.pointee
let name = String(cString: ifa.ifa_name)
if !seen.contains(name), let addr = ifa.ifa_addr {
let family = addr.pointee.sa_family
if family == UInt8(AF_INET) || family == UInt8(AF_INET6) {
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(addr, socklen_t(addr.pointee.sa_len), &host, socklen_t(host.count),
nil, 0, NI_NUMERICHOST) == 0 {
let text = String(cString: host)
if !text.hasPrefix("fe80") && text != "::1" && text != "127.0.0.1" {
rows.append((name, text))
seen.insert(name)
}
}
}
}
cursor = ifa.ifa_next
}
return rows.isEmpty ? [(tl("网络"), tl("无外部地址"))] : rows
}
static func thermal() -> [(String, String)] {
var rows: [(String, String)] = []
switch ProcessInfo.processInfo.thermalState {
case .nominal: rows.append((tl("热状态"), "nominal"))
case .fair: rows.append((tl("热状态"), "fair"))
case .serious: rows.append((tl("热状态"), "serious"))
case .critical: rows.append((tl("热状态"), "critical"))
@unknown default: rows.append((tl("热状态"), "unknown"))
}
rows.append((tl("低电量模式"), ProcessInfo.processInfo.isLowPowerModeEnabled ? tl("开") : tl("关")))
rows.append((tl("物理内存"), ByteCountFormatter.string(fromByteCount: Int64(ProcessInfo.processInfo.physicalMemory), countStyle: .memory)))
if let stats = Mach.vmStats() {
rows.append((tl("空闲"), ByteCountFormatter.string(fromByteCount: Int64(stats.free), countStyle: .memory)))
rows.append((tl("活跃"), ByteCountFormatter.string(fromByteCount: Int64(stats.active), countStyle: .memory)))
rows.append((tl("联动"), ByteCountFormatter.string(fromByteCount: Int64(stats.wired), countStyle: .memory)))
}
rows.append((tl("运行时长"), uptimeString()))
return rows
}
private static func uptimeString() -> String {
var boottime = timeval()
var size = MemoryLayout<timeval>.size
guard sysctlbyname("kern.boottime", &boottime, &size, nil, 0) == 0 else { return "—" }
let uptime = Date().timeIntervalSince1970 - Double(boottime.tv_sec)
let h = Int(uptime) / 3600
let m = (Int(uptime) % 3600) / 60
return "\(h)h \(m)m"
}
}
// MARK: - Provisioning profile census
enum ProfileCensus {
struct Profile {
var name: String
var uuid: String
var teamID: String
var appID: String
var expires: Date?
var devices: Int
var entitlements: [String]
var path: String
}
static func installed() -> [Profile] {
let dirs = ["/private/var/MobileDevice/ProvisioningProfiles",
"/private/var/mobile/Library/MobileDevice/ProvisioningProfiles"]
var out: [Profile] = []
for dir in dirs {
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir) else { continue }
for name in names where name.hasSuffix(".mobileprovision") {
let path = (dir as NSString).appendingPathComponent(name)
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let text = String(data: data, encoding: .isoLatin1),
let start = text.range(of: "<?xml"),
let end = text.range(of: "</plist>") else { continue }
let xml = String(text[start.lowerBound..<end.upperBound])
guard let plistData = xml.data(using: .utf8),
let obj = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] else { continue }
let ents = obj["Entitlements"] as? [String: Any] ?? [:]
out.append(Profile(
name: obj["Name"] as? String ?? name,
uuid: obj["UUID"] as? String ?? "—",
teamID: (obj["TeamIdentifier"] as? [String])?.first ?? "—",
appID: ents["application-identifier"] as? String ?? "—",
expires: obj["ExpirationDate"] as? Date,
devices: (obj["ProvisionedDevices"] as? [String])?.count ?? 0,
entitlements: ents.keys.sorted(),
path: path))
}
}
return out
}
}