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.
542 lines
15 KiB
Swift
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))
|
|
}
|
|
}
|