Files
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

542 lines
15 KiB
Swift

//
// 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<AdminLibraryResponse>()
@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<Int> = []
@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<AdminEnvelope<AdminLibraryDetail>>()
@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<AdminLibraryDetail>.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))
}
}