Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
eab22a4
ios: a catalog lists the completed scans the cleanup keeps, and stage…
SamGu-NRX Oct 2, 2026
0d3ba37
ios: Saved scans on the onboarding shares a kept scan after the app i…
SamGu-NRX Oct 2, 2026
aeae9dc
ios: a practice scan's row says its meter was a sample, not that its …
SamGu-NRX Oct 2, 2026
429448d
ios: Saved scans is an opaque full-height column in the app's own typ…
SamGu-NRX Oct 2, 2026
769af49
ios: launch deletes share copies an earlier run left, keeping any mad…
SamGu-NRX Oct 2, 2026
09ed76d
ios: the launch cutoff for old share copies is taken once per process…
SamGu-NRX Oct 2, 2026
96abec1
ios: a partial scan.zip no longer takes a kept place or gets an intac…
SamGu-NRX Oct 2, 2026
897ef2a
ios: bundleName's comment says a completed scan needs a whole packet,…
SamGu-NRX Oct 2, 2026
362700f
ios: a test replaces scan.zip during the archive check and expects it…
SamGu-NRX Oct 2, 2026
95eee1e
ios: each app run stages share copies in its own folder; launch delet…
SamGu-NRX Oct 2, 2026
be89599
Merge remote-tracking branch 'origin/feat/saved-scan-recovery' into f…
SamGu-NRX Oct 2, 2026
74640d5
ios: only a real scan folder is judged by its bundle; a link in its p…
SamGu-NRX Oct 2, 2026
f1767e3
Merge #200 (feat/saved-scan-recovery at 95eee1ed) into the saved-scan…
SamGu-NRX Oct 3, 2026
b826a08
Merge #202 (fix/cleanup-partial-bundles at 74640d5b) into the saved-s…
SamGu-NRX Oct 3, 2026
60e11c2
ios: tests pin share-copy ownership across sheets in one run and acro…
SamGu-NRX Oct 3, 2026
9945acb
ios: a failed upload's note says Saved scans can still share the save…
SamGu-NRX Oct 3, 2026
cf30991
ios: the walk's end card has a true answer for a wall that goes on, a…
SamGu-NRX Oct 3, 2026
72e5d1b
Let the homeowner ask again after an answer House Scan can't use
SamGu-NRX Oct 3, 2026
21abb9d
Close the test servers' listeners and test the engine's repeat count
SamGu-NRX Oct 3, 2026
c13cf30
fix(ios): describe the spot check's area in words when no outline is …
SamGu-NRX Oct 3, 2026
e1bc23a
docs(ios): correct the spot area's rounding example and scope
SamGu-NRX Oct 3, 2026
b722367
ios: "Wall ends here" and its preview share one check; "The wall keep…
SamGu-NRX Oct 3, 2026
4a7d4bf
ios: the cleanup deletes an entry only if it is still what it was whe…
SamGu-NRX Oct 3, 2026
d11fdc8
test(ios): keep screenshots of the end card, its refusal and the lost…
SamGu-NRX Oct 3, 2026
041dae0
ios: the README says which scans Saved scans lists and what a partial…
SamGu-NRX Oct 3, 2026
08ed892
ios: EndMarkRefusal conforms to Error, as Result's failure type needs
SamGu-NRX Oct 3, 2026
c1b2566
build(ios): exclude the two new spot fixtures from the UI test bundle
SamGu-NRX Oct 3, 2026
659f668
fix(ios): log the spot question shown, and expose each area line
SamGu-NRX Oct 3, 2026
4bc4ded
Answer the engine UI test's uploads through the gate folder
SamGu-NRX Oct 3, 2026
11d02a3
Keep the gate server answering after a lost request, and stop it fully
SamGu-NRX Oct 3, 2026
a7f99a5
feat(ios): let the homeowner answer "I can't check this area" on the …
SamGu-NRX Oct 3, 2026
0348628
ios: a close-up the phone couldn't save says so, instead of "Hold still"
SamGu-NRX Oct 3, 2026
803535d
fix(ios): read the area card's title as one element, and scroll each …
SamGu-NRX Oct 3, 2026
f020cbe
Merge branch 'fix/spot-fallback-20261003' into fix/spot-unknown-20261003
SamGu-NRX Oct 3, 2026
892acdc
test(ios): scroll the new spot-check checks fully on screen
SamGu-NRX Oct 3, 2026
f44f603
ios: a close-up is acknowledged only once it reads back; the hook hol…
SamGu-NRX Oct 3, 2026
d576a46
ios: the circle's end check is one tested decision, and lost place me…
SamGu-NRX Oct 3, 2026
c6f1aed
ios: "Wall ends here" keeps requiring normal tracking, as feature mar…
SamGu-NRX Oct 3, 2026
a7458e7
Give the repeated Try again the light screens' quiet style
SamGu-NRX Oct 3, 2026
74cd5cb
Merge #205 (codex/saved-scans-candidate-20261003 at 041dae0e) into th…
SamGu-NRX Oct 3, 2026
68dd71e
Merge #203 (codex/upload-answer-recovery at a7458e7c) into the candid…
SamGu-NRX Oct 3, 2026
d14eeb9
Merge #207 (feat/walk-unstuck at c6f1aed4) into the candidate branch:…
SamGu-NRX Oct 3, 2026
1e45aba
ios: a result with no spot and no nearest spot draws no sweep zones
SamGu-NRX Oct 3, 2026
2378edc
ios: the card says a cable run past the review line needs an installe…
SamGu-NRX Oct 3, 2026
23d446a
ios: UI tests for the result card's review band and an answer with no…
SamGu-NRX Oct 3, 2026
23e05a6
ios: decide whether the review line applies in the server's feet
SamGu-NRX Oct 3, 2026
7071b95
fix(ios): keep "an area wasn't checked" on a result without that spot
SamGu-NRX Oct 3, 2026
d672973
ios: an unsure line promises a photo only when it offers the camera f…
SamGu-NRX Oct 3, 2026
8e8d6e8
fix(ios): end the UI tests' answer file with the scan's spot checks
SamGu-NRX Oct 3, 2026
6cd1248
fix(ios): name an earlier unchecked area beside the spot's own notice
SamGu-NRX Oct 3, 2026
97d8ea2
Merge #204 (fix/spot-fallback-20261003 at 803535db) into the candidat…
SamGu-NRX Oct 3, 2026
268b241
Merge #208 (fix/still-save-failure at f44f6037) into the candidate br…
SamGu-NRX Oct 3, 2026
e2ef03f
fix(ios): let only the spot's own not-checked notice stand for an ear…
SamGu-NRX Oct 3, 2026
f0c271c
ios: regenerate the project for the new result fixtures; scroll the c…
SamGu-NRX Oct 3, 2026
ccb86b3
ios: an unusable answer's note points to Saved scans once the scan is…
SamGu-NRX Oct 3, 2026
98214f5
Merge #206 (fix/spot-unknown-20261003 at e2ef03f7) into the candidate…
SamGu-NRX Oct 3, 2026
260fd67
test(ios): the practice meter switch is tapped once its sheet can tak…
SamGu-NRX Oct 3, 2026
9f14817
Merge #209 (fix/result-clarity at f0c271cf) into the candidate branch…
SamGu-NRX Oct 3, 2026
55fb4b4
ios: the review line's wording includes the line itself
SamGu-NRX Oct 3, 2026
613c79d
test(ios): the share sheet test closes its sheet and app before it ends
SamGu-NRX Oct 3, 2026
d0d207a
Merge #209 (fix/result-clarity at 55fb4b4f) into the candidate branch…
SamGu-NRX Oct 3, 2026
485f1a6
ci: the iOS job gets 90 minutes for the combined candidate's UI suite
SamGu-NRX Oct 3, 2026
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
6 changes: 4 additions & 2 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ jobs:
# Beta run 36302099002 took 43 minutes before the spot-confirmation and review checks
# were added. Full run 36832282114 (#190) then took 58 of 60 minutes, and the ground
# freshness UI tests add about 161 s more (run 36744881974), so 60 would cut the
# every-state audit short. 75 leaves room without dropping any check.
timeout-minutes: 75
# every-state audit short. 75 left room until the 2026-10-03 candidate's 86 UI cases:
# run 37121918073 attempt 2 spent 10 min 34 s before the UI step and 64 min 39 s in it,
# and the cap stopped it after 76 cases with 0 failures. 90 runs all of them.
timeout-minutes: 90
env:
# Newest stable Xcode on macos-26 per images/macos/macos-26-arm64-Readme.md (image 20260907).
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
Expand Down
5 changes: 5 additions & 0 deletions ios/HouseScan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@
membershipExceptions = (
Fixtures/results,
"Fixtures/results/no-clean-spot.json",
"Fixtures/results/no-spot-no-nearest.json",
Fixtures/results/pass.json,
"Fixtures/results/reject-nearest.json",
"Fixtures/results/review-band.json",
"Fixtures/results/spot-left.json",
"Fixtures/results/spot-straddle.json",
"Fixtures/results/unsure-view.json",
"Fixtures/results/view-not-offered.json",
"Fixtures/synthetic-wall",
"Fixtures/synthetic-wall-lidar",
"Fixtures/synthetic-wall-lidar/keyframes",
Expand Down
18 changes: 15 additions & 3 deletions ios/HouseScan/Contract/ResultPresentation+Reading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,23 @@ extension ResultPresentation {
ResultReading.cardLines(readingChecks).map { checks[$0] }
}

/// The view `row`'s line on the card offers to take, or nil: the view the server named to
/// settle it (`settledBy`), when `ResultCardActions.lineOffersView` says the line offers it.
/// The line's "Show me" and its words (`ScanCopy.cardLine`, `ScanCopy.unsureNote`) both read
/// this, so the words never promise a photo the line doesn't let the homeowner take.
func offeredView(for row: CheckRow, sourceAvailable: Bool) -> MissingEvidence? {
let view = row.settledBy.flatMap { id in missing.first { $0.id == id } }
guard ResultCardActions.lineOffersView(
outcome: row.outcome.placementOutcome, needsPerson: row.needsPerson,
settlingViewCapturable: view?.capturable, sourceAvailable: sourceAvailable)
else { return nil }
return view
}

/// The view that would settle `row` and that the camera can take now: only for an unsure
/// check a person needn't judge.
/// check a person needn't judge. `offeredView` with the camera up.
func viewToTake(for row: CheckRow) -> MissingEvidence? {
guard row.outcome == .unsure, !row.needsPerson, let id = row.settledBy else { return nil }
return missing.first { $0.id == id && $0.capturable }
offeredView(for: row, sourceAvailable: true)
}

/// The first view in `missing` that settles an unsure check and can be taken now.
Expand Down
50 changes: 45 additions & 5 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ enum CloseUpProblem: Equatable, Sendable {
case numberTooSmall
/// No number could be read at all: retake.
case noNumber
/// The photo couldn't be written to the phone, or read back once written. Nothing about the
/// shot was wrong, so the words don't blame the homeowner's hands (B-20).
case photoNotSaved
}

/// One reading of the meter number from the close-up, for the homeowner to confirm.
Expand Down Expand Up @@ -553,6 +556,19 @@ enum NextWallRefusal: Equatable, Sendable {
case notAtCorner
}

/// Why "Wall ends here" at the circle marked no end (`ScanViewState.endMarkRefusal`). Before,
/// the button did nothing at all in each case, and nothing said why (B-06).
enum EndMarkRefusal: Error, Equatable, Sendable {
/// The circle isn't on the wall: aimed at the ground, so the ray meets the wall's plane under
/// the floor, or past the distance a camera's view counts for (`CoverageConfig.maxDistance`)
/// far down a long wall. Aimed above the wall is kept (`EndAim`).
case noWall
/// The circle is on the wall on this side of the meter, not the side the card asks about.
case otherSide(WallSide)
/// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`).
case trackingNotReady
}

enum MarkRefusal: Equatable, Sendable {
case noSurface
case wrongSide
Expand Down Expand Up @@ -627,10 +643,14 @@ enum UploadState: Equatable, Sendable {
/// A network failure or a server error (5xx): sending again can work. `offline` means no
/// connection at all.
case failed(message: String, offline: Bool)
/// The server refused the scan (4xx), its answer couldn't be read, or the scan couldn't be
/// packaged. Sending again would send the same thing, so the way on is back to the review
/// or start over, never "Try again".
/// The server refused the scan (4xx) or the scan couldn't be packaged. Sending again would send
/// the same thing, so the way on is back to the review or start over, never "Try again".
case rejected(message: String)
/// The server answered, but House Scan couldn't use the answer: it didn't decode, or it didn't
/// name the scene sent. It isn't shown. The scan isn't at fault, so the way on is "Try again",
/// with sharing the scan or starting over for when asking again doesn't help. `attempts` counts
/// such answers since the scan was sent from the review or a gap, at least 1.
case unusableAnswer(attempts: Int)
case done
}

Expand Down Expand Up @@ -665,6 +685,13 @@ struct CheckRow: Identifiable, Equatable, Sendable {
var plusMinus: Float? = nil
/// Whether `threshold` is a minimum or a maximum, when the server said which.
var comparison: RuleComparison? = nil
/// A stricter line inside `threshold`, meters, when the server sent one (`review_threshold_ft`,
/// on the cable run): a measurement that doesn't clear it needs review even within the limit.
/// Nil when the answer has none.
var reviewThreshold: Float? = nil
/// True when `reviewThreshold` explains this unsure check, so the card names it
/// (`PlacementCheck.reviewBandApplies`, decided in the server's feet before conversion).
var reviewBandApplies = false
/// The `MissingEvidence.id` of the first view that would settle this check, when the server
/// named one.
var settledBy: String? = nil
Expand Down Expand Up @@ -759,6 +786,10 @@ enum SpotCheckAnswer: Equatable, Sendable {
case clear
/// Something stands there: the scan stops claiming that area and is checked again.
case somethingThere
/// The homeowner can't see or reach the area to say: it was not observed, which is neither
/// an obstruction nor clear. The scan stops claiming that area and is checked again, and the
/// result says nobody checked it.
case cannotCheck
}

/// The one question asked before an answer's spot is shown as the result: is anything standing
Expand Down Expand Up @@ -867,6 +898,10 @@ final class ScanViewState {
/// offer (a question or a mark is up, both ends are marked, or the walk is doing something
/// else). "Wall ends here" shows only while it is set.
var endPreview: EndPreview?
/// Why the last "Wall ends here" at the circle marked nothing, while the walk still asks for
/// that end. Cleared once the circle is on the asked end (`EndPreview` at the reticle on that
/// side), when an end is set, or when the walk asks for something else.
var endMarkRefusal: EndMarkRefusal?
/// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one
/// that last ended a side: the walk asks "End the scan here?" instead of ending this side too
/// (#82). Answered by `answerEndScan`.
Expand All @@ -891,6 +926,11 @@ final class ScanViewState {
var result: ResultPresentation?
/// The homeowner's check of the proposed spot, retained beside the result.
var spotCheck: SpotCheck?
/// True when the homeowner answered "I can't check this area" about an area other than the
/// spot the result names, or while it names none. That area stays out of the scan, so the
/// result says so beside whatever it says about its own spot. Cleared with the scan's spot
/// checks.
var uncheckedAreaElsewhere = false
/// True while the engine sees the AR scene drawing the result in the live camera
/// (`ResultOverlayPolicy`). The AR screen then draws no overlay of its own; otherwise it
/// draws `BatteryOverlay`.
Expand Down Expand Up @@ -995,8 +1035,8 @@ protocol ScanActions: AnyObject {
func retryUpload()
/// After a rejected upload: back to the feature review, keeping the scan.
func backToReview()
/// The answer to `ScanViewState.spotCheck`: true when nothing stands in the area.
func answerSpotCheck(clear: Bool)
/// The answer to `ScanViewState.spotCheck`.
func answerSpotCheck(_ answer: SpotCheckAnswer)
/// Start a capture for a server-listed missing item.
func captureMissing(_ id: String)
func showAR()
Expand Down
8 changes: 8 additions & 0 deletions ios/HouseScan/HouseScanApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import SwiftUI

@main
struct HouseScanApp: App {
/// Runs once per process, before any window exists: deletes the share copies earlier runs
/// left, whose share sheets can't still be reading them. This run's copies live in its own
/// session folder (`SavedScansLocation.staging`), so they are never among them.
init() {
let staging = SavedScansLocation.staging
Task.detached(priority: .utility) { staging.removeOtherSessions() }
}

var body: some Scene {
WindowGroup {
Group {
Expand Down
34 changes: 21 additions & 13 deletions ios/HouseScan/Runtime/Autopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ final class Autopilot {
/// back to pixels use the same size.
private let viewSize = CGSize(width: 393, height: 852)
private var hold: Double { engine.options.autopilotHold }
/// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`): the
/// checks after it are answered "It's clear", so a server that keeps moving the spot can't
/// keep the run going.
private var answeredSomethingThere = false
/// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`) or "I
/// can't check this area" (`-autopilotCannotCheck`): the checks after it are answered "It's
/// clear", so a server that keeps moving the spot can't keep the run going.
private var withheldClear = false

init(engine: ScanEngine) {
self.engine = engine
Expand Down Expand Up @@ -119,7 +119,7 @@ final class Autopilot {
let state = engine.state
guard state.phase == .meterCloseUp else { return .left }
if case .choose(let candidates) = state.meterNumber, let first = candidates.first { return .choose(first) }
if case .aiming(_, let problem?) = state.closeUp, problem == .noNumber || problem == .numberTooSmall { return .retake(problem) }
if case .aiming(_, let problem?) = state.closeUp, [.noNumber, .numberTooSmall, .photoNotSaved].contains(problem) { return .retake(problem) }
return nil
}

Expand Down Expand Up @@ -399,18 +399,26 @@ final class Autopilot {
return false
}

/// "It's clear", or with `-autopilotSomethingThere` "Something's there" the first time. After
/// a refusal the stretch it withdrew goes to the gate folder as `spot-refusal.json` (the
/// stretch's s in feet, as scene.json's spans), for the UI test to check the scene against.
/// "It's clear", or the first time "Something's there" with `-autopilotSomethingThere` and "I
/// can't check this area" with `-autopilotCannotCheck`. After either, the stretch the answer
/// withdrew goes to the gate folder as `spot-refusal.json` (the stretch's s in feet, as
/// scene.json's spans), for the UI test to check the scene against.
private func answerSpotCheck() async {
guard let check = engine.state.spotCheck, check.answer == nil else { return }
await pause(hold)
await engine.waitForGate(.spotConfirm)
let refuse = engine.options.autopilotSomethingThere && !answeredSomethingThere
engine.answerSpotCheck(clear: !refuse)
log("spot check \(check.id) over \(format(check.area)): \(refuse ? "something's there" : "it's clear")")
if refuse {
answeredSomethingThere = true
let options = engine.options
let first: SpotCheckAnswer = options.autopilotCannotCheck ? .cannotCheck : options.autopilotSomethingThere ? .somethingThere : .clear
let answer: SpotCheckAnswer = withheldClear ? .clear : first
engine.answerSpotCheck(answer)
let said = switch answer {
case .clear: "it's clear"
case .somethingThere: "something's there"
case .cannotCheck: "I can't check this area"
}
log("spot check \(check.id) over \(format(check.area)): \(said)")
if answer != .clear {
withheldClear = true
writeRefusalForTest()
}
_ = await waitUntil(timeout: 20) { self.engine.state.phase != .spotConfirm }
Expand Down
104 changes: 104 additions & 0 deletions ios/HouseScan/Runtime/GateAnswerProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation
import Synchronization

/// The placement server's side of an upload under `-answersFromGate`, played by a UI test through
/// the `-autopilotGate` folder. The app's real `PlacementHTTPClient` sends through `session`, so
/// the request, the answer's status check and `ResultBinding` all run as they do against a server;
/// only the socket is replaced.
///
/// For the n-th request (from 0, in the order the app sends them) it writes `request-n.target`
/// ("POST /v1/placements"), then the exact body as `request-n.json`, then waits up to 60 seconds
/// for the test's `answer-n.json` and answers HTTP 200 with its bytes. With no answer by then the
/// request fails as timed out.
///
/// The UI test used to run a server on 127.0.0.1 inside its runner. That server answered
/// URLSession on a Mac, but on the hosted Simulator the app's requests to it failed as offline
/// (Actions run 37107233020); the runner is a background app while the app under test is open.
/// HouseScanKit's `PlacementHTTPClientTests` still drive the client over a real loopback socket.
final class GateAnswerProtocol: URLProtocol, @unchecked Sendable {
static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [GateAnswerProtocol.self]
return URLSession(configuration: configuration)
}()

static let answerWait: TimeInterval = 60

/// Requests started in this process, which numbers the files.
private static let started = Mutex(0)

/// Set and read only on the loading thread: `startLoading`, the timer and `stopLoading` all
/// run on the run loop URLSession called `startLoading` on.
private var poll: Timer?

override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
guard let gate = LaunchOptions().autopilotGate, let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let body: Data
do {
body = try Self.body(of: request)
} catch {
client?.urlProtocol(self, didFailWithError: error)
return
}
// Numbered only once the body is read. A write that fails after this leaves a gap in the
// numbers, which the test's server skips over.
let index = Self.started.withLock { count in
defer { count += 1 }
return count
}
do {
let target = "\(request.httpMethod ?? "GET") \(url.path())"
try Data(target.utf8).write(to: gate.appending(path: "request-\(index).target"), options: .atomic)
try body.write(to: gate.appending(path: "request-\(index).json"), options: .atomic)
} catch {
client?.urlProtocol(self, didFailWithError: error)
return
}
let answer = gate.appending(path: "answer-\(index).json")
let deadline = Date().addingTimeInterval(Self.answerWait)
let timer = Timer(timeInterval: 0.1, repeats: true) { [self] timer in
if let body = try? Data(contentsOf: answer) {
timer.invalidate()
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
} else if Date() >= deadline {
timer.invalidate()
RuntimeLog.engine.error("answers from gate: no \(answer.lastPathComponent, privacy: .public) within \(Self.answerWait, privacy: .public) s")
client?.urlProtocol(self, didFailWithError: URLError(.timedOut))
}
}
RunLoop.current.add(timer, forMode: RunLoop.current.currentMode ?? .default)
poll = timer
}

override func stopLoading() {
poll?.invalidate()
poll = nil
}

/// An upload task hands its body over as a stream, not `httpBody`. A stream that fails to read
/// throws: a cut-short body would reach the test as an answer naming the wrong scene.
private static func body(of request: URLRequest) throws -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let read = stream.read(&buffer, maxLength: buffer.count)
if read < 0 { throw stream.streamError ?? URLError(.cannotDecodeRawData) }
guard read > 0 else { break }
data.append(buffer, count: read)
}
return data
}
}
Loading
Loading