Files

306 lines
12 KiB
Swift

import Foundation
import Combine
struct FileItem: Identifiable, Hashable {
var id: String { path }
let name: String
let path: String
let isDirectory: Bool
let size: Int64
let modified: Date?
let isSymlink: Bool
var icon: String {
if isDirectory { return "folder.fill" }
switch (name as NSString).pathExtension.lowercased() {
case "plist": return "list.bullet.rectangle"
case "db", "sqlite", "sqlite3": return "cylinder.split.1x2"
case "ipa", "deb", "zip", "tar", "gz", "xz": return "shippingbox.fill"
case "dylib", "so": return "puzzlepiece.extension"
case "png", "jpg", "jpeg", "heic", "gif": return "photo"
case "json", "xml", "strings", "txt", "log": return "doc.text"
case "mobileprovision": return "checkmark.seal"
case "car", "nib", "storyboardc": return "square.stack.3d.up"
default: return "doc"
}
}
var displaySize: String {
if isDirectory { return "—" }
let f = ByteCountFormatter()
f.countStyle = .file
return f.string(fromByteCount: size)
}
}
final class FileBrowser: ObservableObject {
@Published private(set) var path: String
@Published private(set) var items: [FileItem] = []
@Published private(set) var error: String?
@Published private(set) var writable: Bool = false
private let fm = FileManager.default
init(path: String) {
self.path = Container.normalize(path)
reload()
}
func reload() {
var isDir: ObjCBool = false
guard fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
items = []
error = Container.describe(ENOENT)
return
}
writable = Container.probe(URL(fileURLWithPath: path, isDirectory: true)).canWrite
do {
let names = try fm.contentsOfDirectory(atPath: path)
var list: [FileItem] = []
for name in names {
let full = (path as NSString).appendingPathComponent(name)
var dirFlag: ObjCBool = false
let exists = fm.fileExists(atPath: full, isDirectory: &dirFlag)
let attrs = try? fm.attributesOfItem(atPath: full)
let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
let modified = attrs?[.modificationDate] as? Date
let symlink = (try? fm.destinationOfSymbolicLink(atPath: full)) != nil
list.append(FileItem(name: name, path: full,
isDirectory: exists && dirFlag.boolValue,
size: size, modified: modified, isSymlink: symlink))
}
items = list.sorted {
if $0.isDirectory != $1.isDirectory { return $0.isDirectory }
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
}
error = nil
} catch {
items = []
let code = errno
self.error = "\(Container.describe(code)) · \(path)"
}
}
func enter(_ item: FileItem) {
guard item.isDirectory else { return }
path = item.path
reload()
}
func up() {
guard path != "/" else { return }
path = (path as NSString).deletingLastPathComponent
if path.isEmpty { path = "/" }
reload()
}
func chdir(_ newPath: String) {
path = Container.normalize(newPath)
reload()
}
@discardableResult
func delete(_ item: FileItem) -> String? {
do { try fm.removeItem(atPath: item.path); reload(); return nil }
catch { return error.localizedDescription }
}
@discardableResult
func makeDirectory(_ name: String) -> String? {
let target = (path as NSString).appendingPathComponent(name)
do {
try fm.createDirectory(atPath: target, withIntermediateDirectories: true)
// prove it took, rather than trusting the call
let probe = Container.probe(URL(fileURLWithPath: target, isDirectory: true))
reload()
return probe.ok ? nil : tl("已创建但探针失败: ") + "\(probe.note)"
} catch {
let code = errno
return "\(Container.describe(code)) · \(target)"
}
}
@discardableResult
func rename(_ item: FileItem, to newName: String) -> String? {
let dest = (path as NSString).appendingPathComponent(newName)
do { try fm.moveItem(atPath: item.path, toPath: dest); reload(); return nil }
catch { let code = errno; return "\(Container.describe(code)) · \(dest)" }
}
func fileInfo(_ item: FileItem) -> [(String, String)] {
var rows: [(String, String)] = []
rows.append((t("files.path"), item.path))
rows.append(("Type", item.isDirectory ? "dir" : ((item.name as NSString).pathExtension.isEmpty ? "file" : (item.name as NSString).pathExtension)))
rows.append(("Size", item.isDirectory ? "—" : "\(item.size) B"))
if let m = item.modified { rows.append(("Modified", Self.dateFormatter.string(from: m))) }
if let attrs = try? fm.attributesOfItem(atPath: item.path) {
if let perm = attrs[.posixPermissions] as? NSNumber {
rows.append(("Mode", String(perm.intValue, radix: 8)))
}
if let owner = attrs[.ownerAccountName] as? String { rows.append(("Owner", owner)) }
}
let xattrs = listxattrs(item.path)
rows.append((t("pane.xattr"), xattrs.isEmpty ? t("common.none") : xattrs.joined(separator: ", ")))
return rows
}
private func listxattrs(_ path: String) -> [String] {
let name = path
var size = listxattr(name, nil, 0, 0)
guard size > 0 else { return [] }
var buffer = [CChar](repeating: 0, count: size)
size = listxattr(name, &buffer, size, 0)
guard size > 0 else { return [] }
var out: [String] = []
var current: [CChar] = []
for c in buffer {
if c == 0 {
if !current.isEmpty { out.append(String(cString: current)); current = [] }
} else {
current.append(c)
}
}
return out
}
static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
}
// MARK: - Text / plist / hex
enum FileKind {
case text, plist, binary, image
}
enum FileLoader {
static func kind(of path: String) -> FileKind {
let ext = (path as NSString).pathExtension.lowercased()
if ["plist", "strings", "xml", "json", "txt", "log", "conf", "cfg", "sh", "py", "swift", "c", "h", "mm", "js"].contains(ext) {
return ext == "plist" ? .plist : .text
}
if ["png", "jpg", "jpeg", "gif", "heic", "webp"].contains(ext) { return .image }
return .binary
}
static func text(at path: String, limit: Int = 400_000) -> String? {
guard let handle = FileHandle(forReadingAtPath: path) else { return nil }
defer { try? handle.close() }
let data = handle.readData(ofLength: limit)
if let s = String(data: data, encoding: .utf8) { return s }
if let s = String(data: data, encoding: .isoLatin1) { return s }
return nil
}
static func plist(at path: String) -> String? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
if let obj = try? PropertyListSerialization.propertyList(from: data, format: nil) {
if let xml = try? PropertyListSerialization.data(fromPropertyList: obj, format: .xml, options: 0),
let s = String(data: xml, encoding: .utf8) {
return s
}
return "\(obj)"
}
return nil
}
static func hexDump(at path: String, limit: Int = 8192) -> String? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
return hexdump(data, limit: limit)
}
@discardableResult
static func write(_ text: String, to path: String) -> String? {
do { try Container.write(text, to: path); return nil }
catch { let code = errno; return "\(Container.describe(code)) · \(path)" }
}
@discardableResult
static func write(_ data: Data, to path: String) -> String? {
do { try Container.write(data, to: path); return nil }
catch { let code = errno; return "\(Container.describe(code)) · \(path)" }
}
}
// MARK: - Package inspection (IPA / DEB / ZIP)
struct PackageEntry: Identifiable {
var id: String { name }
let name: String
let size: Int64
}
enum PackageTool {
static func isZip(_ path: String) -> Bool {
guard let h = FileHandle(forReadingAtPath: path) else { return false }
defer { try? h.close() }
let magic = h.readData(ofLength: 4)
return magic == Data([0x50, 0x4B, 0x03, 0x04]) || magic == Data([0x50, 0x4B, 0x05, 0x06])
}
/// Uses libarchive so IPA, DEB, tar and gzip all go through one path.
static func list(_ path: String) -> [PackageEntry] {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return [] }
guard let (entries, _) = try? Runners.Archive.list(data) else { return [] }
return entries.map { line in
let parts = line.components(separatedBy: " (")
let name = parts.first ?? line
let sizeText = parts.count > 1 ? parts[1].replacingOccurrences(of: " B)", with: "") : "0"
return PackageEntry(name: name, size: Int64(sizeText) ?? 0)
}
}
static func extract(_ path: String, to directory: URL) -> String {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
return tl("读取失败")
}
let result = Runners.Archive.extractAll(data, to: directory)
if result.count == 0 && result.errors == 0 { return result.status }
return "✓ \(result.count) / \(result.errors)"
}
}
// MARK: - Capability matrix
struct CapabilityRow: Identifiable {
var id: String { name }
let name: String
let level: String
let achieved: Bool
let detail: String
}
enum Capability {
static func matrix() -> [CapabilityRow] {
let canReadSystem = (try? FileManager.default.contentsOfDirectory(atPath: "/System/Library/Frameworks")) != nil
let canReadVar = (try? FileManager.default.contentsOfDirectory(atPath: "/private/var/mobile/Library")) != nil
let canReadOthers = (try? FileManager.default.contentsOfDirectory(atPath: "/private/var/mobile/Containers/Data/Application")) != nil
let canReadJB = FileManager.default.fileExists(atPath: "/private/var/jb")
let canLoadDylib = dlopen("/usr/lib/libarchive.2.dylib", RTLD_NOW) != nil
// L0 is fixed, not probed. The container is writable by construction — an app that
// cannot write its own Documents directory cannot run at all. Every probe we tried
// here (access(W_OK) on the container root, a create/write/fsync cycle inside it)
// measures the sandbox's answer about a *path*, not about the extension the process
// actually holds, and reports false on real hardware for a container that writes
// perfectly well. The honest baseline is the fact itself.
return [
CapabilityRow(name: t("cap.l0"), level: "L0", achieved: true,
detail: "~/Documents · ~/Library · ~/tmp"),
CapabilityRow(name: t("cap.l1"), level: "L1", achieved: canReadSystem,
detail: "/System/Library/Frameworks"),
CapabilityRow(name: t("cap.l2"), level: "L2", achieved: canReadVar,
detail: canReadVar ? t("cap.yes") : t("cap.no")),
CapabilityRow(name: t("cap.l3"), level: "L3", achieved: canReadOthers,
detail: canReadOthers ? t("cap.yes") : t("cap.no")),
CapabilityRow(name: t("cap.l4"), level: "L4", achieved: canLoadDylib,
detail: "libarchive.2.dylib"),
CapabilityRow(name: t("cap.l5"), level: "L5", achieved: canReadJB,
detail: canReadJB ? t("cap.yes") : t("cap.notJailbroken")),
]
}
}