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

338 lines
9.8 KiB
Swift

//
// AdminUploadView.swift
// FTStore Admin
//
// Hand-upload an IPA straight into the repo, the same way Library → Upload IPA
// does it in the browser: the file is pushed in 8 MiB chunks (a reverse proxy
// in front of FTStore Admin would reject a 400 MB single request — Cloudflare
// stops at 100 MB), then one finalize call runs the normal ingest pipeline and
// the build is live in the feed.
//
import SwiftUI
import UniformTypeIdentifiers
struct AdminUploadView: View {
@EnvironmentObject private var session: AdminSession
@Environment(\.dismiss) private var dismiss
/// The chunk size the server documents (`UPLOAD_CHUNK_SIZE`). Changing it
/// here alone is safe — the handler appends whatever arrives at `offset` —
/// but there is no reason to.
private static let chunkSize = 8 * 1024 * 1024
/// `MAX_UPLOAD_BYTES` on the server. Checked here too so a 3 GB file fails
/// before the first byte goes over the wire.
private static let maxBytes: Int64 = 2 * 1024 * 1024 * 1024
@StateObject private var _sources = AdminLoader<AdminEnvelope<AdminUploadSources>>()
@State private var _picking = false
@State private var _fileURL: URL?
@State private var _fileName = ""
@State private var _fileSize: Int64 = 0
@State private var _channelId: String?
@State private var _notes = ""
@State private var _uploading = false
@State private var _progress: Double = 0
@State private var _phase = ""
@State private var _error: String?
@State private var _result: AdminUploadResult?
var body: some View {
Form {
_fileSection
if _fileURL != nil {
_attributionSection
}
if _uploading {
_progressSection
}
if let error = _error {
Section {
Label(error, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
}
if let result = _result {
Section("Uploaded") {
LabeledContent("App", value: result.appName)
LabeledContent("Version", value: result.version)
LabeledContent("Bundle id", value: result.bundleId)
LabeledContent("Size", value: AdminFormat.bytes(result.fileSize))
if result.isTweaked {
Label("Tweaked build", systemImage: "wrench.and.screwdriver")
.font(.footnote)
.foregroundStyle(.secondary)
}
if result.storageOverLimit {
Label(
"Storage is at its limit, so scraper downloads stay paused.",
systemImage: "externaldrive.badge.exclamationmark"
)
.font(.footnote)
.foregroundStyle(.orange)
}
}
}
Section {
Button {
_start()
} label: {
HStack {
Label("Upload to Repo", systemImage: "arrow.up.doc")
if _uploading {
Spacer()
ProgressView()
}
}
}
.disabled(_fileURL == nil || _uploading)
} footer: {
Text("A manual upload is never blocked by the storage cap, only the scraper is. It runs the same pipeline as a scanned file: metadata, tweak detection, App Store enrichment.")
}
}
.navigationTitle("Upload IPA")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
.disabled(_uploading)
}
}
.task {
await _sources.load {
try await session.get(
"/api/actions/upload-ipa",
as: AdminEnvelope<AdminUploadSources>.self
)
}
}
// Deliberately wider than `.ipa` — see `UTType++.swift`. Asking for the
// IPA type alone left every file in the picker greyed out on a device
// where nothing had declared the extension. `_pick` is what enforces
// `.ipa`, which is also what the finalize handler accepts.
.fileImporter(
isPresented: $_picking,
allowedContentTypes: UTType.ipaImportTypes,
allowsMultipleSelection: false
) { result in
_pick(result)
}
.interactiveDismissDisabled(_uploading)
}
// MARK: Sections
private var _fileSection: some View {
Section {
Button {
_picking = true
} label: {
HStack(spacing: 12) {
Image(systemName: "doc.badge.plus")
.foregroundStyle(AdminTheme.brand)
VStack(alignment: .leading, spacing: 2) {
Text(_fileName.isEmpty ? "Choose an .ipa" : _fileName)
.foregroundStyle(.primary)
.lineLimit(1)
if _fileSize > 0 {
Text(AdminFormat.bytes(_fileSize))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
}
.disabled(_uploading)
} header: {
Text("File")
}
}
private var _attributionSection: some View {
Section {
Picker("Attribute to source", selection: $_channelId) {
Text("Manual upload").tag(String?.none)
ForEach(_sources.value?.data?.sources ?? []) { source in
Text(source.label).tag(String?.some(source.id))
}
}
.disabled(_uploading)
TextField("Notes (optional)", text: $_notes, axis: .vertical)
.lineLimit(1...4)
.disabled(_uploading)
} header: {
Text("Attribution")
} footer: {
Text("Filing the build under a channel also applies that channel's known-tweak lock, so it groups with the rest of that tweak's versions instead of appearing as a separate app.")
}
}
private var _progressSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(_phase)
.font(.subheadline.weight(.medium))
Spacer()
Text("\(Int((_progress * 100).rounded()))%")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
ProgressView(value: min(max(_progress, 0), 1))
.tint(AdminTheme.brand)
}
.padding(.vertical, 2)
}
}
// MARK: Picking
private func _pick(_ result: Result<[URL], Error>) {
_error = nil
_result = nil
switch result {
case .failure(let error):
_error = error.localizedDescription
case .success(let urls):
guard let url = urls.first else { return }
// The picker offers more than IPAs so that an IPA is always
// selectable whatever it happens to be tagged as; this is the check
// that keeps the rest out, and it matches the server's.
guard url.pathExtension.lowercased() == "ipa" else {
_error = .localized("Pick a file ending in .ipa. Only IPAs can be uploaded.")
return
}
// A file picked out of iCloud Drive may not be on the device yet, and
// its size reads as 0 until it is. Reading it is what pulls it down.
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap(Int64.init) ?? 0
guard size > 0 else {
_error = .localized("That file is empty or unreadable. If it lives in iCloud Drive, download it first.")
return
}
guard size <= Self.maxBytes else {
_error = .localized(
"That file is %@; the limit is %@.",
AdminFormat.bytes(size),
AdminFormat.bytes(Self.maxBytes)
)
return
}
_fileURL = url
_fileName = url.lastPathComponent
_fileSize = size
}
}
// MARK: Uploading
private func _start() {
guard let url = _fileURL else { return }
_uploading = true
_error = nil
_result = nil
_progress = 0
_phase = .localized("Preparing…")
Task { @MainActor in
// The picker hands back a reference to a file outside the sandbox;
// the scope has to stay open for the whole transfer, not just the
// first read.
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
_result = try await _transfer(url)
_phase = .localized("Done")
_progress = 1
} catch is CancellationError {
_error = .localized("Upload cancelled.")
} catch {
_error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_uploading = false
}
}
private func _transfer(_ url: URL) async throws -> AdminUploadResult {
// Lowercase because the server validates the id against a strict
// `[0-9a-f-]` pattern before it becomes part of a file path.
let uploadId = UUID().uuidString.lowercased()
let total = _fileSize
var offset: Int64 = 0
_phase = .localized("Uploading…")
while offset < total {
let count = Int(min(Int64(Self.chunkSize), total - offset))
let chunk = try await AdminUploadIO.readChunk(at: url, offset: UInt64(offset), count: count)
guard !chunk.isEmpty else { break }
_ = try await session.upload(
"/api/actions/upload-ipa/chunk",
fields: ["uploadId": uploadId, "offset": String(offset)],
fileField: "chunk",
fileName: "chunk.bin",
mimeType: "application/octet-stream",
fileData: chunk,
as: AdminAck.self
)
offset += Int64(chunk.count)
_progress = total > 0 ? Double(offset) / Double(total) : 0
try Task.checkCancellation()
}
_phase = .localized("Processing on the server…")
let response = try await session.send(
.post,
"/api/actions/upload-ipa",
body: AdminRequest.UploadFinalize(
uploadId: uploadId,
fileName: _fileName,
channelId: _channelId,
notes: _notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: _notes.trimmingCharacters(in: .whitespacesAndNewlines)
),
as: AdminEnvelope<AdminUploadResult>.self
)
guard let data = response.data else {
throw AdminAPIError.http(500, response.error ?? "The server didn't report a result.")
}
return data
}
}
// MARK: - File IO
/// Chunk reads run off the main actor: an 8 MiB read from an iCloud-backed file
/// can take long enough to stutter the progress bar otherwise. The handle is
/// opened per chunk rather than passed across actors, which costs a `seek` and
/// keeps everything crossing the boundary `Sendable`.
enum AdminUploadIO {
static func readChunk(at url: URL, offset: UInt64, count: Int) async throws -> Data {
try await Task.detached(priority: .userInitiated) {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
try handle.seek(toOffset: offset)
return try handle.read(upToCount: count) ?? Data()
}.value
}
}