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.
338 lines
9.8 KiB
Swift
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
|
|
}
|
|
}
|