// // AdminLibraryView.swift // FTStore Admin // // The catalog behind `ftstore.json`: every build the server has, searchable // and filterable, with the row-level actions the web Library offers — flag a // corrupted build, re-pull App Store metadata, delete it from the feed. // import SwiftUI struct AdminLibraryView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _library = AdminLoader() @State private var _search = "" @State private var _filter: Filter = .all @State private var _sourceFilter: String? @State private var _selected: AdminLibraryItem? @State private var _pendingDelete: AdminLibraryItem? @State private var _busyIds: Set = [] @State private var _banner: String? @State private var _uploading = false enum Filter: String, CaseIterable, Identifiable { case all = "All" case tweaked = "Tweaked" case corrupted = "Corrupted" case hidden = "Not in feed" case missing = "No file" var id: String { rawValue } } private var _items: [AdminLibraryItem] { guard let all = _library.value?.data else { return [] } let needle = _search.trimmingCharacters(in: .whitespaces).lowercased() return all.filter { item in switch _filter { case .all: break case .tweaked: if !item.isTweaked { return false } case .corrupted: if !item.isCorrupted { return false } case .hidden: if item.inFeed { return false } case .missing: if item.hasFile { return false } } if let _sourceFilter, item.channelId != _sourceFilter { return false } guard !needle.isEmpty else { return true } return item.displayName.lowercased().contains(needle) || item.appName.lowercased().contains(needle) || item.bundleId.lowercased().contains(needle) || item.fileName.lowercased().contains(needle) } } var body: some View { List { if let banner = _banner { Section { Text(banner) .font(.footnote) .foregroundStyle(.secondary) } } if _library.isLoading { AdminLoadingRow() } else if let error = _library.errorMessage, !_library.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else if _items.isEmpty { AdminEmptyRow( icon: "shippingbox", title: "Nothing here", detail: _search.isEmpty ? "The catalog is empty." : "No build matches that search." ) } else { Section { ForEach(_items) { item in Button { _selected = item } label: { _row(item) } .buttonStyle(.plain) .swipeActions(edge: .trailing) { Button(role: .destructive) { _pendingDelete = item } label: { Label("Delete", systemImage: "trash") } Button { _setCorrupted(item, to: !item.isCorrupted) } label: { Label( item.isCorrupted ? "Clear" : "Corrupt", systemImage: item.isCorrupted ? "checkmark.circle" : "exclamationmark.triangle" ) } .tint(.orange) } } } header: { // Two whole keys rather than one with an "s" spliced on: the // plural suffix is an English rule, and a translator needs // the whole sentence to work with. Text( _items.count == 1 ? String.localized("1 build") : String.localized("%lld builds", _items.count) ) } } } .navigationTitle("Library") .navigationBarTitleDisplayMode(.inline) .searchable(text: $_search, prompt: "App, bundle id or file name") .refreshable { await _reload() } .task { await _reload() } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { _uploading = true } label: { Image(systemName: "arrow.up.doc") } } ToolbarItem(placement: .topBarTrailing) { Menu { Picker("Filter", selection: $_filter) { // The raw value is the filter's identity *and* its label, and a // `String` reaching `Text` is rendered verbatim, so the label // is looked up rather than the identity translated. ForEach(Filter.allCases) { Text(String.localized($0.rawValue)).tag($0) } } if let sources = _library.value?.channels, !sources.isEmpty { Divider() Picker("Source", selection: $_sourceFilter) { Text("All sources").tag(String?.none) ForEach(sources) { source in Text(source.label).tag(String?.some(source.id)) } } } } label: { Image(systemName: _filter == .all && _sourceFilter == nil ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") } } } // The session is handed to each sheet explicitly: a sheet is its own // presentation, and an `@EnvironmentObject` that failed to reach it // would be a crash rather than a missing value. .sheet(item: $_selected) { item in NavigationStack { AdminLibraryDetailView(item: item) { await _reload() } } .environmentObject(session) } .sheet(isPresented: $_uploading, onDismiss: { Task { await _reload() } }) { NavigationStack { AdminUploadView() } .environmentObject(session) } .confirmationDialog( "Delete this build?", isPresented: Binding( get: { _pendingDelete != nil }, set: { if !$0 { _pendingDelete = nil } } ), titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let item = _pendingDelete { _delete(item) } _pendingDelete = nil } Button("Cancel", role: .cancel) { _pendingDelete = nil } } message: { Text("\(_pendingDelete?.displayName ?? "") \(_pendingDelete?.version ?? "") is removed from the feed and its file is deleted from the server.") } } // MARK: Row private func _row(_ item: AdminLibraryItem) -> some View { HStack(spacing: 12) { AdminRemoteIcon(url: AdminAPI.mediaURL(item.iconUrl)) VStack(alignment: .leading, spacing: 3) { Text(item.displayName) .font(.subheadline.weight(.medium)) .lineLimit(1) Text("\(item.version) · \(AdminFormat.bytes(item.fileSize))") .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 6) { if item.isTweaked { _chip("Tweaked", .purple) } if item.isCorrupted { _chip("Corrupted", .red) } if !item.inFeed { _chip("Hidden", .orange) } if !item.hasFile { _chip("No file", .gray) } if let label = item.sourceLabel { Text(label) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } } Spacer(minLength: 0) if _busyIds.contains(item.id) { ProgressView() } else { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) } } .padding(.vertical, 2) .contentShape(Rectangle()) } private func _chip(_ text: String, _ tint: Color) -> some View { Text(text) .font(.caption2.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(tint.opacity(0.15), in: Capsule()) .foregroundStyle(tint) } // MARK: Actions private func _reload() async { await _library.load { try await session.get("/api/library", as: AdminLibraryResponse.self) } } private func _delete(_ item: AdminLibraryItem) { _run(item) { let ack = try await session.act( .post, "/api/library/apply", body: AdminRequest.LibraryApply.delete(item.id) ) return ack.summary } } private func _setCorrupted(_ item: AdminLibraryItem, to flagged: Bool) { _run(item) { let ack = try await session.act( .post, "/api/library/corruption", body: AdminRequest.Corruption(id: item.id, isCorrupted: flagged) ) return ack.summary } } private func _run(_ item: AdminLibraryItem, _ operation: @escaping @MainActor () async throws -> String) { _busyIds.insert(item.id) Task { @MainActor in do { _banner = try await operation() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busyIds.remove(item.id) await _reload() } } } // MARK: - Detail struct AdminLibraryDetailView: View { let item: AdminLibraryItem let onChange: () async -> Void @EnvironmentObject private var session: AdminSession @Environment(\.dismiss) private var dismiss @StateObject private var _detail = AdminLoader>() @State private var _busy = false @State private var _message: String? @State private var _renaming = false @State private var _newName = "" private var _data: AdminLibraryDetail? { _detail.value?.data } var body: some View { List { Section { HStack(spacing: 14) { AdminRemoteIcon(url: AdminAPI.mediaURL(_data?.iconUrl ?? item.iconUrl), size: 60) VStack(alignment: .leading, spacing: 3) { Text(item.displayName) .font(.headline) Text(item.bundleId) .font(.caption) .foregroundStyle(.secondary) if let developer = _data?.developerName { Text(developer) .font(.caption) .foregroundStyle(.tertiary) } } } } if let message = _message { Section { Text(message).font(.footnote).foregroundStyle(.secondary) } } Section("Build") { LabeledContent("Version", value: item.version) if let build = _data?.buildNumber { LabeledContent("Build", value: build) } if let minOS = _data?.minOsVersion { LabeledContent("Minimum iOS", value: minOS) } LabeledContent("Size", value: AdminFormat.bytes(item.fileSize)) LabeledContent("File", value: item.fileName) LabeledContent("Added", value: AdminFormat.timestamp(item.createdAt)) if let downloads = _data?.downloadCount { LabeledContent("Downloads", value: AdminFormat.count(downloads)) } if let source = item.sourceLabel { LabeledContent("Source", value: source) } } if let tweaks = _data?.tweaks, !tweaks.isEmpty { Section("Injected Tweaks") { ForEach(tweaks, id: \.self) { tweak in Label(tweak, systemImage: "wrench.and.screwdriver") .font(.subheadline) } } } if let entitlements = _data?.entitlements, !entitlements.isEmpty { Section("Entitlements") { ForEach(entitlements.keys.sorted(), id: \.self) { key in VStack(alignment: .leading, spacing: 2) { Text(key) .font(.caption.weight(.medium)) Text(String(describing: entitlements[key]?.value ?? "")) .font(.caption2) .foregroundStyle(.secondary) } } } } if let privacy = _data?.privacyInfo, !privacy.isEmpty { Section("Privacy Manifest") { ForEach(privacy.keys.sorted(), id: \.self) { key in LabeledContent(key, value: String(describing: privacy[key]?.value ?? "")) .font(.caption) } } } if let description = _data?.description, !description.isEmpty { Section("Description") { Text(description) .font(.footnote) .foregroundStyle(.secondary) } } Section("State") { if item.isCorrupted, let note = _data?.corruptionNote, !note.isEmpty { LabeledContent("Corruption note", value: note) } LabeledContent("In feed", value: item.inFeed ? "Yes" : "No") LabeledContent("File on server", value: item.hasFile ? "Yes" : "No") } Section { Button { _newName = item.displayName _renaming = true } label: { HStack(spacing: 12) { Image(systemName: "textformat") .frame(width: 22) .foregroundStyle(AdminTheme.brand) VStack(alignment: .leading, spacing: 2) { Text("Rename in Feed") .foregroundStyle(.primary) Text(item.renameScope == "ftstore" ? "Renames this tweak's variant only" : "Renames every build of \(item.bundleId)") .font(.caption) .foregroundStyle(.secondary) } Spacer() } } AdminActionRow( title: "Refresh App Store Metadata", icon: "arrow.triangle.2.circlepath", detail: "Re-pull icon, screenshots, description" ) { let ack = try await session.act( .post, "/api/library/refresh", body: AdminRequest.Identified(id: item.id) ) await onChange() return ack.summary } AdminActionRow( title: item.isCorrupted ? "Mark as Working" : "Mark as Corrupted", icon: item.isCorrupted ? "checkmark.seal" : "exclamationmark.triangle", detail: "Corrupted builds stay listed but can't be installed" ) { let ack = try await session.act( .post, "/api/library/corruption", body: AdminRequest.Corruption(id: item.id, isCorrupted: !item.isCorrupted) ) await onChange() return ack.summary } AdminActionRow( title: "Delete Build", icon: "trash", detail: "Removes the row and the stored file", destructive: true, confirmation: .localized("Delete %@ %@?", item.displayName, item.version) ) { let ack = try await session.act( .post, "/api/library/apply", body: AdminRequest.LibraryApply.delete(item.id) ) await onChange() dismiss() return ack.summary } } } .navigationTitle("Build") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { if _busy { ProgressView() } } ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } .disabled(_busy) } } .task { await _detail.load { try await session.get( "/api/library/entries/\(item.id)", as: AdminEnvelope.self ) } } .alert("Rename in Feed", isPresented: $_renaming) { TextField("Display name", text: $_newName) Button("Rename") { _rename() } Button("Cancel", role: .cancel) {} } message: { Text(item.renameScope == "ftstore" ? "Every version of this tweak's variant is listed under the new name." : "Every version of \(item.bundleId) is listed under the new name.") } } /// A rename is an override row keyed by the variant's group, not an edit to /// this one build — which is why it reaches for `/api/library/apply` rather /// than the entry endpoint. private func _rename() { let name = _newName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } _busy = true Task { @MainActor in do { let ack = try await session.act( .post, "/api/library/apply", body: AdminRequest.LibraryApply.rename(item, to: name) ) _message = ack.summary await onChange() } catch { _message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false } } } // MARK: - Icon /// App icons come from the server (`/media/icons/…`) or straight from the App /// Store; both are plain image URLs, so `AsyncImage` is enough here — the /// Nuke pipeline the installer uses would be overkill for a settings list. struct AdminRemoteIcon: View { let url: URL? var size: CGFloat = 40 var body: some View { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() default: Image(systemName: "app.dashed") .font(.system(size: size * 0.45)) .foregroundStyle(.tertiary) } } .frame(width: size, height: size) .background(Color(uiColor: .tertiarySystemFill)) .clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous)) } }