Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
547 lines
No EOL
23 KiB
Swift
547 lines
No EOL
23 KiB
Swift
import Foundation
|
|
import Network
|
|
|
|
// MARK: - wire models
|
|
|
|
public struct OfflineFileInfo: Codable, Sendable, Equatable {
|
|
public let path: String
|
|
public let name: String
|
|
public let bytes: Int64
|
|
}
|
|
|
|
public struct OfflineStatusPayload: Codable, Sendable, Equatable {
|
|
public let cacheDir: String
|
|
public let onDiskFiles: Int
|
|
public let onDiskBytes: Int64
|
|
public let diskTotalBytes: Int64?
|
|
public let diskFreeBytes: Int64?
|
|
public let budgetBytes: Int64
|
|
public let policy: OfflineCachePolicy
|
|
public let caching: Bool
|
|
public let lastPlanCount: Int
|
|
public let downloadingLabel: String?
|
|
public let downloadingProgress: Double?
|
|
public let queueProgress: Double?
|
|
public let downloadQueue: [OfflineQueueItem]
|
|
public let downloadSummary: String?
|
|
public let status: String?
|
|
public let localDeviceId: String?
|
|
public let offlineCacheEnabled: Bool
|
|
public let files: [OfflineFileInfo]
|
|
public let lastCullSummary: String?
|
|
public let lastCulledAt: Date?
|
|
public let lastCulledFiles: [String]
|
|
public let planMissingCount: Int
|
|
public let reconcileIntervalSeconds: Int
|
|
}
|
|
|
|
public struct DevicesPayload: Codable, Sendable {
|
|
public let devices: [DeviceConfig]
|
|
}
|
|
|
|
public struct LocalDevicePayload: Codable, Sendable {
|
|
public let device: DeviceConfig
|
|
public let offlinePolicy: OfflineCachePolicy
|
|
}
|
|
|
|
public struct SettingsPayload: Codable, Sendable {
|
|
public let settings: AppSettings
|
|
}
|
|
|
|
public struct PlayerStatusPayload: Codable, Sendable {
|
|
public let activeId: String
|
|
public let activeName: String?
|
|
public let playbackMode: PlaybackMode
|
|
}
|
|
|
|
public struct PlayShowRequest: Codable, Sendable {
|
|
public let show: String
|
|
public var season: Int?
|
|
public var episode: Int?
|
|
}
|
|
|
|
public struct PlayResultPayload: Codable, Sendable {
|
|
public let activeId: String
|
|
public let activeName: String?
|
|
public let show: String
|
|
public let season: Int
|
|
public let episode: Int
|
|
public let label: String
|
|
public let path: String
|
|
public let queueLength: Int
|
|
}
|
|
|
|
// MARK: - server
|
|
|
|
/// Localhost HTTP API so MCP / Claude can read live app state (offline progress,
|
|
/// device policy, app settings) without guessing from log files. When the Mac app
|
|
/// is running it is the single source of truth — patches go through LibraryController
|
|
/// / PlayerController so memory and disk stay aligned.
|
|
@MainActor
|
|
public final class AppLocalAPI {
|
|
public static let defaultPort: UInt16 = 8791
|
|
|
|
public static var port: UInt16 {
|
|
if let raw = ProcessInfo.processInfo.environment["TV_ANARCHY_APP_PORT"],
|
|
let n = UInt16(raw) { return n }
|
|
return defaultPort
|
|
}
|
|
|
|
private let queue = DispatchQueue(label: "tv-anarchy.app-api", qos: .userInitiated)
|
|
private var listener: NWListener?
|
|
private weak var offline: OfflineCacheController?
|
|
private weak var player: PlayerController?
|
|
private weak var library: LibraryController?
|
|
private weak var playlist: PlaylistController?
|
|
|
|
public init() {}
|
|
|
|
public func attach(offline: OfflineCacheController, player: PlayerController,
|
|
library: LibraryController, playlist: PlaylistController) {
|
|
self.offline = offline
|
|
self.player = player
|
|
self.library = library
|
|
self.playlist = playlist
|
|
}
|
|
|
|
public func start() {
|
|
guard listener == nil else { return }
|
|
let p = NWEndpoint.Port(rawValue: Self.port)!
|
|
do {
|
|
let params = NWParameters.tcp
|
|
params.allowLocalEndpointReuse = true
|
|
listener = try NWListener(using: params, on: p)
|
|
} catch {
|
|
Log.error("app API: failed to bind :\(Self.port) — \(error)")
|
|
return
|
|
}
|
|
listener?.newConnectionHandler = { [weak self] conn in
|
|
Task { @MainActor in self?.accept(conn) }
|
|
}
|
|
listener?.stateUpdateHandler = { state in
|
|
if case .failed(let err) = state {
|
|
Log.error("app API listener failed: \(err)")
|
|
}
|
|
}
|
|
listener?.start(queue: queue)
|
|
// Security (v1 review + v2 plan): local-only (127 loopback by client + log; no network exposure intended).
|
|
// See correlation/cross-cutting.md "Infra Security Surfaces" + plan.md expanded checklist (mandatory auth/PathGuard for surfaces).
|
|
// For app-internal + MCP app_cfg only; do not expose. (Full NW localhost-only bind is client-enforced + firewall.)
|
|
Log.info("app API listening on 127.0.0.1:\(Self.port) (local-only; security per v2 plan)")
|
|
}
|
|
|
|
public func stop() {
|
|
listener?.cancel()
|
|
listener = nil
|
|
}
|
|
|
|
// MARK: handlers (testable)
|
|
|
|
public func offlineStatus() -> OfflineStatusPayload {
|
|
let policy = DevicesConfig.localOfflinePolicy()
|
|
let root = OfflineCacheController.destRoot(for: policy).path
|
|
let cfg = DevicesConfig.loadOrSeed()
|
|
let local = cfg.localDevice
|
|
let off = offline
|
|
return OfflineStatusPayload(
|
|
cacheDir: root,
|
|
onDiskFiles: off?.diskFileCount ?? OfflineCacheController.scanDisk().0,
|
|
onDiskBytes: off?.diskBytes ?? OfflineCacheController.scanDisk().1,
|
|
diskTotalBytes: OfflineCacheController.storageTotalBytes(at: root),
|
|
diskFreeBytes: OfflineCacheController.storageFreeBytes(at: root),
|
|
budgetBytes: OfflineCacheController.budgetBytes(policy: policy),
|
|
policy: policy,
|
|
caching: off?.caching ?? false,
|
|
lastPlanCount: off?.lastPlanCount ?? 0,
|
|
downloadingLabel: off?.downloadingLabel,
|
|
downloadingProgress: off?.downloadingProgress,
|
|
queueProgress: off?.queueProgress,
|
|
downloadQueue: off?.downloadQueue ?? [],
|
|
downloadSummary: off?.downloadSummaryLine,
|
|
status: off?.status,
|
|
localDeviceId: local?.id,
|
|
offlineCacheEnabled: local?.services.offlineCache ?? false,
|
|
files: Self.listCachedFiles(policy: policy),
|
|
lastCullSummary: off?.lastCullSummary,
|
|
lastCulledAt: off?.lastCulledAt,
|
|
lastCulledFiles: off?.lastCulledFiles ?? [],
|
|
planMissingCount: off?.planMissingCount ?? 0,
|
|
reconcileIntervalSeconds: Int(OfflineCacheController.reconcileInterval))
|
|
}
|
|
|
|
public func settingsSnapshot() -> AppSettings {
|
|
library?.appSettings ?? SettingsStore.load()
|
|
}
|
|
|
|
public func patchSettings(_ patch: Data) throws -> SettingsPayload {
|
|
guard let library else { throw APIError.unavailable("library not attached") }
|
|
let partial = try JSONDecoder().decode(AppSettingsPatch.self, from: patch)
|
|
library.patchSettings(partial)
|
|
if partial.forwardMediaKeys != nil || partial.forwardVolumeKeys != nil {
|
|
player?.applyMediaKeyForwarding()
|
|
}
|
|
return SettingsPayload(settings: library.appSettings)
|
|
}
|
|
|
|
public func patchOfflinePolicy(deviceId: String, patch: Data) throws -> LocalDevicePayload {
|
|
guard let player else { throw APIError.unavailable("player not attached") }
|
|
let partial = try JSONDecoder().decode(OfflinePolicyPatch.self, from: patch)
|
|
var cur = DevicesConfig.loadOrSeed().devices
|
|
.first { $0.id == deviceId }?.resolvedOfflinePolicy() ?? .defaults
|
|
if let v = partial.warmupEnabled { cur.warmupEnabled = v }
|
|
if let v = partial.episodesAhead { cur.episodesAhead = v }
|
|
if let v = partial.episodesBehind { cur.episodesBehind = v }
|
|
if let v = partial.shows { cur.shows = v }
|
|
if let v = partial.fromContinueWatching { cur.fromContinueWatching = v }
|
|
if let v = partial.cullEnabled { cur.cullEnabled = v }
|
|
if let v = partial.budgetPercent { cur.budgetPercent = v }
|
|
if let v = partial.reserveFreeGB { cur.reserveFreeGB = v }
|
|
if let v = partial.cacheDir { cur.cacheDir = v.isEmpty ? nil : v }
|
|
if let v = partial.pinned { cur.pinned = v }
|
|
player.updateOfflinePolicy(deviceId: deviceId, cur)
|
|
guard let d = DevicesConfig.loadOrSeed().devices.first(where: { $0.id == deviceId }) else {
|
|
throw APIError.notFound("device \(deviceId)")
|
|
}
|
|
return LocalDevicePayload(device: d, offlinePolicy: d.resolvedOfflinePolicy())
|
|
}
|
|
|
|
public func triggerWarmup() {
|
|
guard let offline else { return }
|
|
// Explicit warmup uses saved policy immediately (not the debounced actuation copy).
|
|
let saved = DevicesConfig.localOfflinePolicy()
|
|
Task { await offline.warmupIfEnabled(policy: saved) }
|
|
}
|
|
|
|
public func triggerDestroy() {
|
|
guard let offline else { return }
|
|
// Fire-and-forget like warmup; caller (UI or bridge) can poll /offline/status after.
|
|
let saved = DevicesConfig.localOfflinePolicy()
|
|
Task { _ = await offline.destroyAllOfflineMedia(policy: saved) }
|
|
}
|
|
|
|
public func playerStatus() throws -> PlayerStatusPayload {
|
|
guard let player else { throw APIError.unavailable("player not attached") }
|
|
return PlayerStatusPayload(activeId: player.activeID,
|
|
activeName: player.active?.name,
|
|
playbackMode: player.playbackMode)
|
|
}
|
|
|
|
public func setActivePlayer(_ body: Data) throws -> PlayerStatusPayload {
|
|
guard let player else { throw APIError.unavailable("player not attached") }
|
|
struct Body: Decodable { let deviceId: String }
|
|
let req = try JSONDecoder().decode(Body.self, from: body)
|
|
guard DevicesConfig.loadOrSeed().devices.contains(where: { $0.id == req.deviceId }) else {
|
|
throw APIError.notFound("device \(req.deviceId)")
|
|
}
|
|
player.setActive(req.deviceId)
|
|
return try playerStatus()
|
|
}
|
|
|
|
/// Play a series episode on whatever host the user last selected in the app —
|
|
/// does not switch devices. Queues the rest of the show when the host supports it.
|
|
public func playShow(_ body: Data) throws -> PlayResultPayload {
|
|
guard let player, let library, let playlist else {
|
|
throw APIError.unavailable("player not attached")
|
|
}
|
|
let req = try JSONDecoder().decode(PlayShowRequest.self, from: body)
|
|
let season = req.season ?? 1
|
|
let episode = req.episode ?? 1
|
|
guard let show = Self.resolveShow(named: req.show, in: library.shows) else {
|
|
throw APIError.notFound("show not found: \(req.show)")
|
|
}
|
|
guard let ep = show.orderedEpisodes.first(where: { $0.season == season && $0.episode == episode }) else {
|
|
throw APIError.notFound("no S\(season)E\(episode) in \(show.name)")
|
|
}
|
|
let queueLen: Int
|
|
if show.kind == .series, player.canEnqueue {
|
|
playlist.loadFromHere(show: show, startPath: ep.path)
|
|
player.setActiveContext(series: show.name, category: show.category)
|
|
playlist.play(on: player, resumeFirst: nil)
|
|
queueLen = playlist.queue.count
|
|
} else {
|
|
guard let kind = player.activeKind,
|
|
let launch = library.launchRequest(show: show, episode: ep, targetKind: kind) else {
|
|
throw APIError.unavailable("no player selected")
|
|
}
|
|
player.launch(launch, series: show.name, category: show.category)
|
|
queueLen = 1
|
|
}
|
|
return PlayResultPayload(activeId: player.activeID, activeName: player.active?.name,
|
|
show: show.name, season: ep.season, episode: ep.episode,
|
|
label: ep.label, path: ep.path, queueLength: queueLen)
|
|
}
|
|
|
|
/// Resume a show from the watchlog frontier on the user's selected host.
|
|
public func resumeShow(_ body: Data) throws -> PlayResultPayload {
|
|
guard let player, let library, let playlist else {
|
|
throw APIError.unavailable("player not attached")
|
|
}
|
|
let req = try JSONDecoder().decode(PlayShowRequest.self, from: body)
|
|
guard let show = Self.resolveShow(named: req.show, in: library.shows) else {
|
|
throw APIError.notFound("show not found: \(req.show)")
|
|
}
|
|
guard let item = library.continueWatching.first(where: { $0.show?.lowercased() == show.name.lowercased() })
|
|
?? library.continueWatching.first(where: { cw in
|
|
show.episodes.contains { MediaPaths.toRemote($0.path) == MediaPaths.toRemote(cw.path) }
|
|
}) else {
|
|
throw APIError.notFound("nothing to continue for \(show.name)")
|
|
}
|
|
if playlist.playContinue(item, shows: library.shows, on: player) {
|
|
return PlayResultPayload(activeId: player.activeID, activeName: player.active?.name,
|
|
show: show.name, season: item.season ?? 0, episode: item.episode ?? 0,
|
|
label: item.title, path: item.path, queueLength: playlist.queue.count)
|
|
}
|
|
guard let kind = player.activeKind,
|
|
let launch = library.launchRequest(continue: item, targetKind: kind) else {
|
|
throw APIError.unavailable("no player selected")
|
|
}
|
|
player.launch(launch, series: item.show, resumeSeconds: item.positionSeconds)
|
|
return PlayResultPayload(activeId: player.activeID, activeName: player.active?.name,
|
|
show: show.name, season: item.season ?? 0, episode: item.episode ?? 0,
|
|
label: item.title, path: item.path, queueLength: 1)
|
|
}
|
|
|
|
nonisolated static func resolveShow(named query: String, in shows: [CachedShow]) -> CachedShow? {
|
|
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
|
guard !q.isEmpty else { return nil }
|
|
let hits = shows.filter { $0.name.lowercased().contains(q) || q.contains($0.name.lowercased()) }
|
|
if hits.count == 1 { return hits[0] }
|
|
if let exact = hits.first(where: { $0.name.lowercased() == q }) { return exact }
|
|
return hits.min(by: { $0.name.count < $1.name.count })
|
|
}
|
|
|
|
nonisolated static func listCachedFiles(policy: OfflineCachePolicy) -> [OfflineFileInfo] {
|
|
let fm = FileManager.default
|
|
let root = OfflineCacheController.destRoot(for: policy).path
|
|
guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return [] }
|
|
var files: [OfflineFileInfo] = []
|
|
var stack = entries.map { root + "/" + $0 }
|
|
while let dir = stack.popLast() {
|
|
guard let kids = try? fm.contentsOfDirectory(atPath: dir) else { continue }
|
|
for name in kids {
|
|
let p = dir + "/" + name
|
|
var isDir: ObjCBool = false
|
|
guard fm.fileExists(atPath: p, isDirectory: &isDir) else { continue }
|
|
if isDir.boolValue { stack.append(p); continue }
|
|
guard DownloadsIndex.isVideoFilename(name) else { continue }
|
|
let bytes = (try? fm.attributesOfItem(atPath: p)[.size] as? Int64) ?? 0
|
|
files.append(OfflineFileInfo(path: p, name: name, bytes: bytes))
|
|
}
|
|
}
|
|
return files.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
}
|
|
|
|
// MARK: connection
|
|
|
|
private func accept(_ connection: NWConnection) {
|
|
connection.start(queue: queue)
|
|
var buffer = Data()
|
|
func receive() {
|
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, err in
|
|
if let data { buffer.append(data) }
|
|
if err != nil || isComplete {
|
|
connection.cancel()
|
|
return
|
|
}
|
|
guard let req = HTTPParser.parse(buffer) else {
|
|
receive()
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
let resp = self.dispatch(req)
|
|
Self.send(resp, on: connection, queue: self.queue)
|
|
}
|
|
}
|
|
}
|
|
receive()
|
|
}
|
|
|
|
private func dispatch(_ req: HTTPRequest) -> HTTPResponse {
|
|
let path = req.path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? req.path
|
|
switch (req.method, path) {
|
|
case ("GET", "/healthz"):
|
|
return .json(200, ["ok": true])
|
|
case ("GET", "/offline/status"):
|
|
return .json(200, offlineStatus())
|
|
case ("POST", "/offline/warmup"):
|
|
triggerWarmup()
|
|
return .json(202, ["accepted": true])
|
|
case ("POST", "/offline/destroy"):
|
|
triggerDestroy()
|
|
return .json(202, ["accepted": true])
|
|
case ("GET", "/devices"):
|
|
return .json(200, DevicesPayload(devices: DevicesConfig.loadOrSeed().devices))
|
|
case ("GET", "/devices/local"):
|
|
guard let d = DevicesConfig.loadOrSeed().localDevice else {
|
|
return .json(404, ["error": "no local device"])
|
|
}
|
|
return .json(200, LocalDevicePayload(device: d, offlinePolicy: d.resolvedOfflinePolicy()))
|
|
case ("GET", "/settings"):
|
|
return .json(200, SettingsPayload(settings: settingsSnapshot()))
|
|
case ("GET", "/player/status"):
|
|
do {
|
|
return .json(200, try playerStatus())
|
|
} catch let e as APIError {
|
|
return .json(e.status, ["error": e.message])
|
|
} catch {
|
|
return .json(400, ["error": error.localizedDescription])
|
|
}
|
|
case ("PATCH", "/player/active"):
|
|
do {
|
|
return .json(200, try setActivePlayer(req.body))
|
|
} catch let e as APIError {
|
|
return .json(e.status, ["error": e.message])
|
|
} catch {
|
|
return .json(400, ["error": error.localizedDescription])
|
|
}
|
|
case ("POST", "/play/show"):
|
|
do {
|
|
return .json(202, try playShow(req.body))
|
|
} catch let e as APIError {
|
|
return .json(e.status, ["error": e.message])
|
|
} catch {
|
|
return .json(400, ["error": error.localizedDescription])
|
|
}
|
|
case ("POST", "/play/resume-show"):
|
|
do {
|
|
return .json(202, try resumeShow(req.body))
|
|
} catch let e as APIError {
|
|
return .json(e.status, ["error": e.message])
|
|
} catch {
|
|
return .json(400, ["error": error.localizedDescription])
|
|
}
|
|
case ("PATCH", "/settings"):
|
|
do {
|
|
let body = try patchSettings(req.body)
|
|
return .json(200, body)
|
|
} catch let e as APIError {
|
|
return .json(e.status, ["error": e.message])
|
|
} catch {
|
|
return .json(400, ["error": error.localizedDescription])
|
|
}
|
|
default:
|
|
if req.method == "PATCH", path.hasPrefix("/devices/"), path.hasSuffix("/offline-policy") {
|
|
let id = String(path.dropFirst("/devices/".count).dropLast("/offline-policy".count))
|
|
do {
|
|
let body = try patchOfflinePolicy(deviceId: id, patch: req.body)
|
|
return .json(200, body)
|
|
} catch let e as APIError {
|
|
return .json(e.status, ["error": e.message])
|
|
} catch {
|
|
return .json(400, ["error": error.localizedDescription])
|
|
}
|
|
}
|
|
return .json(404, ["error": "not found"])
|
|
}
|
|
}
|
|
|
|
private static func send(_ resp: HTTPResponse, on connection: NWConnection, queue: DispatchQueue) {
|
|
var payload = resp.body
|
|
if payload.isEmpty, let enc = try? JSONEncoder().encode(EmptyJSON()) {
|
|
payload = enc
|
|
}
|
|
let headers = [
|
|
"HTTP/1.1 \(resp.status) \(HTTPResponse.phrase(resp.status))",
|
|
"Content-Type: application/json",
|
|
"Content-Length: \(payload.count)",
|
|
"Connection: close",
|
|
"",
|
|
"",
|
|
].joined(separator: "\r\n")
|
|
var out = Data(headers.utf8)
|
|
out.append(payload)
|
|
connection.send(content: out, completion: .contentProcessed { _ in connection.cancel() })
|
|
}
|
|
}
|
|
|
|
private struct EmptyJSON: Encodable {}
|
|
|
|
private struct OfflinePolicyPatch: Decodable {
|
|
var warmupEnabled: Bool?
|
|
var episodesAhead: Int?
|
|
var episodesBehind: Int?
|
|
var shows: Int?
|
|
var fromContinueWatching: Bool?
|
|
var cullEnabled: Bool?
|
|
var budgetPercent: Int?
|
|
var reserveFreeGB: Int?
|
|
var cacheDir: String?
|
|
var pinned: [String]?
|
|
}
|
|
|
|
enum APIError: Error {
|
|
case notFound(String)
|
|
case unavailable(String)
|
|
var status: Int {
|
|
switch self {
|
|
case .notFound: 404
|
|
case .unavailable: 503
|
|
}
|
|
}
|
|
var message: String {
|
|
switch self {
|
|
case .notFound(let m), .unavailable(let m): m
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - minimal HTTP
|
|
|
|
struct HTTPRequest {
|
|
let method: String
|
|
let path: String
|
|
let body: Data
|
|
}
|
|
|
|
struct HTTPResponse {
|
|
let status: Int
|
|
let body: Data
|
|
|
|
static func json<T: Encodable>(_ status: Int, _ value: T) -> HTTPResponse {
|
|
let body = (try? JSONEncoder().encode(value)) ?? Data()
|
|
return HTTPResponse(status: status, body: body)
|
|
}
|
|
|
|
static func json(_ status: Int, _ dict: [String: Any]) -> HTTPResponse {
|
|
let body = (try? JSONSerialization.data(withJSONObject: dict)) ?? Data()
|
|
return HTTPResponse(status: status, body: body)
|
|
}
|
|
|
|
static func phrase(_ code: Int) -> String {
|
|
switch code {
|
|
case 200: "OK"
|
|
case 202: "Accepted"
|
|
case 400: "Bad Request"
|
|
case 404: "Not Found"
|
|
case 503: "Service Unavailable"
|
|
default: "OK"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum HTTPParser {
|
|
static func parse(_ data: Data) -> HTTPRequest? {
|
|
guard let raw = String(data: data, encoding: .utf8) else { return nil }
|
|
guard let headerEnd = raw.range(of: "\r\n\r\n") else { return nil }
|
|
let headerPart = raw[..<headerEnd.lowerBound]
|
|
let bodyPart = raw[headerEnd.upperBound...]
|
|
let lines = headerPart.split(separator: "\r\n", omittingEmptySubsequences: false)
|
|
guard let requestLine = lines.first else { return nil }
|
|
let parts = requestLine.split(separator: " ", maxSplits: 2)
|
|
guard parts.count >= 2 else { return nil }
|
|
let method = String(parts[0])
|
|
let path = String(parts[1])
|
|
var contentLength = 0
|
|
for line in lines.dropFirst() {
|
|
let lower = line.lowercased()
|
|
if lower.hasPrefix("content-length:") {
|
|
contentLength = Int(lower.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) ?? 0
|
|
}
|
|
}
|
|
let bodyStr = String(bodyPart)
|
|
let bodyData = Data(bodyStr.utf8)
|
|
if bodyData.count < contentLength { return nil }
|
|
let body = bodyData.prefix(contentLength)
|
|
return HTTPRequest(method: method, path: path, body: Data(body))
|
|
}
|
|
} |