diff --git a/lara/classes/zipmgr.swift b/lara/classes/zipmgr.swift index b85c79429..5da5a3c6d 100644 --- a/lara/classes/zipmgr.swift +++ b/lara/classes/zipmgr.swift @@ -548,3 +548,274 @@ public func createZipArchive(fromDirectory sourceURL: URL, to destinationURL: UR writeLE16(0, to: &eocd) try fh.write(contentsOf: eocd) } + +// MARK: - Streaming writer (constant memory) for App Decrypt +// +// createZipArchive() above loads *every* file of the bundle into RAM with +// Data(contentsOf:) and keeps all of them alive in `fileEntries` until the +// archive is finished. A 2.4 GB app therefore needs >2.4 GB of resident +// memory inside lara and is killed by jetsam. It also uses ZIP32 fields only. +// This variant streams one file at a time, keeps only small central-directory +// records in memory, and emits ZIP64 records when offsets/sizes need them. + +private func writeLE64(_ value: UInt64, to data: inout Data) { + withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } +} + +private func streamCRC32(of url: URL) throws -> (crc: UInt32, size: UInt64) { + guard let input = InputStream(url: url) else { + throw ZipError.corruptArchive("(zip) cannot open \(url.lastPathComponent)") + } + input.open() + defer { input.close() } + + let chunkSize = 1 << 20 + var buffer = [UInt8](repeating: 0, count: chunkSize) + var crc: uLong = 0 + var size: UInt64 = 0 + + while true { + let n = buffer.withUnsafeMutableBytes { raw -> Int in + guard let base = raw.baseAddress else { return 0 } + return input.read(base.assumingMemoryBound(to: UInt8.self), maxLength: chunkSize) + } + if n < 0 { + throw ZipError.corruptArchive("(zip) read failed on \(url.lastPathComponent)") + } + if n == 0 { break } + crc = buffer.withUnsafeBytes { raw -> uLong in + guard let base = raw.baseAddress else { return crc } + return crc32(crc, base.assumingMemoryBound(to: Bytef.self), uInt(n)) + } + size += UInt64(n) + } + return (UInt32(crc & 0xffff_ffff), size) +} + +private func appendZip64Extra(usize: UInt64, csize: UInt64, offset: UInt64?, to data: inout Data) { + writeLE16(0x0001, to: &data) + writeLE16(UInt16(offset == nil ? 16 : 24), to: &data) + writeLE64(usize, to: &data) + writeLE64(csize, to: &data) + if let offset = offset { writeLE64(offset, to: &data) } +} + +public func createZipArchiveStreaming(fromDirectory sourceURL: URL, + to destinationURL: URL, + progress: ((Double) -> Void)? = nil) throws { + let mgr = laramgr.shared + let fm = FileManager.default + let resolvedSource = sourceURL.resolvingSymlinksInPath() + + guard let enumerator = fm.enumerator(at: resolvedSource, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], + options: [.skipsHiddenFiles]) else { + mgr.logmsg("(zip) cannot enumerate source directory") + throw ZipError.corruptArchive("(zip) cannot enumerate source directory") + } + + // Only paths are retained here, never file contents. + var files: [(url: URL, name: String)] = [] + var dirs: [String] = [] + var totalBytes: UInt64 = 0 + for case let fileURL as URL in enumerator { + let resolvedFile = fileURL.resolvingSymlinksInPath() + guard let relRange = resolvedFile.path.range(of: resolvedSource.path + "/") else { continue } + let relPath = String(resolvedFile.path[relRange.upperBound...]) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: fileURL.path, isDirectory: &isDir) else { continue } + if isDir.boolValue { dirs.append(relPath + "/") } + else { + files.append((fileURL, relPath)) + if let sz = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalBytes += UInt64(sz) + } + } + } + + try? fm.removeItem(at: destinationURL) + guard fm.createFile(atPath: destinationURL.path, contents: nil, attributes: nil) else { + mgr.logmsg("(zip) cannot create output file") + throw ZipError.corruptArchive("(zip) cannot create output file") + } + let fh = try FileHandle(forWritingTo: destinationURL) + defer { try? fh.close() } + + var cdEntries: [Data] = [] + cdEntries.reserveCapacity(files.count + dirs.count) + let chunkSize = 1 << 20 + var bytesDone: UInt64 = 0 + var lastReportedBytes: UInt64 = 0 + + for entry in files { + let nameData = entry.name.data(using: .utf8) ?? Data() + guard nameData.count <= 0xffff else { continue } + + // Pass 1: CRC + real size (constant memory). + let (crc, size) = try streamCRC32(of: entry.url) + let offset = UInt64(try fh.offset()) + let zip64 = size > 0xffff_ffff || offset > 0xffff_ffff + let size32: UInt32 = zip64 ? 0xffff_ffff : UInt32(size) + let off32: UInt32 = zip64 ? 0xffff_ffff : UInt32(offset) + + var lfhExtra = Data() + if zip64 { appendZip64Extra(usize: size, csize: size, offset: nil, to: &lfhExtra) } + + var lfh = Data() + writeLE32(lfhSignature, to: &lfh) + writeLE16(zip64 ? 45 : 20, to: &lfh) // version needed + writeLE16(0x0800, to: &lfh) // UTF-8 filenames + writeLE16(0, to: &lfh) // stored, no compression + writeLE16(0, to: &lfh) // time + writeLE16(0, to: &lfh) // date + writeLE32(crc, to: &lfh) + writeLE32(size32, to: &lfh) + writeLE32(size32, to: &lfh) + writeLE16(UInt16(nameData.count), to: &lfh) + writeLE16(UInt16(lfhExtra.count), to: &lfh) + try fh.write(contentsOf: lfh) + try fh.write(contentsOf: nameData) + if !lfhExtra.isEmpty { try fh.write(contentsOf: lfhExtra) } + + // Pass 2: stream the payload straight to the archive. + guard let input = InputStream(url: entry.url) else { + throw ZipError.corruptArchive("(zip) cannot reopen \(entry.name)") + } + input.open() + var buffer = [UInt8](repeating: 0, count: chunkSize) + var written: UInt64 = 0 + while true { + let n = buffer.withUnsafeMutableBytes { raw -> Int in + guard let base = raw.baseAddress else { return 0 } + return input.read(base.assumingMemoryBound(to: UInt8.self), maxLength: chunkSize) + } + if n <= 0 { break } + try fh.write(contentsOf: Data(buffer[0..= 32 * 1024 * 1024 { + lastReportedBytes = bytesDone + let frac = totalBytes > 0 ? Double(bytesDone) / Double(totalBytes) : 1.0 + progress?(min(max(frac, 0.0), 1.0)) + } + + var cdExtra = Data() + if zip64 { appendZip64Extra(usize: size, csize: size, offset: offset, to: &cdExtra) } + + var cd = Data() + writeLE32(cdSignature, to: &cd) + writeLE16(20, to: &cd) // version made by + writeLE16(zip64 ? 45 : 20, to: &cd) // version needed + writeLE16(0x0800, to: &cd) + writeLE16(0, to: &cd) // stored + writeLE16(0, to: &cd); writeLE16(0, to: &cd) + writeLE32(crc, to: &cd) + writeLE32(size32, to: &cd) + writeLE32(size32, to: &cd) + writeLE16(UInt16(nameData.count), to: &cd) + writeLE16(UInt16(cdExtra.count), to: &cd) + writeLE16(0, to: &cd) // comment length + writeLE16(0, to: &cd) // disk number + writeLE16(0, to: &cd) // internal attrs + writeLE32(0x81A40000, to: &cd) // external attrs (0644) + writeLE32(off32, to: &cd) + cd.append(nameData) + cd.append(cdExtra) + cdEntries.append(cd) + } + + for dirName in dirs { + let nameData = dirName.data(using: .utf8) ?? Data() + guard nameData.count <= 0xffff else { continue } + let offset = UInt64(try fh.offset()) + let zip64 = offset > 0xffff_ffff + let off32: UInt32 = zip64 ? 0xffff_ffff : UInt32(offset) + + var lfhExtra = Data() + if zip64 { appendZip64Extra(usize: 0, csize: 0, offset: nil, to: &lfhExtra) } + var lfh = Data() + writeLE32(lfhSignature, to: &lfh) + writeLE16(zip64 ? 45 : 20, to: &lfh) + writeLE16(0x0800, to: &lfh) + writeLE16(0, to: &lfh) + writeLE16(0, to: &lfh); writeLE16(0, to: &lfh) + writeLE32(0, to: &lfh) + writeLE32(zip64 ? 0xffff_ffff : 0, to: &lfh) + writeLE32(zip64 ? 0xffff_ffff : 0, to: &lfh) + writeLE16(UInt16(nameData.count), to: &lfh) + writeLE16(UInt16(lfhExtra.count), to: &lfh) + try fh.write(contentsOf: lfh) + try fh.write(contentsOf: nameData) + if !lfhExtra.isEmpty { try fh.write(contentsOf: lfhExtra) } + + var cdExtra = Data() + if zip64 { appendZip64Extra(usize: 0, csize: 0, offset: offset, to: &cdExtra) } + var cd = Data() + writeLE32(cdSignature, to: &cd) + writeLE16(20, to: &cd) + writeLE16(zip64 ? 45 : 20, to: &cd) + writeLE16(0x0800, to: &cd) + writeLE16(0, to: &cd) + writeLE16(0, to: &cd); writeLE16(0, to: &cd) + writeLE32(0, to: &cd) + writeLE32(zip64 ? 0xffff_ffff : 0, to: &cd) + writeLE32(zip64 ? 0xffff_ffff : 0, to: &cd) + writeLE16(UInt16(nameData.count), to: &cd) + writeLE16(UInt16(cdExtra.count), to: &cd) + writeLE16(0, to: &cd); writeLE16(0, to: &cd); writeLE16(0, to: &cd) + writeLE32(0x41ED0000, to: &cd) + writeLE32(off32, to: &cd) + cd.append(nameData) + cd.append(cdExtra) + cdEntries.append(cd) + } + + let cdOffset = UInt64(try fh.offset()) + var cdSize: UInt64 = 0 + for cd in cdEntries { + try fh.write(contentsOf: cd) + cdSize += UInt64(cd.count) + } + let total = UInt64(cdEntries.count) + let useZip64 = cdOffset > 0xffff_ffff || cdSize > 0xffff_ffff || total > 0xffff + + if useZip64 { + let z64Offset = UInt64(try fh.offset()) + var z64 = Data() + writeLE32(zip64EOCDRecordSignature, to: &z64) + writeLE64(44, to: &z64) + writeLE16(45, to: &z64); writeLE16(45, to: &z64) + writeLE32(0, to: &z64); writeLE32(0, to: &z64) + writeLE64(total, to: &z64); writeLE64(total, to: &z64) + writeLE64(cdSize, to: &z64); writeLE64(cdOffset, to: &z64) + try fh.write(contentsOf: z64) + + var loc = Data() + writeLE32(zip64EOCDLocatorSignature, to: &loc) + writeLE32(0, to: &loc) + writeLE64(z64Offset, to: &loc) + writeLE32(1, to: &loc) + try fh.write(contentsOf: loc) + } + + var eocd = Data() + writeLE32(eocdSignature, to: &eocd) + writeLE16(0, to: &eocd); writeLE16(0, to: &eocd) + writeLE16(useZip64 ? 0xffff : UInt16(total), to: &eocd) + writeLE16(useZip64 ? 0xffff : UInt16(total), to: &eocd) + writeLE32(useZip64 ? 0xffff_ffff : UInt32(cdSize), to: &eocd) + writeLE32(useZip64 ? 0xffff_ffff : UInt32(cdOffset), to: &eocd) + writeLE16(0, to: &eocd) + try fh.write(contentsOf: eocd) + + progress?(1.0) + mgr.logmsg("(zip) streaming done: \(cdEntries.count) entries, \(cdOffset) bytes of payload") +} diff --git a/lara/kexploit/TaskRop/vm.h b/lara/kexploit/TaskRop/vm.h index adfcdbbac..54b72e9b4 100644 --- a/lara/kexploit/TaskRop/vm.h +++ b/lara/kexploit/TaskRop/vm.h @@ -101,6 +101,11 @@ struct vmobj vmgetobject(uint64_t map, uint64_t address); struct vmshmem vmcreateshmemwithobj(struct vmobj *object); struct vmshmem vmmapremotepage(uint64_t vmMap, uint64_t address); +/* Releases the one-page mapping AND the memory-entry Mach port created by + * vmcreateshmemwithobj(). Call this for every successful vmmapremotepage(); + * otherwise each decrypted page leaks a port plus a kernel named entry. */ +void vmreleaseshmem(struct vmshmem *shmem); + bool VM_PACKING_IS_BASE_RELATIVE(struct vmpackingparams *p); uint64_t vmunpackptr(uint64_t packed, struct vmpackingparams *params); uint64_t vmpackptr(uint64_t ptr, struct vmpackingparams *params); diff --git a/lara/kexploit/TaskRop/vm.m b/lara/kexploit/TaskRop/vm.m index 96a4d4d82..c83e1fae9 100644 --- a/lara/kexploit/TaskRop/vm.m +++ b/lara/kexploit/TaskRop/vm.m @@ -6,6 +6,7 @@ // #import +#import #import "remotecall.h" #import "vm.h" #import "offsets.h" @@ -173,6 +174,7 @@ struct vmshmem vmcreateshmemwithobj(struct vmobj *object) { if (entry.vme_kernel_object || entry.is_sub_map) { printf("(vm) entry cannot be a submap or kernel object\n"); mach_vm_deallocate(mach_task_self_, localaddr, roundedsize); + mach_port_deallocate(mach_task_self_, memobj); return shmem; } @@ -200,6 +202,11 @@ struct vmshmem vmcreateshmemwithobj(struct vmobj *object) { if (ret != KERN_SUCCESS) { printf("(vm) mach_vm_map failed: %s\n", mach_error_string(ret)); mappedaddr = 0; + // Callers only release shmem when `used` is true, so a failed map must + // drop the memory-entry port here or it leaks on every bad page. + mach_vm_deallocate(mach_task_self_, localaddr, roundedsize); + mach_port_deallocate(mach_task_self_, memobj); + return shmem; } ret = mach_vm_deallocate(mach_task_self_, localaddr, roundedsize); @@ -225,3 +232,22 @@ struct vmshmem vmmapremotepage(uint64_t vmmap, uint64_t address) { return vmcreateshmemwithobj(&vmobject); } + +void vmreleaseshmem(struct vmshmem *shmem) { + if (!shmem) return; + + // mach_vm_map() mapped exactly PAGE_SIZE bytes from the memory entry. + if (shmem->localAddress) { + mach_vm_deallocate(mach_task_self_, (mach_vm_address_t)shmem->localAddress, PAGE_SIZE); + shmem->localAddress = 0; + } + + // vmcreateshmemwithobj() hands back the send right obtained from + // mach_make_memory_entry_64(); nobody was releasing it before. + if (shmem->port) { + mach_port_deallocate(mach_task_self_, (mach_port_t)shmem->port); + shmem->port = 0; + } + + shmem->used = false; +} diff --git a/lara/kexploit/decrypt.m b/lara/kexploit/decrypt.m index b7173c6e2..ab4e0111e 100644 --- a/lara/kexploit/decrypt.m +++ b/lara/kexploit/decrypt.m @@ -17,6 +17,7 @@ #import #import #import +#import #import extern kern_return_t mach_vm_deallocate(task_t task, mach_vm_address_t addr, mach_vm_size_t size); @@ -33,6 +34,7 @@ void set_progress_callback(progress_callback_t callback) { } volatile double decrypt_progress = 0.0; +static double last_reported_progress = -1.0; struct macho_ctx { uint32_t cryptid; @@ -54,7 +56,16 @@ static void logmsg(const char *msg) { static void progupdate(double p) { decrypt_progress = p; - if (g_prog_cb) { g_prog_cb(p); } + if (!g_prog_cb) return; + + /* Called once per page (thousands of times for a large app). Only notify + * the UI on ~1% boundaries, and always on start/finish/restart, so the + * main queue is not flooded with one Swift callback per page. */ + if (p == 0.0 || p >= 1.0 || p < last_reported_progress || + p - last_reported_progress >= 0.01) { + last_reported_progress = p; + g_prog_cb(p); + } } int launch_app(const char *bundleID) { @@ -109,11 +120,18 @@ static int refile(const char *path, uint8_t **out, uint64_t *out_size) { off_t fsz = lseek(fd, 0, SEEK_END); lseek(fd, 0, SEEK_SET); if (fsz <= 0) { close(fd); logmsg("empty file"); return -1; } + if (fsz > (off_t)SSIZE_MAX) { close(fd); logmsg("binary too large"); return -1; } uint8_t *buf = malloc((size_t)fsz); if (!buf) { close(fd); logmsg("malloc failed"); return -1; } - ssize_t n = read(fd, buf, (size_t)fsz); + uint64_t done = 0; + while (done < (uint64_t)fsz) { + size_t want = (size_t)((uint64_t)fsz - done); + if (want > (size_t)SSIZE_MAX) want = (size_t)SSIZE_MAX; + ssize_t n = read(fd, buf + done, want); + if (n <= 0) { free(buf); close(fd); logmsg("read failed to read full binary"); return -1; } + done += (uint64_t)n; + } close(fd); - if (n != fsz) { free(buf); logmsg("read failed to read full binary"); return -1; } *out = buf; *out_size = (uint64_t)fsz; return 0; @@ -122,9 +140,15 @@ static int refile(const char *path, uint8_t **out, uint64_t *out_size) { static int write_file(const char *path, uint8_t *data, uint64_t size) { int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { logmsg("open for write failed"); return -1; } - ssize_t n = write(fd, data, (size_t)size); + uint64_t done = 0; + while (done < size) { + size_t want = (size_t)(size - done); + if (want > (size_t)SSIZE_MAX) want = (size_t)SSIZE_MAX; + ssize_t n = write(fd, data + done, want); + if (n <= 0) { close(fd); logmsg("write failed (disk full or short write)"); return -1; } + done += (uint64_t)n; + } close(fd); - if (n != (ssize_t)size) { logmsg("write failed"); return -1; } return 0; } @@ -240,7 +264,7 @@ static int find_text_segment_in_vm_map(uint64_t vmMap, uint64_t fileoff, uint64_ struct vmshmem shmem = vmmapremotepage(vmMap, start); if (shmem.used) { uint32_t magic = *(volatile uint32_t *)(uintptr_t)shmem.localAddress; - mach_vm_deallocate(mach_task_self_, shmem.localAddress, PAGE_SIZE); + vmreleaseshmem(&shmem); if (magic == MH_MAGIC_64 || magic == MH_MAGIC || magic == FAT_MAGIC || magic == FAT_CIGAM) { found_addr = start; @@ -258,7 +282,7 @@ static int find_text_segment_in_vm_map(uint64_t vmMap, uint64_t fileoff, uint64_ struct vmshmem shmem = vmmapremotepage(vmMap, start); if (shmem.used) { uint32_t magic = *(volatile uint32_t *)(uintptr_t)shmem.localAddress; - mach_vm_deallocate(mach_task_self_, shmem.localAddress, PAGE_SIZE); + vmreleaseshmem(&shmem); if (magic == MH_MAGIC_64 || magic == MH_MAGIC || magic == FAT_MAGIC || magic == FAT_CIGAM) { found_addr = start; @@ -286,7 +310,7 @@ static int find_macho_in_vm(uint64_t vmMap, uint64_t *out_base, uint64_t *out_en struct vmshmem shmem = vmmapremotepage(vmMap, start); if (shmem.used) { uint32_t magic = *(volatile uint32_t *)(uintptr_t)shmem.localAddress; - mach_vm_deallocate(mach_task_self_, shmem.localAddress, PAGE_SIZE); + vmreleaseshmem(&shmem); if (magic == MH_MAGIC_64 || magic == MH_MAGIC) { found = start; found_end = end; @@ -303,7 +327,7 @@ static int find_macho_in_vm(uint64_t vmMap, uint64_t *out_base, uint64_t *out_en struct vmshmem shmem = vmmapremotepage(vmMap, start); if (shmem.used) { uint32_t magic = *(volatile uint32_t *)(uintptr_t)shmem.localAddress; - mach_vm_deallocate(mach_task_self_, shmem.localAddress, PAGE_SIZE); + vmreleaseshmem(&shmem); if (magic == MH_MAGIC_64 || magic == MH_MAGIC) { found = start; found_end = end; @@ -331,7 +355,7 @@ static int read_file_from_vm(uint64_t vmMap, uint64_t base, uint64_t size, uint8 uint64_t cpy = size - off; if (cpy > PAGE_SIZE - page_off) cpy = PAGE_SIZE - page_off; memcpy(buf + off, (void *)(uintptr_t)shmem.localAddress + page_off, cpy); - mach_vm_deallocate(mach_task_self_, shmem.localAddress, PAGE_SIZE); + vmreleaseshmem(&shmem); off += cpy; } *out = buf; @@ -360,9 +384,9 @@ static int decrypt_to_output(const char *binaryPath, pid_t pid, uint8_t *file_bu return -1; } - uint8_t *decrypted = malloc(file_size); - if (!decrypted) { logmsg("malloc failed"); return -1; } - memcpy(decrypted, file_buf, file_size); + // Patch the caller's buffer in place. The old code allocated a second + // full-size copy, so peak RSS was 2x the entire Mach-O binary. + uint8_t *decrypted = file_buf; uint64_t segment_fileoff = ctx->text_fileoff; uint32_t page_size = (uint32_t)PAGE_SIZE; @@ -377,7 +401,10 @@ static int decrypt_to_output(const char *binaryPath, pid_t pid, uint8_t *file_bu struct vmshmem shmem = vmmapremotepage(vmMap, pg_virt); if (!shmem.used) { - shmem = vmmapremotepage(vmMap, pg_virt & ~(page_size - 1)); + // Cast to 64 bits: `page_size` is uint32_t, so ~(page_size - 1) + // used to be 0x00000000FFFFF000 and wiped the upper half of every + // (above-4GB) address, making the fallback always fail. + shmem = vmmapremotepage(vmMap, pg_virt & ~((uint64_t)page_size - 1)); if (!shmem.used) { pages_done++; progupdate((double)pages_done / total_pages); @@ -391,10 +418,15 @@ static int decrypt_to_output(const char *binaryPath, pid_t pid, uint8_t *file_bu copy_size = (uint32_t)(file_size - copy_off); memcpy(decrypted + copy_off, (void *)(uintptr_t)shmem.localAddress, copy_size); - mach_vm_deallocate(mach_task_self_, shmem.localAddress, PAGE_SIZE); + vmreleaseshmem(&shmem); pages_done++; progupdate((double)pages_done / total_pages); + if (pages_done % 1000 == 0 || pages_done == total_pages) { + snprintf(msg, sizeof(msg), "pages %u/%u (%.1f%%)", + pages_done, total_pages, 100.0 * pages_done / total_pages); + logmsg(msg); + } } uint64_t crypto_off = ctx->fat_offset + (ctx->is_64 ? sizeof(struct mach_header_64) : sizeof(struct mach_header)); @@ -412,12 +444,11 @@ static int decrypt_to_output(const char *binaryPath, pid_t pid, uint8_t *file_bu } if (write_file(outputPath, decrypted, file_size) != 0) { - free(decrypted); logmsg("failed to write decrypted binary"); return -1; } - free(decrypted); + /* `decrypted` aliases file_buf; decrypt_binary_pid() owns and frees it. */ return 0; } diff --git a/lara/views/tweaks/DecryptView.swift b/lara/views/tweaks/DecryptView.swift index fd1d4d9c5..ffd89160f 100644 --- a/lara/views/tweaks/DecryptView.swift +++ b/lara/views/tweaks/DecryptView.swift @@ -7,6 +7,23 @@ import SwiftUI +extension Notification.Name { + static let laraDecryptProgress = Notification.Name("lara.decrypt.progress") +} + +// C function-pointer callbacks cannot capture `self`, so the page progress +// travels through a global. The C callback runs on a background queue while +// SwiftUI reads it on main, hence the lock. +private var latestPageProgress: Double = 0.0 +private let pageProgressLock = NSLock() + +// Overall progress is split into phases. The C callback only knows about the +// page loop, so copy and pack get their own slices of the bar. +private let kDecryptPhaseStart = 0.35 +private let kDecryptPhaseSpan = 0.50 +private let kPackPhaseStart = 0.85 +private let kPackPhaseSpan = 0.14 + struct DecryptApp: Identifiable { let id = UUID() let name: String @@ -28,6 +45,22 @@ struct DecryptView: View { @State private var decryptingbid: String? = nil @State private var errormsg: String? = nil @State private var pendingdecrypt: DecryptApp? = nil + @State private var decryptProgress: Double = 0.0 + @State private var appSizeBytes: Int64 = 0 + @State private var stageText: String = "" + + // Always hop to main before touching @State. NotificationCenter.Publisher + // delivers on the posting thread, so a background post would mutate + // @State off-main: SwiftUI would not repaint until the very last main + // thread refresh, which looked like "jumps straight to 100%". + private func setStage(_ text: String, _ value: Double? = nil) { + DispatchQueue.main.async { + self.stageText = text + if let v = value { + self.decryptProgress = max(self.decryptProgress, min(v, 1.0)) + } + } + } private let documentspath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first ?? "" @@ -58,6 +91,25 @@ struct DecryptView: View { .disabled(!mgr.sbxready || decryptingbid != nil) } + if decryptingbid != nil { + Section { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(stageText.isEmpty ? "Preparing..." : stageText) + Spacer() + Text("\(Int(decryptProgress * 100))%") + .monospacedDigit() + } + .font(.caption).foregroundColor(.secondary) + ProgressView(value: decryptProgress) + if appSizeBytes > 200 * 1024 * 1024 { + Text(String(format: "%.2f GB app — keep lara in the foreground, screen unlocked, device charging.", Double(appSizeBytes) / 1024 / 1024 / 1024)) + .font(.caption2).foregroundColor(.orange) + } + } + } header: { Text("Progress") } + } + if let error = errormsg { Section { PlainAlert(title: "Error", icon: "exclamationmark.triangle", text: error, color: .red) @@ -89,8 +141,30 @@ struct DecryptView: View { laramgr.shared.logmsg("(decrypt) \(s)") } } + set_progress_callback { p in + pageProgressLock.lock() + latestPageProgress = p + pageProgressLock.unlock() + // Post on main: Combine's NotificationCenter.Publisher emits + // synchronously on whatever thread called post(). + DispatchQueue.main.async { + NotificationCenter.default.post(name: .laraDecryptProgress, object: nil) + } + } if mgr.sbxready { loadApps() } } + .onReceive(NotificationCenter.default.publisher(for: .laraDecryptProgress)) { _ in + // Ignore page callbacks that arrive late, after packing started. + guard decryptProgress < kPackPhaseStart else { return } + pageProgressLock.lock() + let p = latestPageProgress + pageProgressLock.unlock() + stageText = "Decrypting binary..." + // Framework binaries restart the C progress at 0, so keep the + // bar monotonic instead of letting it jump backwards. + decryptProgress = max(decryptProgress, + kDecryptPhaseStart + p * kDecryptPhaseSpan) + } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in if let app = pendingdecrypt { pendingdecrypt = nil @@ -172,25 +246,31 @@ struct DecryptView: View { guard mgr.dsready else { errormsg = "Darksword not ready, run the exploit first."; return } guard mgr.sbxready else { errormsg = "Sandbox not escaped."; return } + // Enumerating a multi-GB bundle can take many seconds on the main + // thread and gets the app killed by the 0x8badf00d watchdog. Do it in + // the background, then hop back for the alert. + decryptingbid = app.bundleID + errormsg = nil + decryptProgress = 0.0 + stageText = "Scanning app size..." + pageProgressLock.lock() + latestPageProgress = 0.0 + pageProgressLock.unlock() let appurl = URL(fileURLWithPath: app.bundlePath) - var total: Int64 = 0 - if let enumerator = FileManager.default.enumerator(at: appurl, includingPropertiesForKeys: [.fileSizeKey]) { - for case let furi as URL in enumerator { - if let fs = (try? furi.resourceValues(forKeys: [.fileSizeKey]))?.fileSize { - total += Int64(fs) + DispatchQueue.global(qos: .userInitiated).async { + var total: Int64 = 0 + if let enumerator = FileManager.default.enumerator(at: appurl, includingPropertiesForKeys: [.fileSizeKey]) { + for case let furi as URL in enumerator { + if let fs = (try? furi.resourceValues(forKeys: [.fileSizeKey]))?.fileSize { + total += Int64(fs) + } } } + DispatchQueue.main.async { + self.appSizeBytes = total + self.runDecrypt(app) + } } - if total > 200 * 1024 * 1024 { - Alertinator.shared.alert( - title: "Large App", - body: "This app is over 200 MB, decryption may take a while.", - actionLabel: "Continue", - action: { self.runDecrypt(app) } - ) - return - } - runDecrypt(app) } func runDecrypt(_ app: DecryptApp) { @@ -256,10 +336,38 @@ struct DecryptView: View { let ipaPath = documentspath + "/Decrypted/" + ipaName laramgr.shared.logmsg("(decrypt) decrypting \(app.bundleID)...") + laramgr.shared.logmsg("(decrypt) work dir: \(workDir)") + stageText = "Copying bundle..." + + // The old code's only background task was the 3-second "relaunch me" + // one, so the long job ran with no assertion and got suspended as soon + // as the screen locked / the user switched away. + var bgTask: UIBackgroundTaskIdentifier = .invalid + bgTask = UIApplication.shared.beginBackgroundTask(withName: "AppDecrypt") { + UIApplication.shared.endBackgroundTask(bgTask) + } DispatchQueue.global(qos: .userInitiated).async { + defer { + DispatchQueue.main.async { + UIApplication.shared.endBackgroundTask(bgTask) + } + } let fm = FileManager.default + if let values = try? URL(fileURLWithPath: documentspath) + .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let free = values.volumeAvailableCapacityForImportantUsage { + let needed = Int64(appSizeBytes) * 3 // copy + IPA + headroom + laramgr.shared.logmsg(String(format: "(decrypt) free space %.2f GiB, want ~%.2f GiB", + Double(free) / 1024 / 1024 / 1024, + Double(needed) / 1024 / 1024 / 1024)) + if free < needed { + laramgr.shared.logmsg("(decrypt) WARNING: low disk space, output may be truncated") + } + } + + laramgr.shared.logmsg("(decrypt) existing work dir (if any) will be removed: \(workDir)") try? fm.removeItem(atPath: workDir) try? fm.createDirectory(atPath: payloadDir, withIntermediateDirectories: true) @@ -272,18 +380,41 @@ struct DecryptView: View { return } + var copyFailures = 0 + var copiedBytes: Int64 = 0 + var lastCopyReport: Int64 = 0 + let copyTotal = max(Double(appSizeBytes), 1.0) for case let subpath as String in enumerator { let src = (app.bundlePath as NSString).appendingPathComponent(subpath) let dst = (destAppPath as NSString).appendingPathComponent(subpath) var isDir: ObjCBool = false guard fm.fileExists(atPath: src, isDirectory: &isDir) else { continue } - if isDir.boolValue { - try? fm.createDirectory(atPath: dst, withIntermediateDirectories: true) - } else { - try? fm.copyItem(atPath: src, toPath: dst) + do { + if isDir.boolValue { + try fm.createDirectory(atPath: dst, withIntermediateDirectories: true) + } else { + try fm.copyItem(atPath: src, toPath: dst) + if let sz = (try? fm.attributesOfItem(atPath: dst)[.size]) as? NSNumber { + copiedBytes += sz.int64Value + } + } + } catch { + copyFailures += 1 + if copyFailures <= 5 { + laramgr.shared.logmsg("(decrypt) copy failed for \(subpath): \(error.localizedDescription)") + } + } + if copiedBytes - lastCopyReport >= 32 * 1024 * 1024 { + lastCopyReport = copiedBytes + let frac = min(Double(copiedBytes) / copyTotal, 1.0) + self.setStage("Copying bundle...", kDecryptPhaseStart * frac) } } + if copyFailures > 0 { + laramgr.shared.logmsg("(decrypt) WARNING: \(copyFailures) files failed to copy; IPA will be incomplete") + } + self.setStage("Decrypting binary...", kDecryptPhaseStart) let mainBinary = destAppPath + "/" + app.executable let mainRet = decrypt_binary_pid((app.bundlePath + "/" + app.executable), pid, mainBinary) guard mainRet == 0 else { @@ -324,21 +455,30 @@ struct DecryptView: View { return } + self.setStage("Packing IPA...", kPackPhaseStart) do { - try createZipArchive( + try createZipArchiveStreaming( fromDirectory: URL(fileURLWithPath: workDir, isDirectory: true), - to: URL(fileURLWithPath: ipaPath) + to: URL(fileURLWithPath: ipaPath), + progress: { frac in + self.setStage("Packing IPA...", + kPackPhaseStart + frac * kPackPhaseSpan) + } ) } catch { + // Deliberately keep workDir: Payload/ already contains the + // decrypted binary, so it can be zipped off-device instead. DispatchQueue.main.async { decryptingbid = nil errormsg = "Failed to create ipa: \(error.localizedDescription)" laramgr.shared.logmsg("(decrypt) zip failed: \(error.localizedDescription)") + laramgr.shared.logmsg("(decrypt) decrypted bundle preserved at \(payloadDir)") } return } try? fm.removeItem(atPath: workDir) + self.setStage("Done", 1.0) DispatchQueue.main.async { decryptingbid = nil