// // 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>() @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.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.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 } }