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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,22 @@ struct VaultExportPDFDocumentRenderer<Renderer: PDFDocumentRenderer>: PDFDocumen
self.attacher = attacher
}

func render(document: VaultExportPayload) throws -> PDFDocument {
func render(document: VaultExportPayload, progress: @escaping (Double) -> Void) throws -> PDFDocument {
let generator = VaultExportDataBlockGenerator(payload: document, dataShardBuilder: dataShardBuilder)

func render(totalPageCount: Int?) throws -> PDFDocument {
/// Each pass renders every code, so each pass owns half of the overall progress.
func render(totalPageCount: Int?, progressRange: ClosedRange<Double>) throws -> PDFDocument {
let finalPageCount = totalPageCount ?? 0
let document = try generator.makeDocument(knownPageCount: finalPageCount)
return try renderer.render(document: document)
return try renderer.render(document: document) { fraction in
let span = progressRange.upperBound - progressRange.lowerBound
progress(progressRange.lowerBound + span * fraction)
}
}

// The first pass render determines how many pages there actually are.
let firstPassRender = try render(totalPageCount: nil)
var finalRender = try render(totalPageCount: firstPassRender.pageCount)
let firstPassRender = try render(totalPageCount: nil, progressRange: 0 ... 0.5)
var finalRender = try render(totalPageCount: firstPassRender.pageCount, progressRange: 0.5 ... 1)

// Attach the encrypted vault as well, so we can read it easily and automatically.
try attacher.attach(vault: document.encryptedVault, to: &finalRender)
Expand Down
10 changes: 8 additions & 2 deletions Vault/Sources/VaultBackup/VaultBackupPDFGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ public struct VaultBackupPDFGenerator {
self.authorName = authorName
}

public func makePDF(payload: VaultExportPayload) throws -> PDFDocument {
/// Renders the payload to a PDF.
///
/// `progress` is called synchronously on the rendering thread with values in `0...1`, ending at `1`.
public func makePDF(
payload: VaultExportPayload,
progress: @escaping (Double) -> Void = { _ in },
) throws -> PDFDocument {
let blockDocumentRenderer = PDFDataBlockDocumentRenderer(
documentSize: size,
rendererFactory: PDFDocumentPageRendererFactory(
Expand All @@ -40,7 +46,7 @@ public struct VaultBackupPDFGenerator {
dataShardBuilder: dataShardBuilder,
attacher: VaultBackupPDFAttacherImpl(),
)
return try documentRenderer.render(document: payload)
return try documentRenderer.render(document: payload, progress: progress)
}

private var dataShardBuilder: DataShardBuilder {
Expand Down
30 changes: 28 additions & 2 deletions Vault/Sources/VaultExport/PDF/PDFDataBlockDocumentRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ public struct PDFDataBlockDocumentRenderer<
self.blockLayout = blockLayout
}

public func render(document: DataBlockDocument) throws -> PDFDocument {
public func render(document: DataBlockDocument, progress: @escaping (Double) -> Void) throws -> PDFDocument {
let renderer = rendererFactory.makeRenderer()
// Images dominate rendering time, so progress is measured in images processed.
let totalImages = document.totalImageCount
var processedImages = 0
var renderingError: (any Error)?
let data = renderer.pdfData { context in
let drawer = PDFDocumentDrawerHelper(
Expand All @@ -48,7 +51,10 @@ public struct PDFDataBlockDocumentRenderer<
images: imageData,
imageRenderer: imageRenderer,
rectSeriesLayout: blockLayout,
)
) {
processedImages += 1
progress(Double(processedImages) / Double(totalImages))
}
}
}
} catch {
Expand All @@ -58,6 +64,9 @@ public struct PDFDataBlockDocumentRenderer<
if let renderingError {
throw renderingError
} else if let document = PDFDocument(data: data) {
if totalImages == 0 {
progress(1)
}
return document
} else {
throw PDFRenderingError.invalidData
Expand Down Expand Up @@ -122,10 +131,13 @@ private final class PDFDocumentDrawerHelper<Layout: PageLayout> {
}

/// Throws if unable to draw.
///
/// `onImageProcessed` is called after each image is dealt with, whether it was drawn or skipped.
func draw(
images: [Data],
imageRenderer: some ImageDataRenderer,
rectSeriesLayout: @escaping (CGRect) -> some RectSeriesLayout,
onImageProcessed: () -> Void,
) throws(PDFContentDrawerer.DrawError) {
var currentImageNumberOnPage: UInt = 0
var currentLayoutEngine = rectSeriesLayout(contentArea.currentBounds)
Expand All @@ -150,6 +162,7 @@ private final class PDFDocumentDrawerHelper<Layout: PageLayout> {
}

try drawerer.drawContent()
onImageProcessed()
}
}

Expand Down Expand Up @@ -186,6 +199,19 @@ private final class PDFDocumentDrawerHelper<Layout: PageLayout> {
}
}

// MARK: - Progress

extension DataBlockDocument {
fileprivate var totalImageCount: Int {
content.reduce(0) { count, item in
switch item {
case .title: count
case let .dataBlock(images): count + images.count
}
}
}
}

// MARK: - Positioning

extension DataBlockHeader {
Expand Down
14 changes: 13 additions & 1 deletion Vault/Sources/VaultExport/PDF/PDFDocumentRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,17 @@ import PDFKit
/// @mockable(typealias: Document = DataBlockDocument)
public protocol PDFDocumentRenderer<Document> {
associatedtype Document
func render(document: Document) throws -> PDFDocument

/// Renders the document, reporting progress as it goes.
///
/// `progress` is called synchronously on the rendering thread with values in `0...1`. A successful
/// render always ends with a report of `1`.
func render(document: Document, progress: @escaping (Double) -> Void) throws -> PDFDocument
}

extension PDFDocumentRenderer {
/// Renders the document without observing progress.
public func render(document: Document) throws -> PDFDocument {
try render(document: document, progress: { _ in })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,27 @@ public final class AutoBackupViewModel {
public private(set) var configuration: AutoBackupConfiguration
public private(set) var providerStates: [ProviderDisplayState]
public internal(set) var configureError: AutoBackupError?
/// Briefly true after a backup finishes, so the screen can confirm it rather than silently
/// re-enabling the button. Driven by the transition into `.completed`, not by the sticky
/// `.completed` status itself, which is what the service rests on between backups.
public internal(set) var showsBackupCompleteNotice = false

private let service: any AutoBackupService
private let providerStatesWereSeeded: Bool
private let completionNoticeDuration: Duration
@ObservationIgnored private var cancellables = Set<AnyCancellable>()
@ObservationIgnored private var completionNoticeTask: Task<Void, Never>?
/// Set when a backup starts and cleared when it completes, so only a real backup earns the notice.
@ObservationIgnored private var isAwaitingBackupCompletion = false

public init(
service: any AutoBackupService,
initialProviderStates: [ProviderDisplayState] = [],
completionNoticeDuration: Duration = .seconds(2),
) {
self.service = service
providerStatesWereSeeded = !initialProviderStates.isEmpty
self.completionNoticeDuration = completionNoticeDuration
// Seed synchronously: the service's publishers do not replay, so
// waiting for an emission would leave the screen stuck on defaults.
status = service.status
Expand All @@ -51,7 +61,7 @@ public final class AutoBackupViewModel {

service.statusPublisher
.sink { [weak self] newStatus in
self?.status = newStatus
self?.apply(newStatus)
}
.store(in: &cancellables)

Expand Down Expand Up @@ -150,6 +160,49 @@ public final class AutoBackupViewModel {
}
}

/// Progress of the in-flight backup, or nil when none is running.
public var backupProgress: AutoBackupProgress? {
if case let .backingUp(progress) = status {
progress
} else {
nil
}
}

private func apply(_ newStatus: AutoBackupStatus) {
status = newStatus
switch newStatus {
case .backingUp:
isAwaitingBackupCompletion = true
hideCompletionNotice()
case .completed where isAwaitingBackupCompletion:
isAwaitingBackupCompletion = false
showCompletionNotice()
case .error, .disabled, .idle:
isAwaitingBackupCompletion = false
hideCompletionNotice()
case .completed, .cleaningUp:
// A retention change alone cleans up and re-emits `.completed`; that is not a backup.
break
}
}

private func showCompletionNotice() {
completionNoticeTask?.cancel()
showsBackupCompleteNotice = true
completionNoticeTask = Task { [weak self, completionNoticeDuration] in
try? await Task.sleep(for: completionNoticeDuration)
guard !Task.isCancelled else { return }
self?.showsBackupCompleteNotice = false
}
}

private func hideCompletionNotice() {
completionNoticeTask?.cancel()
completionNoticeTask = nil
showsBackupCompleteNotice = false
}

public var statusIconName: String {
switch status {
case .disabled:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

/// How far an in-flight backup has got.
///
/// Progress is reported per phase; `fractionCompleted` folds the phases into a single `0...1` value
/// weighted by how long each phase typically takes, so a bar driven by it moves at a steady pace.
public struct AutoBackupProgress: Equatable, Sendable {
public enum Phase: Equatable, Sendable, CaseIterable {
/// Reading the vault out of storage.
case exporting
/// Encrypting the exported vault with the backup password.
case encrypting
/// Drawing the encrypted vault as QR codes into the PDF. Dominates the backup time.
case rendering
/// Writing the PDF to the storage provider.
case saving

public var localizedTitle: String {
switch self {
case .exporting: "Exporting vault…"
case .encrypting: "Encrypting…"
case .rendering: "Rendering QR codes…"
case .saving: "Saving…"
}
}

/// The slice of overall progress this phase occupies.
var overallRange: ClosedRange<Double> {
switch self {
case .exporting: 0 ... 0.05
case .encrypting: 0.05 ... 0.15
case .rendering: 0.15 ... 0.95
case .saving: 0.95 ... 1
}
}
}

public let phase: Phase
/// Progress within `phase`, in `0...1`.
public let phaseFraction: Double

public init(phase: Phase, phaseFraction: Double = 0) {
self.phase = phase
self.phaseFraction = min(max(phaseFraction, 0), 1)
}

/// Overall progress in `0...1`.
public var fractionCompleted: Double {
let range = phase.overallRange
return range.lowerBound + (range.upperBound - range.lowerBound) * phaseFraction
}

/// The state of a backup that has just begun.
public static let starting = AutoBackupProgress(phase: .exporting)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public enum AutoBackupStatus: Equatable, Sendable {
case idle

/// A backup is currently in progress.
case backingUp
case backingUp(AutoBackupProgress)

/// Cleaning up old backups.
case cleaningUp
Expand Down
Loading
Loading