Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation

enum MaterializerError: LocalizedError {
case securityScopedAccessDenied
case fileNotFound
case durationCapExceeded
case disallowedContentType
case heicConversionFailed
case videoExportFailed(underlyingError: Error)
case unknownContentType
case remoteDownloadFailed(underlyingError: Error)

var errorDescription: String? {
switch self {
case .securityScopedAccessDenied: return Strings.uploadErrorSecurityScopedAccess
case .fileNotFound: return Strings.uploadErrorFileNotFound
case .durationCapExceeded: return Strings.uploadErrorDurationCap
case .disallowedContentType: return Strings.uploadErrorDisallowedType
case .heicConversionFailed: return Strings.uploadErrorHEICConversion
case .videoExportFailed(let underlyingError):
return String.localizedStringWithFormat(
Strings.uploadErrorVideoExport,
underlyingError.localizedDescription
)
case .unknownContentType: return Strings.uploadErrorUnknownContentType
case .remoteDownloadFailed(let underlyingError):
return String.localizedStringWithFormat(
Strings.materializerErrorRemoteDownloadFailed,
underlyingError.localizedDescription
)
}
}
}

enum VideoExportFailureReason: LocalizedError {
case noExporterForPreset

var errorDescription: String? {
switch self {
case .noExporterForPreset: return Strings.uploadErrorVideoExportNoExporter
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

/// Single-use downloader for `.remoteURL` materialization. The materializer
/// constructs one per download and discards it after.
///
/// Built on the async `URLSession.download(from:delegate:)`, so the runtime
/// owns the continuation, cancellation, temp-file delivery, and error
/// propagation. The only thing it can't give us is byte-level progress
/// (the async convenience methods suppress `didWriteData`), so we observe
/// the task's own `Progress` via KVO from `didCreateTask`, the workaround
/// Apple's URL Loading System team recommends for this case:
/// https://developer.apple.com/forums/thread/723015
final class RemoteDownloader {

/// Downloads `url` into `parentDir` and returns the local file URL. Reports
/// byte-level progress on `progress` (mapped to its existing 0-100
/// `totalUnitCount`). Cooperative task cancellation cancels the request and
/// surfaces as `CancellationError`.
func download(from url: URL, into parentDir: URL, progress: Progress) async throws -> URL {
let delegate = ProgressForwardingDelegate(progress: progress)
let location: URL
let response: URLResponse
do {
(location, response) = try await URLSession.shared.download(from: url, delegate: delegate)
} catch is CancellationError {
throw CancellationError()
} catch let urlError as URLError where urlError.code == .cancelled {
throw CancellationError()
} catch {
throw MaterializerError.remoteDownloadFailed(underlyingError: error)
}

// HTTP status check: a 404/500 response body looks like a successful
// download to URLSession (it still hands back a valid temp file). For
// Stock Photos the image-byte validator catches HTML error bodies
// later, but Tenor GIF is a raw passthrough, so without this we'd
// happily upload an HTML 404 response as 'image/gif'.
if let httpResponse = response as? HTTPURLResponse,
!(200..<300).contains(httpResponse.statusCode)
{
let statusError = NSError(
domain: "RemoteDownloader",
code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode) response"]
)
throw MaterializerError.remoteDownloadFailed(underlyingError: statusError)
}

// Move out of the system temp location (reaped after this call) into our
// owned parentDir, reusing the system-generated unique name.
let dest = parentDir.appendingPathComponent(location.lastPathComponent)
do {
try FileManager.default.moveItem(at: location, to: dest)
} catch {
throw MaterializerError.remoteDownloadFailed(underlyingError: error)
}
return dest
}
}

/// Forwards the download task's `Progress` to the caller-supplied `progress`.
/// `didCreateTask` fires for the async download API (unlike `didWriteData`),
/// so it's the hook for installing the KVO observation.
private final class ProgressForwardingDelegate: NSObject, URLSessionTaskDelegate {
private let progress: Progress
private var observation: NSKeyValueObservation?

init(progress: Progress) {
self.progress = progress
super.init()
}

func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
observation = task.progress.observe(\.fractionCompleted) { [progress] taskProgress, _ in
// `fractionCompleted` stays 0 when the server sends no Content-Length
// (totalUnitCount is -1), leaving the row indeterminate until the
// upload phase drives it to completion.
progress.completedUnitCount = Int64((taskProgress.fractionCompleted * 100).rounded())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import os

/// Generates the temp-file basenames used for media uploads, and with them the
/// filenames WordPress ends up storing.
///
/// The uploaded media's filename is the basename of the file sent to the
/// server: `MediaCreateParams` carries no explicit filename, so the upload
/// layer (wordpress-rs) falls back to the last path component of the file it
/// uploads. Naming each temp file `<stem>.<ext>` from the source's own name is
/// therefore the whole of filename preservation (and, since the title is left
/// unset, drives the server-derived attachment title too).
///
/// Holds the basenames already handed out so repeated names within one uploader
/// session get ` (2)`, ` (3)` suffixes instead of overwriting each other.
final class UploadFilenameAllocator: Sendable {
private let usedBasenames = OSAllocatedUnfairLock<Set<String>>(initialState: [])

/// The sanitized `preferred` name, or `<fallbackPrefix>-<timestamp>` when
/// the source has no usable name. Carries no extension and is not yet
/// deduplicated; pass the result to `basename`.
func stem(preferred: String?, fallbackPrefix: String, date: Date) -> String {
preferred.flatMap(sanitize) ?? "\(fallbackPrefix)-\(timestampedName(date: date))"
}

/// A unique `<stem>.<ext>` basename. Names already handed out for the
/// lifetime of this allocator are tracked, so a batch with two same-named
/// files becomes `name.jpg`, `name (2).jpg`, and so on.
func basename(stem: String, ext: String) -> String {
usedBasenames.withLock { used in
let first = "\(stem).\(ext)"
if used.insert(first).inserted {
return first
}
var n = 2
while true {
let candidate = "\(stem) (\(n)).\(ext)"
if used.insert(candidate).inserted {
return candidate
}
n += 1
}
}
}

/// Filesystem-safe `yyyy-MM-dd HH-mm-ss` timestamp (fixed POSIX locale)
/// used to build fallback stems when a source has no name of its own.
private func timestampedName(date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
return formatter.string(from: date)
}

/// Strips path separators and NUL, caps length, and treats an empty result
/// as "no usable name" (nil) so the caller falls back to a generated stem.
private func sanitize(_ name: String) -> String? {
let cleaned =
name
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\u{0}", with: "")
let trimmed = String(cleaned.prefix(256))
return trimmed.isEmpty ? nil : trimmed
}
}
Loading