812 lines
33 KiB
Swift
812 lines
33 KiB
Swift
import SwiftUI
|
|
import CryptoKit
|
|
|
|
// MARK: - Hashing helpers
|
|
|
|
extension Data {
|
|
var sha256Hex: String {
|
|
SHA256.hash(data: self).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
var sha1Hex: String {
|
|
Insecure.SHA1.hash(data: self).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
var md5Hex: String {
|
|
Insecure.MD5.hash(data: self).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|
|
|
|
// MARK: - Apps
|
|
|
|
struct AppsPane: View {
|
|
@State private var apps: [InstalledApp] = []
|
|
@State private var query = ""
|
|
@State private var selected: InstalledApp?
|
|
@State private var loading = true
|
|
|
|
private var filtered: [InstalledApp] {
|
|
guard !query.isEmpty else { return apps }
|
|
return apps.filter {
|
|
$0.displayName.localizedCaseInsensitiveContains(query) ||
|
|
$0.bundleID.localizedCaseInsensitiveContains(query)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.white.opacity(0.3))
|
|
TextField("bundle id / name", text: $query)
|
|
.font(T.body)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
if loading { ProgressView().scaleEffect(0.6) }
|
|
Text("\(filtered.count)").font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
|
}
|
|
.padding(10)
|
|
.background(RoundedRectangle(cornerRadius: 10).fill(T.panel))
|
|
.padding(.horizontal, 16)
|
|
|
|
List {
|
|
ForEach(filtered) { app in
|
|
Button { selected = app } label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: app.isSystem ? "gearshape.2.fill" : "app.fill")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(app.isSystem ? T.info : T.ember)
|
|
.frame(width: 20)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(app.displayName).font(T.body).foregroundColor(.white.opacity(0.88))
|
|
Text(app.bundleID).font(T.monoSm).foregroundColor(.white.opacity(0.32))
|
|
.lineLimit(1).truncationMode(.middle)
|
|
}
|
|
Spacer()
|
|
if !app.version.isEmpty {
|
|
Text(app.version).font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(T.panel)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
.onAppear {
|
|
guard apps.isEmpty else { return }
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let list = AppCensus.installed()
|
|
DispatchQueue.main.async { apps = list; loading = false }
|
|
}
|
|
}
|
|
.sheet(item: $selected) { app in
|
|
AppDetailView(app: app).presentationDetents([.large])
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AppDetailView: View {
|
|
let app: InstalledApp
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var files: [String] = []
|
|
@State private var note = ""
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
SectionLabel(text: t("pane.apps"))
|
|
KeyValueRow(key: t("files.name"), value: app.displayName, mono: false)
|
|
KeyValueRow(key: "Bundle ID", value: app.bundleID)
|
|
KeyValueRow(key: "Version", value: app.version.isEmpty ? "—" : app.version)
|
|
KeyValueRow(key: "Type", value: app.isSystem ? "System" : (app.isPlaceholder ? "Placeholder" : "User"))
|
|
}
|
|
}
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
SectionLabel(text: "Container")
|
|
KeyValueRow(key: "Bundle", value: app.bundlePath.isEmpty ? "—" : app.bundlePath)
|
|
KeyValueRow(key: "Data", value: app.dataPath.isEmpty ? "—" : app.dataPath)
|
|
}
|
|
}
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
SectionLabel(text: "Contents")
|
|
if files.isEmpty {
|
|
Text(note.isEmpty ? t("cap.no") : note)
|
|
.font(T.monoSm).foregroundColor(.white.opacity(0.4))
|
|
} else {
|
|
ForEach(files, id: \.self) { line in
|
|
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.6))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(T.bg.ignoresSafeArea())
|
|
.navigationTitle(app.displayName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button(t("module.close")) { dismiss() }.tint(T.ember)
|
|
}
|
|
}
|
|
.onAppear(perform: load)
|
|
}
|
|
}
|
|
|
|
private func load() {
|
|
guard !app.dataPath.isEmpty else { note = "no data container"; return }
|
|
do {
|
|
files = try FileManager.default.contentsOfDirectory(atPath: app.dataPath).sorted()
|
|
} catch {
|
|
note = "\(Container.describe(errno))"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preferences
|
|
|
|
struct PrefsPane: View {
|
|
@State private var entries: [FileItem] = []
|
|
@State private var current = ""
|
|
@State private var editing: FileItem?
|
|
@State private var status = ""
|
|
|
|
private let candidates = [
|
|
"/private/var/mobile/Library/Preferences",
|
|
"/private/var/mobile/Library/Preferences/com.apple.springboard.plist",
|
|
"/private/var/mobile/Library/Preferences/com.apple.Accessibility.plist",
|
|
"/private/var/mobile/Library/Preferences/com.apple.UIKit.plist",
|
|
"/private/var/mobile/Library/Preferences/com.apple.keyboard.plist",
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 7) {
|
|
ForEach(candidates, id: \.self) { path in
|
|
Button { load(path) } label: {
|
|
Pill(text: (path as NSString).lastPathComponent,
|
|
color: current == path ? T.ember : .white.opacity(0.45))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 8)
|
|
}
|
|
|
|
if !status.isEmpty {
|
|
Text(status).font(T.monoSm).foregroundColor(T.warn)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16).padding(.bottom, 6)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
List {
|
|
ForEach(entries) { item in
|
|
Button { editing = item } label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: item.isDirectory ? "folder.fill" : "list.bullet.rectangle")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(item.isDirectory ? T.ember : .white.opacity(0.4))
|
|
.frame(width: 18)
|
|
Text(item.name).font(T.body).foregroundColor(.white.opacity(0.85)).lineLimit(1)
|
|
Spacer()
|
|
Text(item.displaySize).font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
|
}
|
|
}
|
|
.listRowBackground(T.panel)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
.onAppear { if current.isEmpty { load(candidates[0]) } }
|
|
.sheet(item: $editing) { item in
|
|
PlistEditorView(item: item).presentationDetents([.large])
|
|
}
|
|
}
|
|
|
|
private func load(_ path: String) {
|
|
current = path
|
|
entries = []
|
|
status = ""
|
|
let fm = FileManager.default
|
|
var isDir: ObjCBool = false
|
|
guard fm.fileExists(atPath: path, isDirectory: &isDir) else {
|
|
status = "\(path) — \(Container.describe(ENOENT))"
|
|
return
|
|
}
|
|
if isDir.boolValue {
|
|
guard let names = try? fm.contentsOfDirectory(atPath: path) else {
|
|
status = "\(path) — \(Container.describe(errno))"
|
|
return
|
|
}
|
|
entries = names.compactMap { name -> FileItem? in
|
|
let full = (path as NSString).appendingPathComponent(name)
|
|
var d: ObjCBool = false
|
|
_ = fm.fileExists(atPath: full, isDirectory: &d)
|
|
let size = ((try? fm.attributesOfItem(atPath: full))?[.size] as? NSNumber)?.int64Value ?? 0
|
|
return FileItem(name: name, path: full, isDirectory: d.boolValue, size: size,
|
|
modified: nil, isSymlink: false)
|
|
}.sorted { $0.name < $1.name }
|
|
if entries.isEmpty { status = t("files.empty") }
|
|
} else {
|
|
let size = ((try? fm.attributesOfItem(atPath: path))?[.size] as? NSNumber)?.int64Value ?? 0
|
|
entries = [FileItem(name: (path as NSString).lastPathComponent, path: path,
|
|
isDirectory: false, size: size, modified: nil, isSymlink: false)]
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PlistEditorView: View {
|
|
let item: FileItem
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var text = ""
|
|
@State private var editable = false
|
|
@State private var status = ""
|
|
@State private var dirty = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
if !status.isEmpty {
|
|
Text(status).font(T.monoSm)
|
|
.foregroundColor(status.hasPrefix("✓") ? T.good : T.warn)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
TextEditor(text: $text)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.scrollContentBackground(.hidden)
|
|
.background(T.bg)
|
|
.disabled(!editable)
|
|
.onChange(of: text) { _, _ in dirty = true }
|
|
}
|
|
.background(T.bg.ignoresSafeArea())
|
|
.navigationTitle(item.name)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button(editable ? "RO" : "Edit") { editable.toggle() }.tint(T.ember)
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button(t("files.save")) { save() }
|
|
.tint(dirty && editable ? T.ember : .white.opacity(0.3))
|
|
.disabled(!editable || !dirty)
|
|
}
|
|
}
|
|
.onAppear(perform: load)
|
|
}
|
|
}
|
|
|
|
private func load() {
|
|
if let s = FileLoader.plist(at: item.path) ?? FileLoader.text(at: item.path) {
|
|
text = s
|
|
editable = true
|
|
status = "\(s.count) chars"
|
|
} else {
|
|
text = FileLoader.hexDump(at: item.path) ?? "—"
|
|
status = "binary"
|
|
}
|
|
dirty = false
|
|
}
|
|
|
|
private func save() {
|
|
guard let data = text.data(using: .utf8) else { return }
|
|
if item.name.hasSuffix(".plist"),
|
|
(try? PropertyListSerialization.propertyList(from: data, format: nil)) == nil {
|
|
status = "plist syntax error — not written"
|
|
return
|
|
}
|
|
let backup = item.path + ".cinder27.bak"
|
|
if !FileManager.default.fileExists(atPath: backup) {
|
|
try? FileManager.default.copyItem(atPath: item.path, toPath: backup)
|
|
}
|
|
do {
|
|
try Container.write(data, to: item.path)
|
|
status = "✓ \(t("files.save"))"
|
|
dirty = false
|
|
} catch {
|
|
status = Container.describe(errno)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Processes
|
|
|
|
struct ProcessPane: View {
|
|
@State private var procs: [ProcInfo] = []
|
|
@State private var query = ""
|
|
|
|
private var filtered: [ProcInfo] {
|
|
guard !query.isEmpty else { return procs }
|
|
return procs.filter { $0.name.localizedCaseInsensitiveContains(query) || "\($0.pid)" == query }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.white.opacity(0.3))
|
|
TextField("pid / name", text: $query).font(T.body)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
Text("\(procs.count)").font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
|
}
|
|
.padding(10)
|
|
.background(RoundedRectangle(cornerRadius: 10).fill(T.panel))
|
|
.padding(.horizontal, 16)
|
|
|
|
List {
|
|
ForEach(filtered) { p in
|
|
HStack(spacing: 10) {
|
|
Text("\(p.pid)").font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
|
.frame(width: 44, alignment: .leading)
|
|
Text(p.name).font(T.mono).foregroundColor(.white.opacity(0.85)).lineLimit(1)
|
|
Spacer()
|
|
Text("uid \(p.uid)").font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
|
}
|
|
.listRowBackground(T.panel)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
.onAppear { procs = ProcList.snapshot() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Daemons
|
|
|
|
struct DaemonPane: View {
|
|
@State private var daemons: [LaunchCensus.Daemon] = []
|
|
@State private var query = ""
|
|
|
|
private var filtered: [LaunchCensus.Daemon] {
|
|
guard !query.isEmpty else { return daemons }
|
|
return daemons.filter { $0.label.localizedCaseInsensitiveContains(query) }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.white.opacity(0.3))
|
|
TextField("label", text: $query).font(T.body)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
Text("\(daemons.count)").font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
|
}
|
|
.padding(10)
|
|
.background(RoundedRectangle(cornerRadius: 10).fill(T.panel))
|
|
.padding(.horizontal, 16)
|
|
|
|
List {
|
|
ForEach(filtered) { d in
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 6) {
|
|
Text(d.label).font(T.mono).foregroundColor(.white.opacity(0.85)).lineLimit(1)
|
|
if d.runAtLoad { Pill(text: "RunAtLoad", color: T.good) }
|
|
if d.keepAlive { Pill(text: "KeepAlive", color: T.info) }
|
|
}
|
|
Text(d.program.isEmpty ? d.path : d.program)
|
|
.font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
|
.lineLimit(1).truncationMode(.middle)
|
|
}
|
|
.listRowBackground(T.panel)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
.onAppear { daemons = LaunchCensus.daemons() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Crashes
|
|
|
|
struct CrashPane: View {
|
|
@State private var reports: [CrashReport] = []
|
|
@State private var selected: CrashReport?
|
|
|
|
var body: some View {
|
|
List {
|
|
if reports.isEmpty {
|
|
Text("No crash reports in reachable paths.")
|
|
.font(T.monoSm).foregroundColor(.white.opacity(0.4))
|
|
.listRowBackground(T.panel)
|
|
}
|
|
ForEach(reports) { r in
|
|
Button { selected = r } label: {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack {
|
|
Text(r.process.isEmpty ? r.name : r.process)
|
|
.font(T.mono).foregroundColor(.white.opacity(0.88))
|
|
Spacer()
|
|
if let d = r.date {
|
|
Text(FileBrowser.dateFormatter.string(from: d))
|
|
.font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
|
}
|
|
}
|
|
if !r.exception.isEmpty {
|
|
Text(r.exception).font(T.monoSm).foregroundColor(T.emberSoft).lineLimit(2)
|
|
}
|
|
if !r.faultingSymbol.isEmpty {
|
|
Text(r.faultingSymbol).font(T.monoSm).foregroundColor(.white.opacity(0.35)).lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(T.panel)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.onAppear { reports = CrashLogs.list() }
|
|
.sheet(item: $selected) { r in
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
KeyValueRow(key: "Process", value: r.process)
|
|
KeyValueRow(key: "Exception", value: r.exception)
|
|
KeyValueRow(key: "Size", value: "\(r.size) B")
|
|
}
|
|
}
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
SectionLabel(text: "Frames")
|
|
ForEach(Array(r.frames.enumerated()), id: \.offset) { i, f in
|
|
Text("\(i) \(f)").font(T.monoSm).foregroundColor(.white.opacity(0.7))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.background(T.bg.ignoresSafeArea())
|
|
.navigationTitle(r.name)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Binary
|
|
|
|
struct BinaryPane: View {
|
|
@State private var path = ""
|
|
@State private var info: MachOInfo?
|
|
@State private var error = ""
|
|
|
|
private let presets: [(String, String)] = [
|
|
("CoreGraphics", "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"),
|
|
("CoreMedia", "/System/Library/Frameworks/CoreMedia.framework/CoreMedia"),
|
|
("libarchive", "/usr/lib/libarchive.2.dylib"),
|
|
("ImageIO", "/System/Library/Frameworks/ImageIO.framework/ImageIO"),
|
|
("libsystem_sandbox", "/usr/lib/system/libsystem_sandbox.dylib"),
|
|
("Self", Bundle.main.executablePath ?? ""),
|
|
]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 7) {
|
|
ForEach(presets, id: \.0) { p in
|
|
Button { path = p.1; inspect() } label: {
|
|
Pill(text: p.0, color: .white.opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("/path/to/binary", text: $path)
|
|
.font(T.monoSm)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.padding(9)
|
|
.background(RoundedRectangle(cornerRadius: 8).fill(T.panel))
|
|
Button(t("common.ok")) { inspect() }
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.tint(T.ember)
|
|
}
|
|
|
|
if !error.isEmpty {
|
|
Text(error).font(T.monoSm).foregroundColor(T.warn)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let info = info {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
SectionLabel(text: "Mach-O")
|
|
KeyValueRow(key: "Arch", value: info.isFat ? "FAT" : info.arch)
|
|
KeyValueRow(key: "Type", value: info.fileType)
|
|
KeyValueRow(key: "Platform", value: info.platform)
|
|
KeyValueRow(key: "MinOS", value: info.minOS)
|
|
KeyValueRow(key: "SDK", value: info.sdk)
|
|
KeyValueRow(key: "Crypt", value: info.isEncrypted ? "encrypted (\(info.cryptID))" : "plain",
|
|
tint: info.isEncrypted ? T.ember : T.good)
|
|
}
|
|
}
|
|
if !info.linkedLibraries.isEmpty {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
SectionLabel(text: "Libraries (\(info.linkedLibraries.count))")
|
|
ForEach(info.linkedLibraries.prefix(40), id: \.self) { lib in
|
|
Text(lib).font(T.monoSm).foregroundColor(.white.opacity(0.6))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !info.rpaths.isEmpty {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
SectionLabel(text: "RPATH")
|
|
ForEach(info.rpaths, id: \.self) { r in
|
|
Text(r).font(T.monoSm).foregroundColor(.white.opacity(0.6))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private func inspect() {
|
|
error = ""
|
|
info = nil
|
|
guard FileManager.default.fileExists(atPath: path) else {
|
|
error = Container.describe(ENOENT)
|
|
return
|
|
}
|
|
if let result = MachOInspector.inspect(path) {
|
|
info = result
|
|
} else {
|
|
error = "not Mach-O, or read denied"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Backup
|
|
|
|
struct BackupPane: View {
|
|
@State private var status = ""
|
|
@State private var busy = false
|
|
@State private var backups: [FileItem] = []
|
|
@State private var includeCaches = false
|
|
@State private var restoreTarget: FileItem?
|
|
|
|
private var backupDir: URL { Container.backups }
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
SectionLabel(text: t("pane.backup"))
|
|
Text(tl("把容器内的偏好、文稿与工具产物打成 ZIP。归档走同一写入器,可被 libarchive 解回。"))
|
|
.font(T.body).foregroundColor(.white.opacity(0.65))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Toggle(isOn: $includeCaches) {
|
|
Text("Library/Caches").font(T.body).foregroundColor(.white.opacity(0.7))
|
|
}.tint(T.ember)
|
|
RunButton(title: busy ? t("common.loading") : t("pane.backup"),
|
|
systemImage: "archivebox.fill", tint: T.info, disabled: busy) { backup() }
|
|
}
|
|
}
|
|
|
|
if !status.isEmpty {
|
|
Card {
|
|
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.7))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
SectionLabel(text: "\(t("pane.backup")) (\(backups.count))")
|
|
Spacer()
|
|
Button { reload() } label: {
|
|
Image(systemName: "arrow.clockwise").font(.system(size: 11, weight: .bold))
|
|
}.tint(T.ember)
|
|
}
|
|
if backups.isEmpty {
|
|
Text(t("common.none")).font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
|
} else {
|
|
ForEach(backups) { b in
|
|
HStack(spacing: 8) {
|
|
Text(b.name).font(T.monoSm)
|
|
.foregroundColor(.white.opacity(0.72)).lineLimit(1)
|
|
Spacer()
|
|
Text(b.displaySize).font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
|
Button { restoreTarget = b } label: {
|
|
Image(systemName: "arrow.uturn.backward.circle").font(.system(size: 13))
|
|
}.tint(T.warn)
|
|
Button {
|
|
try? FileManager.default.removeItem(atPath: b.path)
|
|
reload()
|
|
} label: {
|
|
Image(systemName: "trash").font(.system(size: 12))
|
|
}.tint(T.bad)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.onAppear(perform: reload)
|
|
.alert("Restore?", isPresented: Binding(
|
|
get: { restoreTarget != nil },
|
|
set: { if !$0 { restoreTarget = nil } })) {
|
|
Button("Restore", role: .destructive) {
|
|
if let t = restoreTarget { restore(t) }
|
|
restoreTarget = nil
|
|
}
|
|
Button(t("files.cancel"), role: .cancel) { restoreTarget = nil }
|
|
} message: {
|
|
Text(restoreTarget?.name ?? "")
|
|
}
|
|
}
|
|
|
|
private func reload() {
|
|
let fm = FileManager.default
|
|
try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true)
|
|
let names = (try? fm.contentsOfDirectory(atPath: backupDir.path)) ?? []
|
|
backups = names.compactMap { name -> FileItem? in
|
|
guard name.hasSuffix(".zip") else { return nil }
|
|
let full = backupDir.appendingPathComponent(name).path
|
|
let size = ((try? fm.attributesOfItem(atPath: full))?[.size] as? NSNumber)?.int64Value ?? 0
|
|
return FileItem(name: name, path: full, isDirectory: false, size: size,
|
|
modified: nil, isSymlink: false)
|
|
}.sorted { $0.name > $1.name }
|
|
}
|
|
|
|
private func backup() {
|
|
busy = true
|
|
status = t("common.loading")
|
|
let fm = FileManager.default
|
|
let rootPath = Container.rootPath
|
|
let stamp = ISO8601DateFormatter().string(from: Date())
|
|
.replacingOccurrences(of: ":", with: "-")
|
|
let target = backupDir.appendingPathComponent("cinder27-\(stamp).zip")
|
|
let skipCaches = !includeCaches
|
|
let backupPrefix = "Documents/Cinder27/Backups/"
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
var zip = ZipWriter()
|
|
var stored = 0
|
|
var skipped = 0
|
|
var bytes = 0
|
|
let limit = 400_000_000
|
|
|
|
if let enumerator = fm.enumerator(at: Container.root,
|
|
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
|
|
options: [.skipsHiddenFiles]) {
|
|
for case let url as URL in enumerator {
|
|
guard let v = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
|
|
v.isRegularFile == true else { continue }
|
|
let relative = url.path.replacingOccurrences(of: rootPath + "/", with: "")
|
|
if relative.hasPrefix(backupPrefix) { continue }
|
|
if skipCaches && relative.hasPrefix("Library/Caches/") { continue }
|
|
if let size = v.fileSize, size > 80_000_000 { skipped += 1; continue }
|
|
if bytes > limit { skipped += 1; continue }
|
|
guard let data = try? Data(contentsOf: url) else { skipped += 1; continue }
|
|
zip.add(relative, data)
|
|
stored += 1
|
|
bytes += data.count
|
|
}
|
|
}
|
|
|
|
let archive = zip.build()
|
|
var failure: String?
|
|
do {
|
|
try fm.createDirectory(at: self.backupDir, withIntermediateDirectories: true)
|
|
try archive.write(to: target, options: .atomic)
|
|
} catch {
|
|
failure = Container.describe(errno)
|
|
}
|
|
let produced = archive.count
|
|
DispatchQueue.main.async {
|
|
busy = false
|
|
status = failure.map { "\($0)" } ?? "✓ \(stored) files · \(ByteCountFormatter.string(fromByteCount: Int64(produced), countStyle: .file))" + (skipped > 0 ? " · \(skipped) skipped" : "")
|
|
reload()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func restore(_ item: FileItem) {
|
|
busy = true
|
|
status = t("common.loading")
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: item.path)) else {
|
|
DispatchQueue.main.async { busy = false; status = Container.describe(errno) }
|
|
return
|
|
}
|
|
let result = Runners.Archive.extractAll(data, to: Container.root)
|
|
DispatchQueue.main.async {
|
|
busy = false
|
|
status = "✓ \(result.count) · \(result.errors)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Telemetry
|
|
|
|
struct TelemetryPane: View {
|
|
@State private var sections: [(String, [(String, String)])] = []
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
ForEach(Array(sections.enumerated()), id: \.offset) { _, section in
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
SectionLabel(text: section.0)
|
|
ForEach(Array(section.1.enumerated()), id: \.offset) { _, kv in
|
|
KeyValueRow(key: kv.0, value: kv.1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.onAppear {
|
|
sections = [
|
|
("Power", Telemetry.battery() + Telemetry.thermal()),
|
|
("Network", Telemetry.interfaces()),
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Profiles
|
|
|
|
struct ProfilePane: View {
|
|
@State private var profiles: [ProfileCensus.Profile] = []
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
if profiles.isEmpty {
|
|
Card {
|
|
Text("No provisioning profiles reachable. The device-level directory requires cross-container read.")
|
|
.font(T.body).foregroundColor(.white.opacity(0.55))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
ForEach(Array(profiles.enumerated()), id: \.offset) { _, p in
|
|
Card {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(p.name).font(.system(size: 14, weight: .semibold, design: .rounded))
|
|
.foregroundColor(.white)
|
|
KeyValueRow(key: "UUID", value: p.uuid)
|
|
KeyValueRow(key: "Team", value: p.teamID)
|
|
KeyValueRow(key: "AppID", value: p.appID)
|
|
KeyValueRow(key: "Devices", value: "\(p.devices)")
|
|
if let e = p.expires {
|
|
KeyValueRow(key: "Expires", value: FileBrowser.dateFormatter.string(from: e))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
.onAppear { profiles = ProfileCensus.installed() }
|
|
}
|
|
}
|