From eab22a473421371a57999224051bc6362e523bac Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 13:17:22 -0500 Subject: [PATCH 01/15] ios: a catalog lists the completed scans the cleanup keeps, and stages one for sharing SavedScanCatalog lists the folders ScanFolderCleanup keeps as completed, newest first, with each bundle's saved time, size and the stamp's practice flag when it is a JSON boolean. A bundle counts only when it was written to the end (PacketArchiveCheck): a quit during the in-place zip write leaves a partial scan.zip under the completed name. SavedScanStaging copies a bundle out of the scan folder for the share sheet, so a cleanup can't delete it mid-share, and removes only its own copies. Links are never scans. Co-Authored-By: Claude Code --- .../SavedScans/PacketArchiveCheck.swift | 116 ++++++++ .../HouseScanKit/SavedScans/SavedScan.swift | 45 +++ .../SavedScans/SavedScanCatalog.swift | 133 +++++++++ .../SavedScanCatalogTests.swift | 256 ++++++++++++++++++ 4 files changed, 550 insertions(+) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift new file mode 100644 index 000000000..de51b2599 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Whether a file is a whole capture-packet zip, read from its two ends without unpacking it. +/// +/// `scan.zip` is written in place (`ZipWriter.write`), so a scan quit or crashed during the write, +/// or a bundle still being written when a list is made, leaves a partial file under the name that +/// marks a completed scan. The zip's last record (the end of central directory) is written last, +/// so a file whose tail holds that record, pointing at a central directory that ends right before +/// it, was written to the end. The first entry must be `manifest.json`, which the packet always +/// writes first (`KeyframeStore.zipPacket`), so another zip under the name is not taken for a scan. +/// The central directory's first record must name the same entry at offset 0, which ties the +/// footer, the directory and the first entry together. A symbolic link is refused, so a copy of +/// the file is always the bytes themselves. +/// +/// This detects a bundle that wasn't written to the end; it is not a defense against a crafted +/// zip. Only the app writes into its scan folders. +/// +/// The check reads at most 64 KiB from each end, whatever the archive's size. It does not check +/// each entry's CRC: that would read every photo, and a write that reached the last record wrote +/// every entry before it. +public enum PacketArchiveCheck { + public enum Failure: Error, Equatable, Sendable { + /// The file can't be opened or read. + case unreadable + /// The end of central directory is missing or points somewhere else: a partial write. + case incomplete + /// A whole zip, but not a capture packet: its first entry isn't manifest.json. + case notAPacket + /// A symbolic link, a folder or anything else that isn't a plain file. + case notAFile + } + + static let firstEntryName = "manifest.json" + private static let localHeaderSignature: UInt32 = 0x0403_4B50 + private static let centralHeaderSignature: UInt32 = 0x0201_4B50 + private static let endSignature: UInt32 = 0x0605_4B50 + private static let localHeaderSize = 30 + private static let endRecordSize = 22 + private static let centralHeaderSize = 46 + + /// Returns the archive's size in bytes when it is a whole capture packet. + public static func verify(_ url: URL) throws(Failure) -> Int64 { + // attributesOfItem doesn't follow a final symbolic link. + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { throw .unreadable } + guard attributes[.type] as? FileAttributeType == .typeRegular else { throw .notAFile } + guard let handle = try? FileHandle(forReadingFrom: url) else { throw .unreadable } + defer { try? handle.close() } + do { + let size = try handle.seekToEnd() + guard size >= UInt64(localHeaderSize + endRecordSize) else { throw Failure.incomplete } + + // The first local header, and its name. + try handle.seek(toOffset: 0) + let header = try handle.read(upToCount: localHeaderSize) ?? Data() + guard header.count == localHeaderSize, header.le(UInt32.self, at: 0) == localHeaderSignature else { + throw Failure.notAPacket + } + let nameLength = Int(header.le(UInt16.self, at: 26)) + let name = try handle.read(upToCount: nameLength) ?? Data() + guard name == Data(firstEntryName.utf8) else { throw Failure.notAPacket } + + // The end record sits in the last 22 bytes plus its comment, at most 65535 bytes. + let tailLength = min(size, UInt64(endRecordSize + 0xFFFF)) + let tailStart = size - tailLength + try handle.seek(toOffset: tailStart) + let tail = try handle.read(upToCount: Int(tailLength)) ?? Data() + guard tail.count == Int(tailLength), let end = endRecord(in: tail) else { throw Failure.incomplete } + let endOffset = tailStart + UInt64(end) + let entries = tail.le(UInt16.self, at: end + 10) + let directorySize = UInt64(tail.le(UInt32.self, at: end + 12)) + let directoryOffset = UInt64(tail.le(UInt32.self, at: end + 16)) + guard tail.le(UInt16.self, at: end + 4) == 0, tail.le(UInt16.self, at: end + 6) == 0, + tail.le(UInt16.self, at: end + 8) == entries, entries > 0, + directoryOffset + directorySize == endOffset else { throw Failure.incomplete } + + // The central directory starts where the record says, and its first record is the + // manifest at offset 0. + guard directorySize >= UInt64(centralHeaderSize) * UInt64(entries) else { throw Failure.incomplete } + try handle.seek(toOffset: directoryOffset) + let first = try handle.read(upToCount: centralHeaderSize) ?? Data() + guard first.count == centralHeaderSize, first.le(UInt32.self, at: 0) == centralHeaderSignature else { throw Failure.incomplete } + let firstName = try handle.read(upToCount: Int(first.le(UInt16.self, at: 28))) ?? Data() + guard firstName == Data(firstEntryName.utf8), first.le(UInt32.self, at: 42) == 0 else { throw Failure.incomplete } + return Int64(size) + } catch let failure as Failure { + throw failure + } catch { + throw .unreadable + } + } + + /// The offset in `tail` of the end record whose comment runs exactly to the end, searching + /// from the end; nil when there is none. + private static func endRecord(in tail: Data) -> Int? { + var offset = tail.count - endRecordSize + while offset >= 0 { + if tail.le(UInt32.self, at: offset) == endSignature, + Int(tail.le(UInt16.self, at: offset + 20)) == tail.count - offset - endRecordSize { + return offset + } + offset -= 1 + } + return nil + } +} + +private extension Data { + /// A little-endian integer at `offset` from the start of this data. + func le(_: T.Type, at offset: Int) -> T { + var value: T = 0 + for byte in 0...size { + value |= T(self[startIndex + offset + byte]) << (8 * byte) + } + return value + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift new file mode 100644 index 000000000..84ce3c49d --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A completed scan still on the phone, as the Saved scans list shows it: its bundle and what the +/// files say about it. Nothing here is read from the capture itself, so a field the files don't +/// carry is nil rather than guessed. +public struct SavedScan: Sendable, Equatable, Identifiable { + /// The scan folder's name, a UUID the store made. Not shown to the homeowner. + public let id: String + /// The bundle Share scan offers, `/scan.zip`. + public let archive: URL + /// When the bundle was last written: the file's modification time. The bundle is written when + /// the scan is uploaded and again on a retry, so this is when the scan was saved, not when its + /// photos were taken. Nil when the file system gives no date. + public let savedAt: Date? + /// The bundle's size in bytes. + public let byteCount: Int64 + /// `scan-stamp.json`'s `practice`: true for a practice scan, false for a real one, nil when + /// the folder has no readable stamp. Nil is "unknown", never "real". + public let practice: Bool? + + public init(id: String, archive: URL, savedAt: Date?, byteCount: Int64, practice: Bool?) { + self.id = id + self.archive = archive + self.savedAt = savedAt + self.byteCount = byteCount + self.practice = practice + } + + /// The name the shared copy carries, so a recipient with several scans can tell them apart: + /// "House Scan 2026-10-02 14.05.zip" in the phone's time zone, "House Scan practice …" for a + /// practice scan, and plain "House Scan.zip" without a date. No colons, which Files and + /// Windows refuse. + public func shareFileName(timeZone: TimeZone = .current) -> String { + var parts = ["House Scan"] + if practice == true { parts.append("practice") } + if let savedAt { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH.mm" + parts.append(formatter.string(from: savedAt)) + } + return parts.joined(separator: " ") + ".zip" + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift new file mode 100644 index 000000000..52b75a017 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -0,0 +1,133 @@ +import Foundation + +/// The completed scans a homeowner can still share, read from the scan folders on disk. +/// +/// A scan counts by the same rule the cleanup keeps it by (`ScanFolderCleanup`): a folder with a +/// `scan.zip`, newest bundle first, at most the `defaultKeepCompleted` the cleanup keeps. Listing +/// the cleanup's own choice means the list never shows an older scan that the next cleanup is +/// about to delete. On top of that rule a scan is listed only when its bundle is a whole capture +/// packet (`PacketArchiveCheck`), since a scan quit while its bundle was being written leaves a +/// partial `scan.zip` behind. +/// +/// The list is a reading of the folders at one moment, and a scan can still go after it. A new +/// scan's cleanup deletes what its store listed when it was made, a little later and off the main +/// actor, so a bundle an earlier scan finished writing in between can be listed here and then +/// deleted. Sharing it then reports it missing (`SavedScanShareError.missing`) rather than +/// failing silently; listing exactly the cleanup's pending set would mean sharing state with +/// the store's cleanup, which this feature leaves as it is. +/// +/// Everything here reads files synchronously; the app calls it off the main actor. +public struct SavedScanCatalog: Sendable { + /// The folder holding one folder per scan, Caches/Scans in the app. + public let root: URL + /// The largest stamp read for its `practice` flag. A stamp is a few hundred bytes; anything + /// past this is not one. + static let stampByteLimit = 64 * 1024 + + public init(root: URL) { + self.root = root + } + + /// The completed scans, newest first. Folders without a whole bundle are left out. + public func scans() -> [SavedScan] { + // No folder is the scan in use here: the cleanup's `keeping` excludes a name, and the + // empty name matches no folder, so every folder competes by the cleanup's rule. + ScanFolderCleanup(root: root, keeping: "").keptCompleted.compactMap(Self.scan(in:)) + } + + static func scan(in folder: URL) -> SavedScan? { + // The scan folders are the app's own; a link among them is not a scan. + guard (try? FileManager.default.attributesOfItem(atPath: folder.path))?[.type] as? FileAttributeType == .typeDirectory else { return nil } + let archive = folder.appending(path: ScanFolderCleanup.bundleName) + guard let size = try? PacketArchiveCheck.verify(archive) else { return nil } + let attributes = try? FileManager.default.attributesOfItem(atPath: archive.path) + return SavedScan( + id: folder.lastPathComponent, archive: archive, + savedAt: attributes?[.modificationDate] as? Date, + byteCount: size, + practice: practice(stamp: folder.appending(path: ScanStamp.fileName))) + } + + /// The stamp's `practice` flag when the stamp is readable and holds a JSON boolean there; + /// otherwise nil. A missing, oversized or corrupt stamp says nothing about the scan. + static func practice(stamp: URL) -> Bool? { + // Not a link (attributesOfItem doesn't follow one), and never more read than the limit. + guard (try? FileManager.default.attributesOfItem(atPath: stamp.path))?[.type] as? FileAttributeType == .typeRegular, + let handle = try? FileHandle(forReadingFrom: stamp) else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: stampByteLimit + 1), data.count <= stampByteLimit else { return nil } + struct Flag: Decodable { let practice: Bool } + return (try? JSONDecoder().decode(Flag.self, from: data))?.practice + } +} + +/// Why a saved scan couldn't be handed to the share sheet. +public enum SavedScanShareError: Error, Equatable, Sendable { + /// The bundle is no longer on the phone: a newer scan's cleanup deleted it, or the system + /// cleared the app's caches. + case missing + /// The bundle is there but not a whole capture packet. + case damaged + /// The copy for the share sheet couldn't be made, for example with the phone's storage full. + case copyFailed +} + +/// Copies of saved scans made for the share sheet, each in a folder of its own under `root`. +/// +/// The share sheet reads the file it is given for as long as it is open, and a scan's own bundle +/// can be deleted under it by a new scan's cleanup. A copy outside the scan folders can't be. +/// On APFS the copy is a clone, so it costs no space until one side changes. Only this type +/// creates or deletes anything under `root`; the scan folders are never written. +public struct SavedScanStaging: Sendable { + public let root: URL + + public init(root: URL) { + self.root = root + } + + /// A whole copy of `scan`'s bundle under `root`, named `scan.shareFileName()`. + public func stage(_ scan: SavedScan) throws(SavedScanShareError) -> URL { + let files = FileManager.default + let source = scan.archive + func failure(_ check: PacketArchiveCheck.Failure) -> SavedScanShareError { + guard files.fileExists(atPath: source.path) else { return .missing } + return check == .unreadable ? .copyFailed : .damaged + } + do { + _ = try PacketArchiveCheck.verify(source) + } catch { + throw failure(error) + } + let folder = root.appending(path: UUID().uuidString, directoryHint: .isDirectory) + let copy = folder.appending(path: scan.shareFileName()) + do { + try files.createDirectory(at: folder, withIntermediateDirectories: true) + try files.copyItem(at: source, to: copy) + } catch { + try? files.removeItem(at: folder) + throw files.fileExists(atPath: source.path) ? .copyFailed : .missing + } + // The bundle could have been replaced or cut short while it was copied. + do { + _ = try PacketArchiveCheck.verify(copy) + } catch { + try? files.removeItem(at: folder) + throw failure(error) + } + return copy + } + + /// Deletes a copy `stage` made, with its folder. A URL that isn't one of this staging's copies + /// is left alone. + public func remove(_ copy: URL) { + let folder = copy.deletingLastPathComponent().standardizedFileURL + guard folder.deletingLastPathComponent().path == root.standardizedFileURL.path else { return } + try? FileManager.default.removeItem(at: folder) + } + + /// Deletes every copy, for when no share sheet is open: copies a share left when the app was + /// quit with the sheet up. + public func removeAll() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift new file mode 100644 index 000000000..a246abb5c --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -0,0 +1,256 @@ +import Foundation +@testable import HouseScanKit +import Testing + +/// Saved scans: the completed scans the cleanup keeps, read from synthetic scan folders, and the +/// copies handed to the share sheet. Every archive here is made by `ZipWriter`, never a capture. +@Suite struct SavedScanCatalogTests { + let root: URL + let files = FileManager.default + + init() throws { + root = files.temporaryDirectory.appending(path: "saved-scans-\(UUID().uuidString)") + try files.createDirectory(at: root, withIntermediateDirectories: true) + } + + /// A packet as the app zips it: manifest.json first, then a photo. + static let packet: Data = { + // A fixed photo payload so every test archive has the same bytes. + let photo = Data((0..<4096).map { UInt8($0 % 251) }) + return try! ZipWriter.archive([ + ZipEntry(name: "manifest.json", data: Data(#"{"version":"1.1"}"#.utf8)), + ZipEntry(name: "photos/p00001.jpg", data: photo), + ]) + }() + + /// A scan folder with a keyframe, and optionally a bundle saved `minutesAgo` and a stamp. + @discardableResult + func scan(_ name: String, bundle: Data? = packet, minutesAgo: Double = 10, stamp: String? = nil) throws -> URL { + let folder = root.appending(path: name) + try files.createDirectory(at: folder, withIntermediateDirectories: true) + try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) + if let bundle { + let archive = folder.appending(path: ScanFolderCleanup.bundleName) + try bundle.write(to: archive) + try files.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: archive.path) + } + if let stamp { + try Data(stamp.utf8).write(to: folder.appending(path: ScanStamp.fileName)) + } + return folder + } + + func cleanUp() { + try? files.removeItem(at: root) + } + + // MARK: Listing + + /// The list is the cleanup's kept set, newest first, with each bundle's saved time and size. + /// A folder never packaged is not a scan; an older completed one the cleanup will delete is + /// not offered. + @Test func listsTheCompletedScansTheCleanupKeeps() throws { + defer { cleanUp() } + try scan("unfinished", bundle: nil) + try scan("oldest", minutesAgo: 90) + try scan("older", minutesAgo: 30) + try scan("newest", minutesAgo: 5) + + let scans = SavedScanCatalog(root: root).scans() + #expect(scans.map(\.id) == ["newest", "older"]) + #expect(scans.map(\.id) == ScanFolderCleanup(root: root, keeping: "").keptCompleted.map(\.lastPathComponent)) + let newest = try #require(scans.first) + #expect(newest.byteCount == Int64(Self.packet.count)) + #expect(newest.archive == root.appending(path: "newest/\(ScanFolderCleanup.bundleName)")) + let saved = try #require(newest.savedAt) + #expect(abs(saved.timeIntervalSinceNow + 5 * 60) < 5) + } + + /// A relaunch makes a new store: its cleanup runs, and a fresh catalog over the same folders + /// still lists the completed scans. The new scan's empty folder is not one of them. + @Test func aCompletedScanIsListedAfterARelaunch() throws { + defer { cleanUp() } + try scan("finished", minutesAgo: 20) + try scan("quitMidWalk", bundle: nil) + try scan("relaunch", bundle: nil) + + let failed = ScanFolderCleanup(root: root, keeping: "relaunch").run() + #expect(failed.isEmpty) + #expect(SavedScanCatalog(root: root).scans().map(\.id) == ["finished"]) + } + + /// An empty or missing root lists nothing rather than failing. + @Test func noFoldersListNothing() { + defer { cleanUp() } + #expect(SavedScanCatalog(root: root).scans().isEmpty) + #expect(SavedScanCatalog(root: root.appending(path: "absent")).scans().isEmpty) + } + + /// A bundle cut short, as a quit during its write leaves it, is not a completed scan, wherever + /// it was cut. Nor is a zip that isn't a packet, or an empty file. + @Test(arguments: [1, 21, 22, 23, 200, SavedScanCatalogTests.packet.count / 2, SavedScanCatalogTests.packet.count - 31]) + func aPartialBundleIsLeftOut(bytesMissing: Int) throws { + defer { cleanUp() } + try scan("partial", bundle: Self.packet.dropLast(bytesMissing), minutesAgo: 1) + try scan("whole", minutesAgo: 30) + #expect(SavedScanCatalog(root: root).scans().map(\.id) == ["whole"]) + } + + @Test func aBundleThatIsNotAPacketIsLeftOut() throws { + defer { cleanUp() } + let other = try ZipWriter.archive([ZipEntry(name: "notes.txt", data: Data("hello".utf8))]) + try scan("other", bundle: other) + try scan("empty", bundle: Data()) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + let otherURL = root.appending(path: "other/\(ScanFolderCleanup.bundleName)") + #expect(throws: PacketArchiveCheck.Failure.notAPacket) { try PacketArchiveCheck.verify(otherURL) } + let emptyURL = root.appending(path: "empty/\(ScanFolderCleanup.bundleName)") + #expect(throws: PacketArchiveCheck.Failure.incomplete) { try PacketArchiveCheck.verify(emptyURL) } + #expect(throws: PacketArchiveCheck.Failure.unreadable) { try PacketArchiveCheck.verify(root.appending(path: "none.zip")) } + } + + /// Bytes after the end record are a partial rewrite or something else, not a whole packet. + @Test func bytesAfterTheEndRecordAreRefused() throws { + defer { cleanUp() } + try scan("padded", bundle: Self.packet + Data(repeating: 0, count: 8)) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + } + + /// A central directory whose first record doesn't point at the manifest at offset 0 doesn't + /// belong with the entries before it. + @Test func aDirectoryThatDisagreesWithTheFirstEntryIsRefused() throws { + defer { cleanUp() } + var bytes = Self.packet + let end = bytes.count - 22 + let directoryOffset = bytes[(end + 16)..<(end + 20)].enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } + bytes[directoryOffset + 42] = 1 + try scan("forged", bundle: bytes) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + } + + /// A link is never listed, whether the bundle or the scan folder is the link, and a linked + /// stamp says nothing: a copy of a link would not be the bundle's bytes. + @Test func linksAreNotScans() throws { + defer { cleanUp() } + let real = try scan("real", minutesAgo: 30) + let elsewhere = root.appending(path: "elsewhere.zip") + try Self.packet.write(to: elsewhere) + let linked = try scan("linkedBundle", bundle: nil) + try files.createSymbolicLink(at: linked.appending(path: ScanFolderCleanup.bundleName), withDestinationURL: elsewhere) + try files.createSymbolicLink(at: root.appending(path: "linkedFolder"), withDestinationURL: real) + let stamp = root.appending(path: "stamp.json") + try Data(#"{"practice": true}"#.utf8).write(to: stamp) + try files.createSymbolicLink(at: real.appending(path: ScanStamp.fileName), withDestinationURL: stamp) + + let listed = SavedScanCatalog(root: root).scans() + #expect(listed.map(\.id) == ["real"]) + #expect(listed.first?.practice == nil) + let link = linked.appending(path: ScanFolderCleanup.bundleName) + #expect(throws: PacketArchiveCheck.Failure.notAFile) { try PacketArchiveCheck.verify(link) } + } + + // MARK: Practice + + /// The stamp's practice flag is passed through only when it is a JSON boolean. A missing, + /// corrupt or oddly typed stamp leaves it unknown, and the scan is still listed. + @Test(arguments: [ + (#"{"practice": true, "app": {}}"#, true as Bool?), + (#"{"practice": false}"#, false), + (#"{"app": {}}"#, nil), + (#"{"practice": 1}"#, nil), + (#"{"practice": "true"}"#, nil), + ("{not json", nil), + ("", nil), + ]) + func practiceComesOnlyFromAReadableStamp(stamp: String, practice: Bool?) throws { + defer { cleanUp() } + try scan("one", stamp: stamp) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + #expect(listed.practice == practice) + } + + @Test func noStampAndAnOversizedStampSayNothing() throws { + defer { cleanUp() } + try scan("bare", minutesAgo: 5) + let padding = String(repeating: " ", count: SavedScanCatalog.stampByteLimit) + try scan("huge", minutesAgo: 10, stamp: #"{"practice": true}"# + padding) + let scans = SavedScanCatalog(root: root).scans() + #expect(scans.map(\.id) == ["bare", "huge"]) + #expect(scans.allSatisfy { $0.practice == nil }) + } + + // MARK: Sharing + + /// The share sheet gets a copy outside the scan folder with the bundle's exact bytes and a + /// name a recipient can tell apart. Removing it deletes only the copy. + @Test func stagingCopiesTheBundleExactly() throws { + defer { cleanUp() } + let folder = try scan("one", stamp: #"{"practice": false}"#) + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + + let copy = try staging.stage(listed) + #expect(try Data(contentsOf: copy) == Self.packet) + #expect(copy.lastPathComponent == listed.shareFileName()) + #expect(!copy.path.hasPrefix(folder.path)) + + staging.remove(copy) + #expect(!files.fileExists(atPath: copy.deletingLastPathComponent().path)) + #expect(try Data(contentsOf: listed.archive) == Self.packet) + } + + /// A scan the cleanup deleted after the list was made is reported missing, and leaves no copy. + @Test func aBundleGoneSinceListingIsMissing() throws { + defer { cleanUp() } + let folder = try scan("gone") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + try files.removeItem(at: folder) + + #expect(throws: SavedScanShareError.missing) { try staging.stage(listed) } + #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) + } + + /// A bundle cut short after the list was made is reported damaged, and leaves no copy. + @Test func aBundleCutShortSinceListingIsDamaged() throws { + defer { cleanUp() } + try scan("cut") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + try Self.packet.prefix(100).write(to: listed.archive) + + #expect(throws: SavedScanShareError.damaged) { try staging.stage(listed) } + #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) + } + + /// `remove` deletes only folders it made; `removeAll` clears every copy and nothing else. + @Test func removalStaysInsideTheStagingFolder() throws { + defer { cleanUp() } + let folder = try scan("keep") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + + staging.remove(listed.archive) + #expect(files.fileExists(atPath: listed.archive.path)) + + let first = try staging.stage(listed) + let second = try staging.stage(listed) + #expect(first != second) + staging.removeAll() + #expect(!files.fileExists(atPath: staging.root.path)) + #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) + } + + // MARK: Names + + @Test func theShareNameSaysWhenAndWhetherPractice() { + let archive = URL(fileURLWithPath: "/x/scan.zip") + let saved = Date(timeIntervalSince1970: 1_790_000_000) // 2026-09-21 14:13:20 UTC + let utc = TimeZone(identifier: "UTC")! + #expect(SavedScan(id: "a", archive: archive, savedAt: saved, byteCount: 1, practice: false).shareFileName(timeZone: utc) + == "House Scan 2026-09-21 14.13.zip") + #expect(SavedScan(id: "a", archive: archive, savedAt: saved, byteCount: 1, practice: true).shareFileName(timeZone: utc) + == "House Scan practice 2026-09-21 14.13.zip") + #expect(SavedScan(id: "a", archive: archive, savedAt: nil, byteCount: 1, practice: nil).shareFileName() == "House Scan.zip") + } +} From 0d3ba37a034adc2ac8998ae649c5853951fb5533 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:02:43 -0500 Subject: [PATCH 02/15] ios: Saved scans on the onboarding shares a kept scan after the app is reopened (#130) The onboarding's top bar opens Saved scans: the completed scans the cleanup keeps, each with its saved time, size and a Practice tag only when its stamp says so. Share copies the bundle to tmp and hands the copy to the system share sheet, which deletes it when it closes; a scan gone since the list was made gets an alert and drops out. Nothing is sent before Share, and a saved scan is never reopened or continued. The onboarding holds the sheet's state: the top bar swaps layouts at accessibility text sizes, and a sheet owned by the button closed when the text size changed. The empty state scrolls, so its text isn't cut off at large sizes in the half-height sheet. Co-Authored-By: Claude Code --- ios/HouseScan/Runtime/KeyframeStore.swift | 9 +- .../UI/SavedScans/ActivitySheet.swift | 23 ++ .../UI/SavedScans/SavedScanRow.swift | 97 +++++++++ .../UI/SavedScans/SavedScansButton.swift | 33 +++ .../UI/SavedScans/SavedScansCopy.swift | 62 ++++++ .../UI/SavedScans/SavedScansLocation.swift | 22 ++ .../UI/SavedScans/SavedScansSheet.swift | 177 ++++++++++++++++ .../UI/Screens/OnboardingScreen.swift | 8 +- ios/HouseScanUITests/SavedScansUITests.swift | 199 ++++++++++++++++++ ios/README.md | 3 +- 10 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 ios/HouseScan/UI/SavedScans/ActivitySheet.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScanRow.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansButton.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansCopy.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansLocation.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansSheet.swift create mode 100644 ios/HouseScanUITests/SavedScansUITests.swift diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift index b081627b1..77df294d3 100644 --- a/ios/HouseScan/Runtime/KeyframeStore.swift +++ b/ios/HouseScan/Runtime/KeyframeStore.swift @@ -57,13 +57,18 @@ final class KeyframeStore { /// listed here, before a newer store can exist, and deleted later off the main actor /// (`ScanFolderCleanup`), so a deletion that runs late can't take a newer scan's folder. init() { - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let scans = caches.appending(path: "Scans", directoryHint: .isDirectory) + let scans = Self.scansRoot directory = scans.appending(path: UUID().uuidString, directoryHint: .isDirectory) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent)) } + /// Caches/Scans: one folder per scan. Saved scans lists the completed ones here. + nonisolated static var scansRoot: URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appending(path: "Scans", directoryHint: .isDirectory) + } + func nextKeyframeIndex() -> Int { defer { nextIndex += 1 } return nextIndex diff --git a/ios/HouseScan/UI/SavedScans/ActivitySheet.swift b/ios/HouseScan/UI/SavedScans/ActivitySheet.swift new file mode 100644 index 000000000..70ec65a64 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/ActivitySheet.swift @@ -0,0 +1,23 @@ +import SwiftUI +import UIKit + +/// The system share sheet for one file, reporting when it is done with it. +/// +/// `ShareLink` needs its file to exist before it is tapped and never says when the sheet lets go +/// of it. Saved scans makes its copy only after the tap, and deletes the copy once the sheet +/// finishes, so it needs `UIActivityViewController`'s completion. +struct ActivitySheet: UIViewControllerRepresentable { + let file: URL + /// Called once the share completes or is cancelled; the file is no longer read after this. + let onFinish: @MainActor () -> Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: [file], applicationActivities: nil) + // UIKit calls this on the main thread; the hop keeps it correct whatever the handler's + // declared isolation. + controller.completionWithItemsHandler = { _, _, _, _ in Task { @MainActor in onFinish() } } + return controller + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScanRow.swift b/ios/HouseScan/UI/SavedScans/SavedScanRow.swift new file mode 100644 index 000000000..88dabc35a --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScanRow.swift @@ -0,0 +1,97 @@ +import HouseScanKit +import SwiftUI + +/// One saved scan: when its bundle was saved, its size, a Practice tag when the stamp says so, and +/// its Share button. Nothing in the row reads as a result or a place on a wall. +struct SavedScanRow: View { + let scan: SavedScan + /// This scan's copy is being made for the share sheet. + let isPreparing: Bool + /// Some scan's copy is being made; Share waits for it. + let isBusy: Bool + let onShare: () -> Void + + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) + : AnyLayout(HStackLayout(alignment: .center, spacing: 14)) + layout { + HStack(alignment: .top, spacing: 14) { + Image(systemName: scan.practice == true ? "wrench.and.screwdriver.fill" : "doc.zipper") + .font(.title3.weight(.semibold)) + .foregroundStyle(scan.practice == true ? Palette.ink : Palette.signal) + .frame(width: 44, height: 44) + .background(scan.practice == true ? Palette.caution : Palette.signal.opacity(0.1), in: .circle) + .accessibilityHidden(true) + details + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + shareButton + } + .padding(.vertical, 6) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScan.\(scan.id)") + } + + private var details: some View { + VStack(alignment: .leading, spacing: 4) { + Text(scan.savedAt.map { SavedScansCopy.saved($0) } ?? SavedScansCopy.unknownTime) + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.ink) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + if scan.practice == true { + Text(SavedScansCopy.practice) + .font(Typeface.caption) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Palette.caution, in: .capsule) + .accessibilityIdentifier("savedScan.practice") + } + Text(scan.byteCount, format: .byteCount(style: .file)) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .monospacedDigit() + } + if scan.practice == true { + Text(SavedScansCopy.practiceDetail) + .font(.footnote) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// The quiet button's look (`QuietButtonStyle`) at row size: a tinted capsule, 44 pt tall. + private var shareButton: some View { + Button(action: onShare) { + HStack(spacing: 6) { + if isPreparing { + ProgressView() + .controlSize(.small) + .tint(Palette.signalText) + } else { + Image(systemName: "square.and.arrow.up") + } + Text(SavedScansCopy.share) + } + .font(Typeface.caption) + .foregroundStyle(Palette.signalText) + .padding(.horizontal, 16) + .frame(minHeight: Metrics.minTarget) + .background(Palette.signal.opacity(0.1), in: .capsule) + .contentShape(.capsule) + .opacity(isBusy && !isPreparing ? 0.45 : 1) + } + .buttonStyle(PressableStyle()) + .disabled(isBusy) + .accessibilityLabel(SavedScansCopy.share) + .accessibilityHint("Opens the share sheet for this scan") + .accessibilityIdentifier("action.shareSavedScan") + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansButton.swift b/ios/HouseScan/UI/SavedScans/SavedScansButton.swift new file mode 100644 index 000000000..2d3b33b90 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansButton.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// The way into Saved scans, in the onboarding's top bar. Quiet like the Skip beside it: the +/// onboarding's one prominent action stays starting a new scan. +/// +/// The button only asks for the sheet; the screen holds it (`savedScansSheet(isPresented:)`). The +/// top bar swaps layouts at accessibility text sizes, which makes a new button, and a sheet owned +/// by the button closed whenever the text size changed under it. +struct SavedScansButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(SavedScansCopy.entry, systemImage: "tray.full") + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.signalText) + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityIdentifier("action.savedScans") + } +} + +extension View { + /// Saved scans, presented from a view that stays put while the text size changes. + func savedScansSheet(isPresented: Binding) -> some View { + sheet(isPresented: isPresented) { + SavedScansSheet(catalog: SavedScansLocation.catalog(), staging: SavedScansLocation.staging) + } + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift new file mode 100644 index 000000000..99700441a --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift @@ -0,0 +1,62 @@ +import Foundation +import HouseScanKit + +/// The words of Saved scans. A saved scan can be shared and nothing else: none of these lines may +/// suggest it can be reopened, continued or rechecked. +enum SavedScansCopy { + static let title = "Saved scans" + static let entry = "Saved scans" + + /// How many completed scans the phone keeps, in words, from the cleanup's own count. + static var kept: String { + let count = ScanFolderCleanup.defaultKeepCompleted + return count == 1 ? "your most recent finished scan" : "your \(count) most recent finished scans" + } + + static var intro: String { + "This phone keeps \(kept). You can send one to the House Scan team. A saved scan can't be reopened or continued." + } + + static let shareFooter = "Sharing sends the scan's photos and measurements. Nothing leaves your phone until you choose where to send it." + + static let emptyTitle = "No saved scans" + static var emptyDetail: String { + "When you finish a scan, it's saved here so you can share it later. This phone keeps \(kept)." + } + + static let share = "Share" + static let practice = "Practice" + /// Under a practice scan: a drawn sample stood in for the meter, so it says nothing about a + /// real wall. + static let practiceDetail = "Sample meter, not a real wall" + static let unknownTime = "Saved time unknown" + + static func saved(_ date: Date, now: Date = .now, calendar: Calendar = .current) -> String { + let time = date.formatted(date: .omitted, time: .shortened) + if calendar.isDate(date, inSameDayAs: now) { return "Saved today at \(time)" } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(date, inSameDayAs: yesterday) { + return "Saved yesterday at \(time)" + } + let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now) + let day = sameYear + ? date.formatted(.dateTime.month(.abbreviated).day()) + : date.formatted(.dateTime.month(.abbreviated).day().year()) + return "Saved \(day) at \(time)" + } + + static func problemTitle(_ error: SavedScanShareError) -> String { + switch error { + case .missing: "This scan is no longer on this phone" + case .damaged: "This scan can't be shared" + case .copyFailed: "Couldn't get the scan ready" + } + } + + static func problemDetail(_ error: SavedScanShareError) -> String { + switch error { + case .missing: "A newer scan replaced it, or the phone cleared space. The list now shows what's left." + case .damaged: "Its file was not saved completely. The list now shows the scans that can be shared." + case .copyFailed: "Your phone may be low on storage. Free up some space, then try again." + } + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift new file mode 100644 index 000000000..c86fa965b --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift @@ -0,0 +1,22 @@ +import Foundation +import HouseScanKit + +/// Where Saved scans reads scans from and stages the copies it shares. +enum SavedScansLocation { + /// The scan store's folder (`KeyframeStore.scansRoot`). Debug builds take + /// `-savedScansRoot ` instead, so a UI test can list synthetic scans it wrote; the + /// argument only changes which folder is listed, never what the store writes or deletes. + static func catalog(arguments: [String] = ProcessInfo.processInfo.arguments) -> SavedScanCatalog { + #if DEBUG + if let index = arguments.firstIndex(of: "-savedScansRoot"), index + 1 < arguments.count { + return SavedScanCatalog(root: URL(fileURLWithPath: arguments[index + 1], isDirectory: true)) + } + #endif + return SavedScanCatalog(root: KeyframeStore.scansRoot) + } + + /// In the app's temporary folder, apart from the scan folders, so the store's cleanup never + /// sees a copy and a copy never outlives the system's own tmp purge. + static let staging = SavedScanStaging( + root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory)) +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift new file mode 100644 index 000000000..ae6199a5e --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -0,0 +1,177 @@ +import HouseScanKit +import SwiftUI + +/// The completed scans still on the phone, each with a Share button. Opened from the onboarding. +/// +/// Export only: a saved scan is not reopened, its result is not shown, and nothing is sent until +/// the homeowner taps Share and then picks a destination in the system share sheet. Share copies +/// the bundle out of the scan folder first (`SavedScanStaging`), so a cleanup can't delete it +/// while the sheet reads it, and deletes the copy when the sheet closes. +struct SavedScansSheet: View { + let catalog: SavedScanCatalog + let staging: SavedScanStaging + + /// Nil until the first read finishes, so an empty list is never shown before it is known. + @State private var scans: [SavedScan]? + @State private var preparing: SavedScan.ID? + @State private var sharing: StagedCopy? + /// The copy being shared, kept past `sharing` going nil so `finishSharing` can delete it. + @State private var lastShared: URL? + @State private var problem: SavedScanShareError? + @State private var showsProblem = false + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + struct StagedCopy: Identifiable { + let url: URL + var id: URL { url } + } + + var body: some View { + NavigationStack { + content + .navigationTitle(SavedScansCopy.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .disabled(preparing != nil) + .accessibilityIdentifier("action.closeSavedScans") + } + } + } + // A copy being made belongs to the share sheet that follows it; closing now would leave + // the copy with no sheet to delete it. Making it takes a moment (a clone on APFS). + .interactiveDismissDisabled(preparing != nil) + .presentationDetents([.medium, .large]) + .task { await open() } + .sheet(item: $sharing, onDismiss: finishSharing) { copy in + ActivitySheet(file: copy.url) { sharing = nil } + .presentationDetents([.medium, .large]) + .ignoresSafeArea() + } + .alert( + problem.map(SavedScansCopy.problemTitle) ?? "", + isPresented: $showsProblem, + presenting: problem + ) { _ in + Button("OK") {} + } message: { error in + Text(SavedScansCopy.problemDetail(error)) + } + .accessibilityIdentifier("savedScans") + } + + @ViewBuilder + private var content: some View { + if let scans { + if scans.isEmpty { + // Not ContentUnavailableView: it doesn't scroll, so at large text sizes in the + // half-height sheet its description was cut off. + ScrollView { + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(Palette.muted) + .accessibilityHidden(true) + Text(SavedScansCopy.emptyTitle) + .font(Typeface.sectionTitle) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + Text(SavedScansCopy.emptyDetail) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 32) + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScans.empty") + } + .scrollBounceBehavior(.basedOnSize) + } else { + List { + Section { + ForEach(scans) { scan in + SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { + share(scan) + } + } + } header: { + Text(SavedScansCopy.intro) + .textCase(nil) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .padding(.bottom, 4) + .accessibilityIdentifier("savedScans.intro") + } footer: { + Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .accessibilityIdentifier("savedScans.privacy") + } + } + .listStyle(.insetGrouped) + } + } else { + // A list read takes milliseconds; a spinner would only flash. + Color.clear + } + } + + /// No share sheet is open while this sheet opens, so copies a quit left behind go first. + private func open() async { + let catalog = catalog, staging = staging + let found = await Task.detached(priority: .userInitiated) { () -> [SavedScan] in + staging.removeAll() + return catalog.scans() + }.value + scans = found + } + + private func reload() async { + let catalog = catalog + let found = await Task.detached(priority: .userInitiated) { catalog.scans() }.value + withAnimation(reduceMotion ? nil : Motion.settle) { scans = found } + } + + private func share(_ scan: SavedScan) { + guard preparing == nil else { return } + preparing = scan.id + let staging = staging + Task { + let staged = await Task.detached(priority: .userInitiated) { () -> Result in + do throws(SavedScanShareError) { + return .success(try staging.stage(scan)) + } catch { + return .failure(error) + } + }.value + preparing = nil + switch staged { + case .success(let url): + lastShared = url + sharing = StagedCopy(url: url) + case .failure(let error): + problem = error + showsProblem = true + // A missing or damaged scan drops out of the list; a failed copy leaves it to retry. + if error != .copyFailed { await reload() } + } + } + } + + /// The share sheet is closed, by finishing, cancelling or a swipe: its copy goes. + private func finishSharing() { + guard let copy = lastShared else { return } + lastShared = nil + let staging = staging + Task.detached(priority: .utility) { staging.remove(copy) } + } + +} diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 5a935c8f0..bbf45605a 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -7,6 +7,7 @@ struct OnboardingScreen: View { let actions: any ScanActions @State private var page = 0 + @State private var showsSavedScans = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize @@ -49,13 +50,16 @@ struct OnboardingScreen: View { Group { if typeSize.isAccessibilitySize { // The replay badge, Practice label and Skip squeezed the label into - // single syllables at AX5. Give Practice its own full-width row. + // single syllables at AX5. Give Practice its own full-width row, and Saved + // scans too. VStack(alignment: .leading, spacing: 4) { HStack { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) Spacer() skipButton } + SavedScansButton { showsSavedScans = true } + .frame(maxWidth: .infinity, alignment: .leading) DeveloperOptionsButton() .frame(maxWidth: .infinity, alignment: .leading) } @@ -64,6 +68,7 @@ struct OnboardingScreen: View { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) DeveloperOptionsButton() Spacer() + SavedScansButton { showsSavedScans = true } skipButton } } @@ -122,6 +127,7 @@ struct OnboardingScreen: View { .accessibilityIdentifier("onboarding.footer") } .background(Palette.canvas.ignoresSafeArea()) + .savedScansSheet(isPresented: $showsSavedScans) } @ViewBuilder diff --git a/ios/HouseScanUITests/SavedScansUITests.swift b/ios/HouseScanUITests/SavedScansUITests.swift new file mode 100644 index 000000000..fffd1ee9f --- /dev/null +++ b/ios/HouseScanUITests/SavedScansUITests.swift @@ -0,0 +1,199 @@ +import XCTest + +/// Saved scans from the onboarding, on synthetic scan folders the test writes and the app lists +/// through `-savedScansRoot` (Debug builds only). A completed scan is listed with its saved time +/// and a Practice tag only where its stamp says practice; an unfinished folder is not listed. +/// Share opens the system share sheet; a scan deleted since the list was made gets an error and +/// drops out; the list survives a relaunch; and a new scan still starts. +final class SavedScansUITests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + continueAfterFailure = false + root = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-saved-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + @MainActor + func testASavedScanSharesAfterARelaunchAndAMissingOneSaysSo() throws { + try scan("practice", minutesAgo: 5, stamp: #"{"practice": true}"#) + // A corrupt stamp says nothing: the scan is listed, untagged. + try scan("real", minutesAgo: 30, stamp: "{not json") + try scan("unfinished", minutesAgo: nil) + + let app = launch() + openSavedScans(app) + let practice = row(app, "practice") + let real = row(app, "real") + XCTAssertTrue(practice.waitForExistence(timeout: 10), "the practice scan is not listed") + XCTAssertTrue(real.exists, "the real scan is not listed") + XCTAssertFalse(row(app, "unfinished").exists, "a scan without a bundle is listed") + XCTAssertTrue(practice.descendants(matching: .any)["savedScan.practice"].exists, "the practice scan has no Practice tag") + XCTAssertFalse(real.descendants(matching: .any)["savedScan.practice"].exists, "a scan without a readable stamp is tagged Practice") + attach(app, "savedScans-list") + // The audit's text-size check changes the onboarding's top bar layout; the sheet must + // stay open through it (it closed when the button owned it). + try audit(app) + + // Share opens the system sheet, and closing it returns to the list. + tapWhenReady(real.buttons["action.shareSavedScan"]) + let sheet = app.otherElements["ActivityListView"] + XCTAssertTrue(sheet.waitForExistence(timeout: 15), "the share sheet never appeared") + attach(app, "savedScans-shareSheet") + if app.buttons["Close"].waitForExistence(timeout: 3) { + app.buttons["Close"].tap() + } else { + sheet.swipeDown(velocity: .fast) + } + XCTAssertTrue(sheet.waitForNonExistence(timeout: 10), "the share sheet did not close") + + // The practice scan's folder goes after the list was made, as a cleanup would take it. + try FileManager.default.removeItem(at: root.appending(path: "practice")) + tapWhenReady(practice.buttons["action.shareSavedScan"]) + let alert = app.alerts["This scan is no longer on this phone"] + XCTAssertTrue(alert.waitForExistence(timeout: 10), "a missing scan gave no error") + attach(app, "savedScans-missing") + alert.buttons["OK"].tap() + XCTAssertTrue(practice.waitForNonExistence(timeout: 10), "the missing scan stayed in the list") + XCTAssertTrue(real.exists) + + // A relaunch lists the completed scan again. + app.terminate() + app.launch() + openSavedScans(app) + XCTAssertTrue(row(app, "real").waitForExistence(timeout: 10), "the saved scan is gone after a relaunch") + tapWhenReady(app.buttons["action.closeSavedScans"]) + + // A new scan starts as before. + if app.buttons["action.onboardingSkip"].waitForExistence(timeout: 5) { tapWhenReady(app.buttons["action.onboardingSkip"]) } + tapWhenReady(app.buttons["action.finishOnboarding"]) + XCTAssertTrue(app.descendants(matching: .any)["screen.findMeter"].waitForExistence(timeout: 15), "a new scan did not start") + } + + @MainActor + func testNoSavedScansSaysWhereTheyComeFrom() throws { + try scan("unfinished", minutesAgo: nil) + let app = launch() + openSavedScans(app) + let empty = app.descendants(matching: .any)["savedScans.empty"] + XCTAssertTrue(empty.waitForExistence(timeout: 10), "no empty state") + XCTAssertFalse(app.buttons["action.shareSavedScan"].exists) + attach(app, "savedScans-empty") + try audit(app) + } + + // MARK: Helpers + + @MainActor + private func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-savedScansRoot", root.path, "-replay", FullFlowUITests.fixture, "-sampleResult", "-practiceMeter", "NO"] + app.launch() + XCTAssertTrue(app.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 30)) + return app + } + + @MainActor + private func openSavedScans(_ app: XCUIApplication) { + tapWhenReady(app.buttons["action.savedScans"]) + XCTAssertTrue(app.descendants(matching: .any)["savedScans"].waitForExistence(timeout: 10), "Saved scans did not open") + } + + @MainActor + private func row(_ app: XCUIApplication, _ id: String) -> XCUIElement { + app.descendants(matching: .any)["savedScan.\(id)"] + } + + /// Waits for the element to take a tap before tapping it, so a sheet still sliding in + /// doesn't swallow the tap. + @MainActor + private func tapWhenReady(_ element: XCUIElement, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(element.waitForExistence(timeout: 10), "\(element) never appeared", file: file, line: line) + let hittable = expectation(for: NSPredicate(format: "isHittable == true"), evaluatedWith: element) + wait(for: [hittable], timeout: 10) + element.tap() + } + + /// The shared audit (`AccessibilityAudit`): an issue fails only when it shows in two passes. + @MainActor + private func audit(_ app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line) throws { + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 1) } + for unread in outcome.unread { add(XCTAttachment(string: unread)) } + XCTAssertTrue(outcome.persistent.isEmpty, outcome.persistent.map(\.finding.message).joined(separator: "\n"), file: file, line: line) + } + + @MainActor + private func attach(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + /// A scan folder with a keyframe and, unless `minutesAgo` is nil, a bundle saved that long + /// ago: a synthetic packet whose first entry is manifest.json, as the app writes it. + private func scan(_ name: String, minutesAgo: Double?, stamp: String? = nil) throws { + let folder = root.appending(path: name) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) + if let stamp { try Data(stamp.utf8).write(to: folder.appending(path: "scan-stamp.json")) } + guard let minutesAgo else { return } + let bundle = folder.appending(path: "scan.zip") + try StoredZip.archive([ + ("manifest.json", Data(#"{"version":"1.1"}"#.utf8)), + ("photos/p00001.jpg", Data(repeating: 0xAB, count: 2048)), + ]).write(to: bundle) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: bundle.path) + } +} + +/// A stored (uncompressed) zip, enough for a synthetic packet. The UI tests don't link +/// HouseScanKit, whose `ZipWriter` the app uses. +private enum StoredZip { + static func archive(_ entries: [(name: String, data: Data)]) -> Data { + var out = Data() + var central = Data() + for entry in entries { + let name = Data(entry.name.utf8) + let crc = crc32(entry.data) + let offset = UInt32(out.count) + out.append(le: UInt32(0x0403_4B50)); out.append(le: UInt16(20)); out.append(le: UInt16(0)); out.append(le: UInt16(0)) + out.append(le: UInt16(0)); out.append(le: UInt16(0x21)) + out.append(le: crc); out.append(le: UInt32(entry.data.count)); out.append(le: UInt32(entry.data.count)) + out.append(le: UInt16(name.count)); out.append(le: UInt16(0)) + out.append(name); out.append(entry.data) + + central.append(le: UInt32(0x0201_4B50)); central.append(le: UInt16(20)); central.append(le: UInt16(20)) + central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt16(0x21)) + central.append(le: crc); central.append(le: UInt32(entry.data.count)); central.append(le: UInt32(entry.data.count)) + central.append(le: UInt16(name.count)); central.append(le: UInt16(0)); central.append(le: UInt16(0)) + central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt32(0)); central.append(le: offset) + central.append(name) + } + let directoryOffset = UInt32(out.count) + out.append(central) + out.append(le: UInt32(0x0605_4B50)); out.append(le: UInt16(0)); out.append(le: UInt16(0)) + out.append(le: UInt16(entries.count)); out.append(le: UInt16(entries.count)) + out.append(le: UInt32(central.count)); out.append(le: directoryOffset); out.append(le: UInt16(0)) + return out + } + + static func crc32(_ data: Data) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { crc = (crc & 1) != 0 ? 0xEDB8_8320 ^ (crc >> 1) : crc >> 1 } + } + return crc ^ 0xFFFF_FFFF + } +} + +private extension Data { + mutating func append(le value: T) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } +} diff --git a/ios/README.md b/ios/README.md index f8323e881..3678de848 100644 --- a/ios/README.md +++ b/ios/README.md @@ -22,6 +22,7 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotGate ` | Used by the UI tests. Before leaving a screen, the app waits for a file named after that phase in this folder, and while it waits it leaves `.held` there, so a test can audit the screen once the autopilot has finished with it. Once the result shows, the autopilot writes the scan's scene.json there. | | `-simulateAppStore` | Runs as an App Store install would: no developer options, and practice meter stays off whatever the stored switch says. `PracticeMeterUITests` uses it. | | `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | +| `-savedScansRoot ` | Debug builds only. Saved scans lists the scan folders in this folder instead of the app's Caches/Scans. It only changes what is listed; the scan store still writes and cleans up Caches/Scans. `SavedScansUITests` passes it with synthetic scans. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoGroundQuestion` opens the feature review with the ground question showing, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | **Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. @@ -95,7 +96,7 @@ Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabili **Logs.** Open Console.app on the Mac, select the iPhone, press Start and search for `subsystem:dev.housescanning.housescan`. Every screen change logs as `STATE=`; the `engine` category records the capture's decisions, the scan bundle and upload failures. The same lines show in Xcode's console while it runs the app. -**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. +**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. After the app is closed, **Saved scans** in the first screen's top bar lists the completed scans the phone still keeps (the two most recent, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. Share there sends the same bundle. It copies the bundle to the app's tmp folder first, so a new scan's cleanup can't delete it while the share sheet reads it, and deletes the copy when the sheet closes. A saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. ## Scan bundle From aeae9dcec3ce3331eea2ddec4b300a55e21f3c84 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:09:06 -0500 Subject: [PATCH 03/15] ios: a practice scan's row says its meter was a sample, not that its wall wasn't real Practice mode draws a sample meter, and the walk scans a real wall, so "not a real wall" was false. The share copy is not assumed to be a free clone: it can copy every byte and fail on a full disk (copyFailed). Co-Authored-By: Claude Code --- ios/HouseScan/UI/SavedScans/SavedScansCopy.swift | 6 +++--- ios/HouseScan/UI/SavedScans/SavedScansSheet.swift | 2 +- .../Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift | 3 ++- ios/HouseScanUITests/SavedScansUITests.swift | 7 +++++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift index 99700441a..158394571 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift @@ -26,9 +26,9 @@ enum SavedScansCopy { static let share = "Share" static let practice = "Practice" - /// Under a practice scan: a drawn sample stood in for the meter, so it says nothing about a - /// real wall. - static let practiceDetail = "Sample meter, not a real wall" + /// Under a practice scan: a drawn sample stood in for the meter and its close-up, so the + /// meter's place and number describe no real meter. The wall walk itself was real. + static let practiceDetail = "Practice scan with a sample meter" static let unknownTime = "Saved time unknown" static func saved(_ date: Date, now: Date = .now, calendar: Calendar = .current) -> String { diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift index ae6199a5e..1f6d7def1 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -41,7 +41,7 @@ struct SavedScansSheet: View { } } // A copy being made belongs to the share sheet that follows it; closing now would leave - // the copy with no sheet to delete it. Making it takes a moment (a clone on APFS). + // the copy with no sheet to delete it. .interactiveDismissDisabled(preparing != nil) .presentationDetents([.medium, .large]) .task { await open() } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 52b75a017..1a54fd12f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -76,7 +76,8 @@ public enum SavedScanShareError: Error, Equatable, Sendable { /// /// The share sheet reads the file it is given for as long as it is open, and a scan's own bundle /// can be deleted under it by a new scan's cleanup. A copy outside the scan folders can't be. -/// On APFS the copy is a clone, so it costs no space until one side changes. Only this type +/// The copy can be a full copy of the bundle's bytes, so it can fail on a full disk +/// (`SavedScanShareError.copyFailed`), and is deleted as soon as the sheet closes. Only this type /// creates or deletes anything under `root`; the scan folders are never written. public struct SavedScanStaging: Sendable { public let root: URL diff --git a/ios/HouseScanUITests/SavedScansUITests.swift b/ios/HouseScanUITests/SavedScansUITests.swift index fffd1ee9f..74b8bff59 100644 --- a/ios/HouseScanUITests/SavedScansUITests.swift +++ b/ios/HouseScanUITests/SavedScansUITests.swift @@ -34,6 +34,13 @@ final class SavedScansUITests: XCTestCase { XCTAssertFalse(row(app, "unfinished").exists, "a scan without a bundle is listed") XCTAssertTrue(practice.descendants(matching: .any)["savedScan.practice"].exists, "the practice scan has no Practice tag") XCTAssertFalse(real.descendants(matching: .any)["savedScan.practice"].exists, "a scan without a readable stamp is tagged Practice") + // A practice scan walked a real wall; only its meter was a sample. + // The row's text is one combined element, so match within labels. + func says(_ element: XCUIElement, _ text: String) -> Bool { + element.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS[c] %@", text)).firstMatch.exists + } + XCTAssertTrue(says(practice, "Practice scan with a sample meter"), "the practice scan doesn't say its meter was a sample") + XCTAssertFalse(says(app, "real wall"), "a row says a scan wasn't of a real wall") attach(app, "savedScans-list") // The audit's text-size check changes the onboarding's top bar layout; the sheet must // stay open through it (it closed when the button owned it). From 429448d7ecc4cf398a7d56673404f7e4dfa05422 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:49:52 -0500 Subject: [PATCH 04/15] ios: Saved scans is an opaque full-height column in the app's own type, not a system list Hosted run 37051620836's audit failed the half-height sheet. The onboarding showed through its glass behind the intro and footer (contrast), the list's header and footer and the navigation bar's Done didn't fully follow Dynamic Type, and large text ran off the bottom. The sheet is now a scrolling column on the canvas, with the rows on a surface card and the title and Done in the app's fonts, like the onboarding. Co-Authored-By: Claude Code --- .../UI/SavedScans/SavedScansSheet.swift | 142 ++++++++++-------- 1 file changed, 81 insertions(+), 61 deletions(-) diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift index 1f6d7def1..864b30f8a 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -27,23 +27,30 @@ struct SavedScansSheet: View { var id: URL { url } } + // Built like the app's light screens rather than a system list in a half-height sheet. In + // that sheet the onboarding showed through the glass behind the text (contrast), the list's + // header and footer and the navigation bar's Done didn't fully follow Dynamic Type, and large + // text ran off the sheet's bottom (hosted run 37051620836). var body: some View { - NavigationStack { - content - .navigationTitle(SavedScansCopy.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - .disabled(preparing != nil) - .accessibilityIdentifier("action.closeSavedScans") - } - } + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + content + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 32) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) } + .scrollBounceBehavior(.basedOnSize) + .background(Palette.canvas.ignoresSafeArea()) + .presentationBackground(Palette.canvas) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) // A copy being made belongs to the share sheet that follows it; closing now would leave // the copy with no sheet to delete it. .interactiveDismissDisabled(preparing != nil) - .presentationDetents([.medium, .large]) .task { await open() } .sheet(item: $sharing, onDismiss: finishSharing) { copy in ActivitySheet(file: copy.url) { sharing = nil } @@ -62,66 +69,79 @@ struct SavedScansSheet: View { .accessibilityIdentifier("savedScans") } + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(SavedScansCopy.title) + .font(Typeface.instruction) + .foregroundStyle(Palette.ink) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + Button("Done") { dismiss() } + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.signalText) + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + .buttonStyle(PressableStyle()) + .disabled(preparing != nil) + .opacity(preparing != nil ? 0.45 : 1) + .accessibilityIdentifier("action.closeSavedScans") + } + } + @ViewBuilder private var content: some View { if let scans { if scans.isEmpty { - // Not ContentUnavailableView: it doesn't scroll, so at large text sizes in the - // half-height sheet its description was cut off. - ScrollView { - VStack(spacing: 12) { - Image(systemName: "tray") - .font(.system(size: 44, weight: .regular)) - .foregroundStyle(Palette.muted) - .accessibilityHidden(true) - Text(SavedScansCopy.emptyTitle) - .font(Typeface.sectionTitle) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .accessibilityAddTraits(.isHeader) - Text(SavedScansCopy.emptyDetail) - .font(Typeface.hint) - .foregroundStyle(Palette.muted) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 32) - .padding(.vertical, 40) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("savedScans.empty") + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(Palette.muted) + .accessibilityHidden(true) + Text(SavedScansCopy.emptyTitle) + .font(Typeface.sectionTitle) + .foregroundStyle(Palette.ink) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text(SavedScansCopy.emptyDetail) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } - .scrollBounceBehavior(.basedOnSize) + .padding(.horizontal, 12) + .padding(.top, 48) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScans.empty") } else { - List { - Section { - ForEach(scans) { scan in - SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { - share(scan) - } + Text(SavedScansCopy.intro) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("savedScans.intro") + VStack(spacing: 0) { + ForEach(scans) { scan in + if scan.id != scans.first?.id { + Divider().padding(.leading, 58) + } + SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { + share(scan) } - } header: { - Text(SavedScansCopy.intro) - .textCase(nil) - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Palette.muted) - .padding(.bottom, 4) - .accessibilityIdentifier("savedScans.intro") - } footer: { - Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Palette.muted) - .accessibilityIdentifier("savedScans.privacy") + .padding(.vertical, 8) } } - .listStyle(.insetGrouped) + .padding(.horizontal, 16) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") + .font(.footnote) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("savedScans.privacy") } - } else { - // A list read takes milliseconds; a spinner would only flash. - Color.clear } + // Until the first read finishes there is nothing: it takes milliseconds, and a spinner + // would only flash. } /// No share sheet is open while this sheet opens, so copies a quit left behind go first. From 769af49e6789cf29f2df15792e853c71a7522dd5 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 16:30:53 -0500 Subject: [PATCH 05/15] ios: launch deletes share copies an earlier run left, keeping any made since A share sheet open when the app is killed never reports back, so its copy stayed in tmp until Saved scans was opened again. Launch now removes the copies made before it; a share started right after launch keeps its own. Co-Authored-By: Claude Code --- ios/HouseScan/HouseScanApp.swift | 5 +++++ .../SavedScans/SavedScanCatalog.swift | 12 ++++++++++++ .../SavedScanCatalogTests.swift | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index a136ba964..fa4830ad5 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -13,6 +13,11 @@ struct HouseScanApp: App { } // Compiles the live fog's shaders in the background long before the walk needs them. .task { LiveFogSupport.shared.prepare() } + // Share copies an earlier run left behind (`SavedScanStaging.removeCopies`). + .task { + let launch = Date(), staging = SavedScansLocation.staging + await Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) }.value + } } } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 1a54fd12f..6315fc90d 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -131,4 +131,16 @@ public struct SavedScanStaging: Sendable { public func removeAll() { try? FileManager.default.removeItem(at: root) } + + /// Deletes the copies made before `date`, leaving any made since. At launch, with `date` the + /// launch time, that is every copy an earlier run left (a share sheet open when the app was + /// killed never reports back), while a share started right after launch keeps its copy. + public func removeCopies(madeBefore date: Date) { + let files = FileManager.default + guard let folders = try? files.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } + for folder in folders { + let made = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast + if made < date { try? files.removeItem(at: folder) } + } + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift index a246abb5c..1a41170b5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -241,6 +241,24 @@ import Testing #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) } + /// At launch, copies an earlier run left go, and one made since launch stays. + @Test func launchRemovesOnlyCopiesMadeBeforeIt() throws { + defer { cleanUp() } + try scan("one") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let left = try staging.stage(listed) + try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: left.deletingLastPathComponent().path) + let launch = Date(timeIntervalSinceNow: -60) + let current = try staging.stage(listed) + + staging.removeCopies(madeBefore: launch) + #expect(!files.fileExists(atPath: left.path)) + #expect(files.fileExists(atPath: current.path)) + #expect(files.fileExists(atPath: listed.archive.path)) + SavedScanStaging(root: root.appending(path: "absent")).removeCopies(madeBefore: launch) + } + // MARK: Names @Test func theShareNameSaysWhenAndWhetherPractice() { From 09ed76dd25e6c315da45a9417def5511ba8b61e6 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 16:47:01 -0500 Subject: [PATCH 06/15] ios: the launch cutoff for old share copies is taken once per process, in the app's init A window's .task runs again for each window or reappearance, and a later cutoff could cover a copy this run is still sharing. Co-Authored-By: Claude Code --- ios/HouseScan/HouseScanApp.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index fa4830ad5..db0d4f728 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -2,6 +2,15 @@ import SwiftUI @main struct HouseScanApp: App { + /// Runs once per process, before any window exists: share copies made before this moment + /// belong to an earlier run, whose share sheet can't still be reading them. Not a view's + /// `.task`, which runs again for each window or reappearance and would take a later cutoff, + /// one that could cover a copy this run is sharing. + init() { + let launch = Date(), staging = SavedScansLocation.staging + Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) } + } + var body: some Scene { WindowGroup { Group { @@ -13,11 +22,6 @@ struct HouseScanApp: App { } // Compiles the live fog's shaders in the background long before the walk needs them. .task { LiveFogSupport.shared.prepare() } - // Share copies an earlier run left behind (`SavedScanStaging.removeCopies`). - .task { - let launch = Date(), staging = SavedScansLocation.staging - await Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) }.value - } } } } From 96abec13f842f8200d39487b793bdd4c9a975f96 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:01:13 -0500 Subject: [PATCH 07/15] ios: a partial scan.zip no longer takes a kept place or gets an intact scan deleted ScanFolderCleanup counted any scan.zip as a completed scan, so a bundle cut short by a quit during its in-place write held one of the two kept places and an intact older scan was deleted instead. Completed now means PacketArchiveCheck finds a whole packet, still the same file after the check. A partial or unreadable bundle holds no place and its folder is left alone at any age: nothing proves its writer has stopped. Retention count, order, listing at construction and the current-folder guard are unchanged. Co-Authored-By: Claude Code --- .../SavedScans/SavedScanCatalog.swift | 11 +- .../Scene/ScanFolderCleanup.swift | 84 ++++++++++-- .../HouseScanKitTests/KeptScansTests.swift | 123 +++++++++++++++++- 3 files changed, 197 insertions(+), 21 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 6315fc90d..58486394b 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -2,12 +2,11 @@ import Foundation /// The completed scans a homeowner can still share, read from the scan folders on disk. /// -/// A scan counts by the same rule the cleanup keeps it by (`ScanFolderCleanup`): a folder with a -/// `scan.zip`, newest bundle first, at most the `defaultKeepCompleted` the cleanup keeps. Listing -/// the cleanup's own choice means the list never shows an older scan that the next cleanup is -/// about to delete. On top of that rule a scan is listed only when its bundle is a whole capture -/// packet (`PacketArchiveCheck`), since a scan quit while its bundle was being written leaves a -/// partial `scan.zip` behind. +/// A scan counts by the same rule the cleanup keeps it by (`ScanFolderCleanup`): a folder whose +/// `scan.zip` is a whole capture packet (`PacketArchiveCheck`), newest bundle first, at most the +/// `defaultKeepCompleted` the cleanup keeps. Listing the cleanup's own choice means the list never +/// shows an older scan that the next cleanup is about to delete. Each listed bundle is checked +/// again here, since it can change between the cleanup's reading and this one. /// /// The list is a reading of the folders at one moment, and a scan can still go after it. A new /// scan's cleanup deletes what its store listed when it was made, a little later and off the main diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 3bd3bbd3b..4a12be079 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -2,11 +2,25 @@ import Foundation /// The old scan folders a new scan's store deletes, and the ones it keeps. /// -/// Kept: the new scan's own folder, and the `keepCompleted` most recent completed scans (those -/// with a packaged bundle, `bundleName`, newest bundle first), until newer completed scans push -/// them out. Every other entry goes: scans never packaged, and older completed ones. A scan the -/// homeowner finished survives the app being quit and relaunched, which makes a new store; before, -/// every relaunch deleted it, and a field run's export was lost that way. +/// Kept: the new scan's own folder, the `keepCompleted` most recent completed scans (those whose +/// bundle, `bundleName`, is a whole capture packet, newest bundle first) until newer completed +/// scans push them out, and every folder whose bundle is there but not whole. Every other entry +/// goes: scans never packaged, and older completed ones. A scan the homeowner finished survives +/// the app being quit and relaunched, which makes a new store; before, every relaunch deleted it, +/// and a field run's export was lost that way. +/// +/// A bundle counts as completed only when `PacketArchiveCheck` finds it written to the end. The +/// zip is written in place, so a quit or crash during the write leaves a partial `scan.zip`. When +/// the file's presence alone counted, that partial bundle took one of the kept places and an +/// intact older scan was deleted to make room. +/// +/// A partial or unreadable bundle takes no kept place, and its folder is not deleted, however old +/// it is. Nothing here can tell an abandoned partial from one whose writer is still going: Start +/// over makes a new store while the previous scan's bundle may still be being written, and a +/// suspended phone can pause a write for any length of time, so the file's age proves nothing. +/// Deleting it could take the folder from under that write. The cost is that an abandoned partial +/// bundle stays on the phone. Reclaiming it needs a way to prove no writer holds it, which this +/// type does not have. /// /// The folders to delete are listed when the new scan's folder is made, before any newer scan /// can exist, and only those are deleted, whenever the deletion gets to run. Listing at deletion @@ -18,27 +32,73 @@ public struct ScanFolderCleanup: Sendable { /// The most recent completed scan, and one more: a homeowner who starts another scan still /// has the one before it. Each is a few tens of megabytes of photos. public static let defaultKeepCompleted = 2 - /// The folders beside the kept one when the cleanup was made, to delete. public let obsolete: [URL] /// The completed scans kept, newest first. public let keptCompleted: [URL] + /// Folders whose bundle is there but not whole: left alone, holding no kept place. + public let incompleteBundles: [URL] /// Lists `root` now. An unreadable root lists nothing. public init(root: URL, keeping kept: String, keepCompleted: Int = defaultKeepCompleted) { let files = FileManager.default let names = ((try? files.contentsOfDirectory(atPath: root.path)) ?? []).filter { $0 != kept }.sorted() - let completed = names.compactMap { name -> (name: String, date: Date)? in + var completed: [(name: String, date: Date)] = [] + var incomplete: [String] = [] + for name in names { + // A plain file beside the scan folders holds no bundle; it goes, as before. + if (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType == .typeRegular { continue } let bundle = root.appending(path: name).appending(path: Self.bundleName) - guard let attributes = try? files.attributesOfItem(atPath: bundle.path) else { return nil } - return (name, attributes[.modificationDate] as? Date ?? .distantPast) + switch Self.bundleState(bundle) { + case .absent: + continue + case .complete(let date): + completed.append((name, date)) + case .incomplete: + incomplete.append(name) + } } - .sorted { ($0.date, $0.name) > ($1.date, $1.name) } - let keep = Set(completed.prefix(max(0, keepCompleted)).map(\.name)) - keptCompleted = completed.prefix(max(0, keepCompleted)).map { root.appending(path: $0.name) } + completed.sort { ($0.date, $0.name) > ($1.date, $1.name) } + let keptNames = completed.prefix(max(0, keepCompleted)).map(\.name) + let keep = Set(keptNames + incomplete) + keptCompleted = keptNames.map { root.appending(path: $0) } + incompleteBundles = incomplete.map { root.appending(path: $0) } obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } } + private enum BundleState { + case absent + case complete(savedAt: Date) + case incomplete + } + + /// Only a bundle known to be missing is absent. A metadata read that fails for another reason + /// is incomplete: the folder may hold a bundle, so it is kept without a place. + /// + /// A whole bundle must still be the same file after the check. A retry rewrites `scan.zip` in + /// place (`ZipWriter.write` removes it and starts a new file), and a check that opened the old + /// file can finish on it after the new partial one has replaced it; the file number tells them + /// apart. A rewrite that starts after the second read is not caught: the folder then holds its + /// place while its bundle is partial, until the next cleanup. Closing that needs the writer to + /// replace the file atomically, which the packet code doesn't do. + private static func bundleState(_ bundle: URL) -> BundleState { + let files = FileManager.default + let before: [FileAttributeKey: Any] + do { + before = try files.attributesOfItem(atPath: bundle.path) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile { + return .absent + } catch { + return .incomplete + } + guard (try? PacketArchiveCheck.verify(bundle)) != nil, + let after = try? files.attributesOfItem(atPath: bundle.path), + let number = before[.systemFileNumber] as? UInt64, + after[.systemFileNumber] as? UInt64 == number, + after[.size] as? UInt64 == before[.size] as? UInt64 else { return .incomplete } + return .complete(savedAt: before[.modificationDate] as? Date ?? .distantPast) + } + /// Deletes the listed folders; returns each one that could not be deleted with the reason. A /// folder already gone is not an error. public func run() -> [(url: URL, error: any Error)] { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 6f164478f..4d89ebaa9 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -10,17 +10,28 @@ import Testing return root } - /// A scan folder, completed (with its bundle, written at `minutesAgo`) or not. - static func scan(_ name: String, in root: URL, bundledMinutesAgo minutesAgo: Double? = nil) throws { + /// A capture packet as the app zips it, manifest.json first. Synthetic, never a capture. + static let packet: Data = try! ZipWriter.archive([ + ZipEntry(name: "manifest.json", data: Data(#"{"version":"1.1"}"#.utf8)), + ZipEntry(name: "photos/p00001.jpg", data: Data((0..<4096).map { UInt8($0 % 251) })), + ]) + + /// A scan folder, completed (with its bundle, written at `minutesAgo`) or not. A `partial` + /// bundle is the packet cut short, as a quit during its write leaves it. + static func scan(_ name: String, in root: URL, bundledMinutesAgo minutesAgo: Double? = nil, partial: Bool = false) throws { let folder = root.appending(path: name) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) guard let minutesAgo else { return } let bundle = folder.appending(path: ScanFolderCleanup.bundleName) - try Data("zip".utf8).write(to: bundle) + try (partial ? packet.prefix(packet.count / 2) : packet).write(to: bundle) try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: bundle.path) } + static func names(_ urls: [URL]) -> [String] { + urls.map(\.lastPathComponent) + } + /// A relaunch makes a new store: the two most recent completed scans stay, with their /// bundles; an older completed scan and one never packaged go. Before, all of them went. @Test func aRelaunchKeepsTheLastCompletedScans() throws { @@ -58,6 +69,112 @@ import Testing #expect(ScanFolderCleanup(root: root, keeping: "later").keptCompleted.map(\.lastPathComponent) == ["run3", "run2"]) } + /// A partial bundle takes no kept place, however new: both intact scans stay. Its folder + /// is left alone too, however old, since nothing shows its writer has stopped. Before, + /// presence alone counted, so the newer partial kept a place and an intact scan was deleted. + @Test(arguments: [0.5, 20.0, 60.0 * 24 * 365]) + func aPartialBundleNeitherEvictsIntactScansNorIsDeleted(minutesAgo: Double) throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("intact3", in: root, bundledMinutesAgo: 120) + // Newer than every intact scan, unless it is the year-old one. + try Self.scan("partial", in: root, bundledMinutesAgo: minutesAgo, partial: true) + try Self.scan("unpackaged", in: root) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Self.names(cleanup.incompleteBundles) == ["partial"]) + #expect(Self.names(cleanup.obsolete) == ["intact3", "unpackaged"]) + #expect(cleanup.run().isEmpty) + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "partial"]) + #expect(try Data(contentsOf: root.appending(path: "partial/\(ScanFolderCleanup.bundleName)")) == Self.packet.prefix(Self.packet.count / 2)) + } + + /// A bundle that fails the check for any reason (empty, not a packet) is treated like a + /// partial one; once its write finishes it competes like any completed scan. + @Test func anUnreadableBundleIsLeftAloneUntilItIsWhole() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + let empty = root.appending(path: "empty") + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + try Data().write(to: empty.appending(path: ScanFolderCleanup.bundleName)) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.incompleteBundles) == ["empty"]) + #expect(cleanup.obsolete.isEmpty) + + try Self.packet.write(to: empty.appending(path: ScanFolderCleanup.bundleName)) + let later = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(later.keptCompleted) == ["empty", "intact1"]) + #expect(Self.names(later.obsolete) == ["intact2"]) + } + + /// A folder whose bundle can't be read, or whose bundle is a link or a folder, holds no place + /// and is not deleted. + @Test func bundlesThatCannotBeJudgedAreLeftAlone() throws { + let root = try Self.makeRoot() + let files = FileManager.default + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("locked", in: root, bundledMinutesAgo: 1) + let locked = root.appending(path: "locked") + try files.setAttributes([.posixPermissions: 0o000], ofItemAtPath: locked.path) + defer { + try? files.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) + try? files.removeItem(at: root) + } + try Self.scan("linked", in: root) + try files.createSymbolicLink(at: root.appending(path: "linked/\(ScanFolderCleanup.bundleName)"), + withDestinationURL: root.appending(path: "intact1/\(ScanFolderCleanup.bundleName)")) + try Self.scan("folderBundle", in: root) + try files.createDirectory(at: root.appending(path: "folderBundle/\(ScanFolderCleanup.bundleName)"), withIntermediateDirectories: true) + + try Data("stray".utf8).write(to: root.appending(path: "stray.txt")) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Set(Self.names(cleanup.incompleteBundles)) == ["locked", "linked", "folderBundle"]) + // A stray file is not a scan folder and goes, as before. + #expect(Self.names(cleanup.obsolete) == ["stray.txt"]) + #expect(cleanup.run().isEmpty) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle"]) + } + + /// The scan in use is never judged, whatever its bundle; a folder made after the listing is + /// never deleted by it. + @Test func theScanInUseAndLaterFoldersAreSafe() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("current", in: root, bundledMinutesAgo: 60, partial: true) + try Self.scan("old", in: root) + let cleanup = ScanFolderCleanup(root: root, keeping: "current") + #expect(!(cleanup.obsolete + cleanup.keptCompleted + cleanup.incompleteBundles).map(\.lastPathComponent).contains("current")) + #expect(Self.names(cleanup.obsolete) == ["old"]) + + try Self.scan("later", in: root, bundledMinutesAgo: 1) + try Self.scan("laterPartial", in: root, bundledMinutesAgo: 60, partial: true) + #expect(cleanup.run().isEmpty) + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["current", "later", "laterPartial"]) + } + + /// Bundles saved at the same moment keep the order by name, newest name first, as before. + @Test func equalSaveTimesFallBackToNameOrder() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let bundled = Date(timeIntervalSinceNow: -30 * 60) + for name in ["a", "b", "c"] { + try Self.scan(name, in: root, bundledMinutesAgo: 30) + try FileManager.default.setAttributes([.modificationDate: bundled], ofItemAtPath: root.appending(path: "\(name)/\(ScanFolderCleanup.bundleName)").path) + } + let cleanup = ScanFolderCleanup(root: root, keeping: "new") + #expect(Self.names(cleanup.keptCompleted) == ["c", "b"]) + #expect(Self.names(cleanup.obsolete) == ["a"]) + } + /// The stamp carries the build, the server and the answer's rules, with nulls where there /// are none, so the file always has the same shape. @Test func theStampSaysWhatBuiltAndJudgedTheScan() throws { From 897ef2a615ada4852d1162da08c3f5d32420c253 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:05:24 -0500 Subject: [PATCH 08/15] ios: bundleName's comment says a completed scan needs a whole packet, not just the file Co-Authored-By: Claude Code --- .../Sources/HouseScanKit/Scene/ScanFolderCleanup.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 4a12be079..a0b8cc787 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -27,7 +27,8 @@ import Foundation /// time instead let a cleanup that ran late delete a scan started after it: store A is made, /// Start over makes B, and A's cleanup then found B in the listing and deleted the scan in use. public struct ScanFolderCleanup: Sendable { - /// The file whose presence marks a completed scan: the bundle Share scan offers. + /// The bundle Share scan offers. A scan is completed when this file is a whole capture packet + /// (`PacketArchiveCheck`); the file being there is not enough. public static let bundleName = "scan.zip" /// The most recent completed scan, and one more: a homeowner who starts another scan still /// has the one before it. Each is a few tens of megabytes of photos. From 362700f176941c53473bb7c4fcf45ebef1830c19 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:52:31 -0500 Subject: [PATCH 09/15] ios: a test replaces scan.zip during the archive check and expects it not complete The file-number and size comparison had no test that swaps the file mid-check, so it could stop catching a retry's rewrite unnoticed. The check is injectable within the module for that test. Co-Authored-By: Claude Code --- .../Scene/ScanFolderCleanup.swift | 7 +++--- .../HouseScanKitTests/KeptScansTests.swift | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index a0b8cc787..e54028c78 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -67,7 +67,7 @@ public struct ScanFolderCleanup: Sendable { obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } } - private enum BundleState { + enum BundleState: Equatable { case absent case complete(savedAt: Date) case incomplete @@ -82,7 +82,8 @@ public struct ScanFolderCleanup: Sendable { /// apart. A rewrite that starts after the second read is not caught: the folder then holds its /// place while its bundle is partial, until the next cleanup. Closing that needs the writer to /// replace the file atomically, which the packet code doesn't do. - private static func bundleState(_ bundle: URL) -> BundleState { + /// `verify` is `PacketArchiveCheck.verify`; tests pass one that rewrites the file mid-check. + static func bundleState(_ bundle: URL, verify: (URL) throws -> Int64 = PacketArchiveCheck.verify) -> BundleState { let files = FileManager.default let before: [FileAttributeKey: Any] do { @@ -92,7 +93,7 @@ public struct ScanFolderCleanup: Sendable { } catch { return .incomplete } - guard (try? PacketArchiveCheck.verify(bundle)) != nil, + guard (try? verify(bundle)) != nil, let after = try? files.attributesOfItem(atPath: bundle.path), let number = before[.systemFileNumber] as? UInt64, after[.systemFileNumber] as? UInt64 == number, diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 4d89ebaa9..ea792ce85 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -144,6 +144,30 @@ import Testing #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle"]) } + /// A retry rewrites scan.zip in place while the check may still be reading the old file. A + /// check that passes on the old file must not count the new, partial one as complete. + @Test func aBundleReplacedDuringTheCheckIsNotComplete() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("rewritten", in: root, bundledMinutesAgo: 5) + let bundle = root.appending(path: "rewritten/\(ScanFolderCleanup.bundleName)") + + let state = ScanFolderCleanup.bundleState(bundle) { url in + let size = try PacketArchiveCheck.verify(url) + // As ZipWriter.write does: remove the file, then start a new one. + try FileManager.default.removeItem(at: url) + try Self.packet.prefix(Self.packet.count / 2).write(to: url) + return size + } + #expect(state == .incomplete) + + // Left alone, the same bundle is complete. + try Self.packet.write(to: bundle) + let settled = ScanFolderCleanup.bundleState(bundle) + #expect(settled != .incomplete && settled != .absent) + #expect(ScanFolderCleanup.bundleState(root.appending(path: "none/\(ScanFolderCleanup.bundleName)")) == .absent) + } + /// The scan in use is never judged, whatever its bundle; a folder made after the listing is /// never deleted by it. @Test func theScanInUseAndLaterFoldersAreSafe() throws { From 95eee1ed295751e1de28d815fd6b1af151dc7f40 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:55:56 -0500 Subject: [PATCH 10/15] ios: each app run stages share copies in its own folder; launch deletes the others A launch-time cutoff depended on the clock: set back between runs, an earlier run's copy looked newer and stayed. Each run now stages under a session folder of its own, and launch, or opening Saved scans, deletes the other sessions' folders, so no copy a share in progress is reading can match. removeAll is gone with its last caller. Co-Authored-By: Claude Code --- ios/HouseScan/HouseScanApp.swift | 11 +++--- .../UI/SavedScans/SavedScansLocation.swift | 6 ++-- .../UI/SavedScans/SavedScansSheet.swift | 5 +-- .../SavedScans/SavedScanCatalog.swift | 35 ++++++++++--------- .../SavedScanCatalogTests.swift | 34 +++++++++++------- 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index db0d4f728..1818e887c 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -2,13 +2,12 @@ import SwiftUI @main struct HouseScanApp: App { - /// Runs once per process, before any window exists: share copies made before this moment - /// belong to an earlier run, whose share sheet can't still be reading them. Not a view's - /// `.task`, which runs again for each window or reappearance and would take a later cutoff, - /// one that could cover a copy this run is sharing. + /// 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 launch = Date(), staging = SavedScansLocation.staging - Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) } + let staging = SavedScansLocation.staging + Task.detached(priority: .utility) { staging.removeOtherSessions() } } var body: some Scene { diff --git a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift index c86fa965b..508ba5bf9 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift @@ -16,7 +16,9 @@ enum SavedScansLocation { } /// In the app's temporary folder, apart from the scan folders, so the store's cleanup never - /// sees a copy and a copy never outlives the system's own tmp purge. + /// sees a copy and a copy never outlives the system's own tmp purge. One session per process: + /// a static is made once. static let staging = SavedScanStaging( - root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory)) + root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory), + session: UUID().uuidString) } diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift index 864b30f8a..c8add1fb3 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -144,11 +144,12 @@ struct SavedScansSheet: View { // would only flash. } - /// No share sheet is open while this sheet opens, so copies a quit left behind go first. + /// Copies earlier runs left go too, in case launch's pass hasn't run yet; this run's own copy + /// is deleted when its share sheet closes, so nothing here can touch a share in progress. private func open() async { let catalog = catalog, staging = staging let found = await Task.detached(priority: .userInitiated) { () -> [SavedScan] in - staging.removeAll() + staging.removeOtherSessions() return catalog.scans() }.value scans = found diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 6315fc90d..422ae9a87 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -81,9 +81,17 @@ public enum SavedScanShareError: Error, Equatable, Sendable { /// creates or deletes anything under `root`; the scan folders are never written. public struct SavedScanStaging: Sendable { public let root: URL + /// This app run's folder under `root`. Each run stages into its own, so a launch can delete + /// the copies earlier runs left without judging by time, which a clock change would upset. + public let session: String - public init(root: URL) { + public init(root: URL, session: String = UUID().uuidString) { self.root = root + self.session = session + } + + private var sessionRoot: URL { + root.appending(path: session, directoryHint: .isDirectory) } /// A whole copy of `scan`'s bundle under `root`, named `scan.shareFileName()`. @@ -99,7 +107,7 @@ public struct SavedScanStaging: Sendable { } catch { throw failure(error) } - let folder = root.appending(path: UUID().uuidString, directoryHint: .isDirectory) + let folder = sessionRoot.appending(path: UUID().uuidString, directoryHint: .isDirectory) let copy = folder.appending(path: scan.shareFileName()) do { try files.createDirectory(at: folder, withIntermediateDirectories: true) @@ -122,25 +130,18 @@ public struct SavedScanStaging: Sendable { /// is left alone. public func remove(_ copy: URL) { let folder = copy.deletingLastPathComponent().standardizedFileURL - guard folder.deletingLastPathComponent().path == root.standardizedFileURL.path else { return } + guard folder.deletingLastPathComponent().path == sessionRoot.standardizedFileURL.path else { return } try? FileManager.default.removeItem(at: folder) } - /// Deletes every copy, for when no share sheet is open: copies a share left when the app was - /// quit with the sheet up. - public func removeAll() { - try? FileManager.default.removeItem(at: root) - } - - /// Deletes the copies made before `date`, leaving any made since. At launch, with `date` the - /// launch time, that is every copy an earlier run left (a share sheet open when the app was - /// killed never reports back), while a share started right after launch keeps its copy. - public func removeCopies(madeBefore date: Date) { + /// Deletes the copies every other app run made, leaving this run's. At launch that is every + /// copy an earlier run left (a share sheet open when the app was killed never reports back), + /// while a share this run starts keeps its copy. + public func removeOtherSessions() { let files = FileManager.default - guard let folders = try? files.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } - for folder in folders { - let made = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast - if made < date { try? files.removeItem(at: folder) } + guard let sessions = try? files.contentsOfDirectory(atPath: root.path) else { return } + for name in sessions where name != session { + try? files.removeItem(at: root.appending(path: name)) } } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift index 1a41170b5..cb7635da7 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -223,7 +223,7 @@ import Testing #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) } - /// `remove` deletes only folders it made; `removeAll` clears every copy and nothing else. + /// `remove` deletes only folders it made, and removing copies never touches a scan. @Test func removalStaysInsideTheStagingFolder() throws { defer { cleanUp() } let folder = try scan("keep") @@ -236,27 +236,35 @@ import Testing let first = try staging.stage(listed) let second = try staging.stage(listed) #expect(first != second) - staging.removeAll() - #expect(!files.fileExists(atPath: staging.root.path)) + staging.remove(first) + #expect(!files.fileExists(atPath: first.path) && files.fileExists(atPath: second.path)) + SavedScanStaging(root: staging.root).removeOtherSessions() + #expect(!files.fileExists(atPath: second.path)) #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) } - /// At launch, copies an earlier run left go, and one made since launch stays. - @Test func launchRemovesOnlyCopiesMadeBeforeIt() throws { + /// At launch, the copies earlier runs left go and this run's stay, whatever the clock says. + @Test func launchRemovesOnlyOtherRunsCopies() throws { defer { cleanUp() } try scan("one") - let staging = SavedScanStaging(root: root.appending(path: "share")) + let shares = root.appending(path: "share") + let earlier = SavedScanStaging(root: shares, session: "earlier") + let current = SavedScanStaging(root: shares, session: "current") let listed = try #require(SavedScanCatalog(root: root).scans().first) - let left = try staging.stage(listed) - try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: left.deletingLastPathComponent().path) - let launch = Date(timeIntervalSinceNow: -60) - let current = try staging.stage(listed) + let left = try earlier.stage(listed) + let mine = try current.stage(listed) + // A clock set back between runs: the earlier copy looks newer than this run's. + try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: 3600)], ofItemAtPath: left.deletingLastPathComponent().path) - staging.removeCopies(madeBefore: launch) + current.removeOtherSessions() #expect(!files.fileExists(atPath: left.path)) - #expect(files.fileExists(atPath: current.path)) + #expect(files.fileExists(atPath: mine.path)) #expect(files.fileExists(atPath: listed.archive.path)) - SavedScanStaging(root: root.appending(path: "absent")).removeCopies(madeBefore: launch) + // A run removes only its own session's copies one by one. + current.remove(left) + earlier.remove(mine) + #expect(files.fileExists(atPath: mine.path)) + SavedScanStaging(root: root.appending(path: "absent")).removeOtherSessions() } // MARK: Names From 74640d5bff8bbd7023345f2214104080413633c6 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 18:56:38 -0500 Subject: [PATCH 11/15] ios: only a real scan folder is judged by its bundle; a link in its place holds no kept place A top-level link to a scan folder resolved to that scan's bundle, so the link and its target could take both kept places and an intact scan was deleted. A link, or an entry whose type can't be read, now holds no place and is left alone; a plain file still goes, as before. Co-Authored-By: Claude Code --- .../HouseScanKit/Scene/ScanFolderCleanup.swift | 13 +++++++++++-- .../Tests/HouseScanKitTests/KeptScansTests.swift | 10 ++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index e54028c78..450c56d49 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -47,8 +47,17 @@ public struct ScanFolderCleanup: Sendable { var completed: [(name: String, date: Date)] = [] var incomplete: [String] = [] for name in names { - // A plain file beside the scan folders holds no bundle; it goes, as before. - if (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType == .typeRegular { continue } + // Only a real folder is judged by its bundle. A plain file beside the scan folders holds + // no bundle and goes, as before. A link (attributesOfItem doesn't follow one) would + // reach another folder's bundle, so it and its target could take both kept places; + // it, and an entry whose type can't be read, hold no place and are left alone. + switch (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType { + case .typeDirectory?: break + case .typeRegular?: continue + default: + incomplete.append(name) + continue + } let bundle = root.appending(path: name).appending(path: Self.bundleName) switch Self.bundleState(bundle) { case .absent: diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index ea792ce85..084628d49 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -113,8 +113,8 @@ import Testing #expect(Self.names(later.obsolete) == ["intact2"]) } - /// A folder whose bundle can't be read, or whose bundle is a link or a folder, holds no place - /// and is not deleted. + /// A folder whose bundle can't be read, or whose bundle is a link or a folder, and a link in + /// place of a scan folder hold no place and are not deleted. @Test func bundlesThatCannotBeJudgedAreLeftAlone() throws { let root = try Self.makeRoot() let files = FileManager.default @@ -134,14 +134,16 @@ import Testing try files.createDirectory(at: root.appending(path: "folderBundle/\(ScanFolderCleanup.bundleName)"), withIntermediateDirectories: true) try Data("stray".utf8).write(to: root.appending(path: "stray.txt")) + // A link to the newest intact scan, named to sort ahead of it: it must not take a place. + try files.createSymbolicLink(at: root.appending(path: "zalias"), withDestinationURL: root.appending(path: "intact1")) let cleanup = ScanFolderCleanup(root: root, keeping: "now") #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) - #expect(Set(Self.names(cleanup.incompleteBundles)) == ["locked", "linked", "folderBundle"]) + #expect(Set(Self.names(cleanup.incompleteBundles)) == ["locked", "linked", "folderBundle", "zalias"]) // A stray file is not a scan folder and goes, as before. #expect(Self.names(cleanup.obsolete) == ["stray.txt"]) #expect(cleanup.run().isEmpty) - #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle"]) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle", "zalias"]) } /// A retry rewrites scan.zip in place while the check may still be reading the old file. A From 60e11c20f5ce89d8d5dca9b6a06a88b7e39c4bfb Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:25:34 -0500 Subject: [PATCH 12/15] ios: tests pin share-copy ownership across sheets in one run and across a new scan's cleanup Two sheets in one process share a staging session, so opening one never deletes the copy the other is sharing. A copy being shared keeps the bundle's bytes after a new scan's cleanup deletes its source folder. Co-Authored-By: Claude Code --- .../SavedScanCatalogTests.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift index cb7635da7..27fcef093 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -267,6 +267,46 @@ import Testing SavedScanStaging(root: root.appending(path: "absent")).removeOtherSessions() } + /// Two Saved scans sheets in one app run (two windows, if the app ever allows them) share a + /// session: opening one, which clears other runs' copies, never deletes the copy the other + /// is sharing, and each removes only the copy it made. + @Test func sheetsInOneRunNeverDeleteEachOthersCopies() throws { + defer { cleanUp() } + try scan("one") + let shares = root.appending(path: "share") + let first = SavedScanStaging(root: shares, session: "run") + let second = SavedScanStaging(root: shares, session: "run") + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let sharing = try first.stage(listed) + + second.removeOtherSessions() + let other = try second.stage(listed) + second.remove(other) + #expect(try Data(contentsOf: sharing) == Self.packet) + #expect(!files.fileExists(atPath: other.path)) + } + + /// A share outlives a new scan: the new store's cleanup deletes the scan's own folder, and + /// the copy the share sheet is reading, in its own folder, keeps the bundle's bytes. + @Test func aCopyBeingSharedSurvivesANewScansCleanup() throws { + defer { cleanUp() } + try scan("old", minutesAgo: 90) + try scan("newer", minutesAgo: 30) + try scan("newest", minutesAgo: 5) + let staging = SavedScanStaging(root: files.temporaryDirectory.appending(path: "share-\(UUID().uuidString)")) + defer { try? files.removeItem(at: staging.root) } + let older = try #require(SavedScanCatalog(root: root).scans().last) + let copy = try staging.stage(older) + + // Two newer completed scans push it out: a new scan's store deletes its folder. + try scan("evenNewer", minutesAgo: 1) + let cleanup = ScanFolderCleanup(root: root, keeping: "fresh") + #expect(cleanup.obsolete.map(\.lastPathComponent).contains(older.id)) + #expect(cleanup.run().isEmpty) + #expect(!files.fileExists(atPath: older.archive.path)) + #expect(try Data(contentsOf: copy) == Self.packet) + } + // MARK: Names @Test func theShareNameSaysWhenAndWhetherPractice() { From 9945acb88a57fb78871d74b724f35ae7123fb984 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:26:56 -0500 Subject: [PATCH 13/15] ios: a failed upload's note says Saved scans can still share the saved file after the app closes With the scan packaged, the note now says that closing the app ends the retry but not the sharing: Saved scans on the first screen offers the same bundle. It promises no reopening or retry after close. The note without a bundle is unchanged. The test assertion moves with it. Written with the backend owner's handoff of these lines. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Screens/UploadingScreen.swift | 8 +++++--- ios/HouseScanUITests/ScreenStatesUITests.swift | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 9cf8b9d92..9c535a3cf 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -48,11 +48,13 @@ struct UploadingScreen: View { switch state.upload { case .failed: VStack(spacing: 16) { - // Packets stay on disk, but the app has no way to reopen them after - // relaunch. Offer export only once the shareable packet exists. + // The app can't reopen a scan after relaunch, so retrying needs it open. + // Once packaged, its bundle stays among the kept scans, and Saved scans on + // the first screen can still share it. The Share scan button below covers + // sharing now. Text(state.shareableScan == nil ? "Keep this scan open to try again. House Scan can't reopen it after you close the app." - : "Keep this scan open to try again, or use Share scan to save a copy. House Scan can't reopen it after you close the app.") + : "Keep this scan open to try again. If you close the app, you can still share its saved file from Saved scans on the first screen.") .font(Typeface.hint) .foregroundStyle(Palette.muted) .multilineTextAlignment(.center) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 3002e501d..7bf03e93b 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -215,8 +215,9 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 5)) } - /// A failed upload says the app can't reopen the scan after it closes, and with the scan - /// packaged it points to Share scan. The note, Try again and Share scan are all reachable. + /// A failed upload says to keep the scan open to try again, and with the scan packaged that + /// Saved scans can still share its file after the app closes. The note, Try again and Share + /// scan are all reachable. @MainActor func testOfflineRecoveryActions() throws { try checkOfflineRecovery(textSize: [], name: "offline-recovery-polish") @@ -240,9 +241,8 @@ final class ScreenStatesUITests: XCTestCase { let note = element(app, "upload.recoveryLimit") for _ in 0..<8 where !note.isHittable { scroll.swipeUp(velocity: .slow) } XCTAssertTrue(note.isHittable) - // The demo scan is packaged, so the note names the export. - XCTAssertTrue(note.label.contains("use Share scan to save a copy"), note.label) - XCTAssertTrue(note.label.contains("can't reopen it after you close the app"), note.label) + // The demo scan is packaged, so the note points to Saved scans for after the app closes. + XCTAssertTrue(note.label.contains("If you close the app, you can still share its saved file from Saved scans on the first screen."), note.label) let retry = app.buttons["action.retryUpload"] for _ in 0..<8 where !retry.isHittable { scroll.swipeUp(velocity: .slow) } XCTAssertTrue(retry.isHittable) From 4a7d4bfddb350f09447e2a37f1dd990aae29d2f8 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:48:40 -0500 Subject: [PATCH 14/15] ios: the cleanup deletes an entry only if it is still what it was when listed The deletion runs after the listing, off the main actor. A folder listed as never packaged could gain a bundle, partial or whole, before it ran, and was deleted anyway; so was a kept-out bundle a retry had rewritten. run() now reads each obsolete entry again and leaves any that changed, by type, bundle state, save time or file number, for the next cleanup. PacketArchiveCheck's docs now say it detects a write that reached the end in the packet's layout, not that a file is a capture packet. Co-Authored-By: Claude Code --- .../SavedScans/PacketArchiveCheck.swift | 12 ++-- .../Scene/ScanFolderCleanup.swift | 62 +++++++++++++------ .../HouseScanKitTests/KeptScansTests.swift | 32 ++++++++++ 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift index de51b2599..8db0606e8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift @@ -1,19 +1,23 @@ import Foundation -/// Whether a file is a whole capture-packet zip, read from its two ends without unpacking it. +/// Whether a file is a zip written to the end in the capture packet's layout, read from its two +/// ends without unpacking it. /// /// `scan.zip` is written in place (`ZipWriter.write`), so a scan quit or crashed during the write, /// or a bundle still being written when a list is made, leaves a partial file under the name that /// marks a completed scan. The zip's last record (the end of central directory) is written last, /// so a file whose tail holds that record, pointing at a central directory that ends right before /// it, was written to the end. The first entry must be `manifest.json`, which the packet always -/// writes first (`KeyframeStore.zipPacket`), so another zip under the name is not taken for a scan. +/// writes first (`KeyframeStore.zipPacket`), so a zip that doesn't start with it is not taken for a +/// scan. /// The central directory's first record must name the same entry at offset 0, which ties the /// footer, the directory and the first entry together. A symbolic link is refused, so a copy of /// the file is always the bytes themselves. /// -/// This detects a bundle that wasn't written to the end; it is not a defense against a crafted -/// zip. Only the app writes into its scan folders. +/// This detects a bundle that wasn't written to the end. It does not establish that a file is a +/// capture packet: any zip whose first entry is named `manifest.json` passes, whatever the +/// manifest says, and the entries' contents and CRCs are not read. Only the app writes into its +/// scan folders, which is what makes the name a sufficient marker there. /// /// The check reads at most 64 KiB from each end, whatever the archive's size. It does not check /// each entry's CRC: that would read every photo, and a write that reached the last record wrote diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 450c56d49..3335f8361 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -39,6 +39,8 @@ public struct ScanFolderCleanup: Sendable { public let keptCompleted: [URL] /// Folders whose bundle is there but not whole: left alone, holding no kept place. public let incompleteBundles: [URL] + /// What each obsolete entry was when listed. `run` deletes an entry only if it still is. + private let listedAs: [URL: Entry] /// Lists `root` now. An unreadable root lists nothing. public init(root: URL, keeping kept: String, keepCompleted: Int = defaultKeepCompleted) { @@ -46,25 +48,16 @@ public struct ScanFolderCleanup: Sendable { let names = ((try? files.contentsOfDirectory(atPath: root.path)) ?? []).filter { $0 != kept }.sorted() var completed: [(name: String, date: Date)] = [] var incomplete: [String] = [] + var entries: [String: Entry] = [:] for name in names { - // Only a real folder is judged by its bundle. A plain file beside the scan folders holds - // no bundle and goes, as before. A link (attributesOfItem doesn't follow one) would - // reach another folder's bundle, so it and its target could take both kept places; - // it, and an entry whose type can't be read, hold no place and are left alone. - switch (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType { - case .typeDirectory?: break - case .typeRegular?: continue - default: - incomplete.append(name) - continue - } - let bundle = root.appending(path: name).appending(path: Self.bundleName) - switch Self.bundleState(bundle) { - case .absent: + let entry = Self.entry(at: root.appending(path: name)) + entries[name] = entry + switch entry { + case .file, .folder(.absent): continue - case .complete(let date): + case .folder(.complete(let date, _)): completed.append((name, date)) - case .incomplete: + case .unjudged, .folder(.incomplete): incomplete.append(name) } } @@ -73,12 +66,35 @@ public struct ScanFolderCleanup: Sendable { let keep = Set(keptNames + incomplete) keptCompleted = keptNames.map { root.appending(path: $0) } incompleteBundles = incomplete.map { root.appending(path: $0) } - obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } + let obsoleteNames = names.filter { !keep.contains($0) } + obsolete = obsoleteNames.map { root.appending(path: $0) } + listedAs = Dictionary(uniqueKeysWithValues: obsoleteNames.compactMap { name in entries[name].map { (root.appending(path: name), $0) } }) + } + + /// One entry beside the scan in use. + enum Entry: Equatable, Sendable { + /// A plain file: no bundle, so it goes, as before. + case file + /// A link or an entry whose type can't be read. A link (attributesOfItem doesn't follow + /// one) would reach another folder's bundle, so it and its target could take both kept + /// places; it holds no place and is left alone. + case unjudged + /// A real folder, judged by its bundle. + case folder(BundleState) } - enum BundleState: Equatable { + static func entry(at url: URL) -> Entry { + switch (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type] as? FileAttributeType { + case .typeDirectory?: .folder(bundleState(url.appending(path: bundleName))) + case .typeRegular?: .file + default: .unjudged + } + } + + enum BundleState: Equatable, Sendable { case absent - case complete(savedAt: Date) + /// `file` is the bundle's file number: a rewrite makes a new file, whatever its date. + case complete(savedAt: Date, file: UInt64) case incomplete } @@ -107,15 +123,21 @@ public struct ScanFolderCleanup: Sendable { let number = before[.systemFileNumber] as? UInt64, after[.systemFileNumber] as? UInt64 == number, after[.size] as? UInt64 == before[.size] as? UInt64 else { return .incomplete } - return .complete(savedAt: before[.modificationDate] as? Date ?? .distantPast) + return .complete(savedAt: before[.modificationDate] as? Date ?? .distantPast, file: number) } /// Deletes the listed folders; returns each one that could not be deleted with the reason. A /// folder already gone is not an error. + /// + /// The deletion runs later than the listing, off the main actor, and a folder can change in + /// between: a bundle write can start in a folder listed as never packaged, or finish in one. + /// Each entry is read again and deleted only if it is still what it was when listed; one that + /// changed is left for the next cleanup. A change after this second read is not caught. public func run() -> [(url: URL, error: any Error)] { let files = FileManager.default return obsolete.compactMap { url in guard files.fileExists(atPath: url.path) else { return nil } + guard let listed = listedAs[url], Self.entry(at: url) == listed else { return nil } do { try files.removeItem(at: url) return nil diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 084628d49..521549436 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -187,6 +187,38 @@ import Testing #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["current", "later", "laterPartial"]) } + /// The deletion runs after the listing. A folder whose bundle appeared since, partial or + /// whole, is left for the next cleanup, and so is a bundle a retry rewrote; one still as + /// listed is deleted. + @Test func runDeletesOnlyEntriesStillAsListed() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let files = FileManager.default + let bundle = { (name: String) in root.appending(path: "\(name)/\(ScanFolderCleanup.bundleName)") } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("intact3", in: root, bundledMinutesAgo: 120) + try Self.scan("rewritten", in: root, bundledMinutesAgo: 150) + // The same date before and after the rewrite: only the new file tells them apart. + let saved = Date(timeIntervalSinceReferenceDate: 800_000_000) + try files.setAttributes([.modificationDate: saved], ofItemAtPath: bundle("rewritten").path) + try Self.scan("startsWriting", in: root) + try Self.scan("finishesWriting", in: root) + try Self.scan("stillBare", in: root) + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.obsolete) == ["finishesWriting", "intact3", "rewritten", "startsWriting", "stillBare"]) + + try Self.packet.prefix(Self.packet.count / 2).write(to: bundle("startsWriting")) + try Self.packet.write(to: bundle("finishesWriting")) + // A retry rewrites in place: the old file goes and a new one takes its name. + try files.removeItem(at: bundle("rewritten")) + try Self.packet.write(to: bundle("rewritten")) + try files.setAttributes([.modificationDate: saved], ofItemAtPath: bundle("rewritten").path) + + #expect(cleanup.run().isEmpty) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "rewritten", "startsWriting", "finishesWriting"]) + } + /// Bundles saved at the same moment keep the order by name, newest name first, as before. @Test func equalSaveTimesFallBackToNameOrder() throws { let root = try Self.makeRoot() From 041dae0e21ed0981ee644f5af831888252e8c717 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:48:56 -0500 Subject: [PATCH 15/15] ios: the README says which scans Saved scans lists and what a partial bundle does Co-Authored-By: Claude Code --- ios/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/README.md b/ios/README.md index 3678de848..9f54635ea 100644 --- a/ios/README.md +++ b/ios/README.md @@ -96,7 +96,7 @@ Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabili **Logs.** Open Console.app on the Mac, select the iPhone, press Start and search for `subsystem:dev.housescanning.housescan`. Every screen change logs as `STATE=`; the `engine` category records the capture's decisions, the scan bundle and upload failures. The same lines show in Xcode's console while it runs the app. -**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. After the app is closed, **Saved scans** in the first screen's top bar lists the completed scans the phone still keeps (the two most recent, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. Share there sends the same bundle. It copies the bundle to the app's tmp folder first, so a new scan's cleanup can't delete it while the share sheet reads it, and deletes the copy when the sheet closes. A saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. +**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. After the app is closed, **Saved scans** in the first screen's top bar lists the completed scans the phone still keeps (the two most recent whose `scan.zip` was written to the end, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. A bundle cut short by a quit during its write is not listed, holds no kept place and is not deleted. Share there sends the same bundle. It copies the bundle to the app's tmp folder first, so a new scan's cleanup can't delete it while the share sheet reads it, and deletes the copy when the sheet closes; a launch deletes copies earlier runs left. A saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. ## Scan bundle