diff --git a/apple/Tests/ProgressDerivationTests.swift b/apple/Tests/ProgressDerivationTests.swift index 6e6de33..ea54eb8 100644 --- a/apple/Tests/ProgressDerivationTests.swift +++ b/apple/Tests/ProgressDerivationTests.swift @@ -57,7 +57,7 @@ final class ProgressDerivationTests: XCTestCase { remoteEndpointId: "peer-a", totalSizeHint: 100 ) - XCTAssertEqual(progress?.kind, "completed") + XCTAssertEqual(progress?.kind, .completed) XCTAssertEqual(progress?.labelKey, L10n.Progress.completed) XCTAssertEqual(progress?.progress, 1) } diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 33fe647..df40304 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable { let timestamp: Int64 let scope: String let transferId: UInt64? + /// Raw wire values as emitted by the core. Interpret them through the typed + /// `eventDirection` / `eventPhase` / `eventKind` accessors below — logic code + /// should never compare these strings directly. let direction: String? let phase: String let kind: String let dataJson: String + + var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) } + var eventPhase: EventPhase? { EventPhase(rawValue: phase) } + var eventKind: EventKind? { EventKind(rawValue: kind) } +} + +/// Direction of a core event, matching the wire strings the core emits. +enum EventDirection: String, Equatable, Sendable { + case send + case receive +} + +/// Phase of a core progress event (the `phase` wire field). +enum EventPhase: String, Equatable, Sendable { + case importing = "import" + case ticket + case access + case transfer + case download + case export + case lifecycle + case network + case handshake + case error +} + +/// Kind of a core progress event (the `kind` wire field). +enum EventKind: String, Equatable, Sendable { + case started + case copyProgress = "copy-progress" + case copyDone = "copy-done" + case outboardProgress = "outboard-progress" + case done + case created + case progress + case completed + case aborted + case failed + case connecting + case connected + case foundCollection = "found-collection" + case cancelled + case shareStopped = "share-stopped" } enum ShareAccessPolicy: Equatable, Sendable { diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 141e54d..f455a81 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -298,14 +298,15 @@ private extension CoreEvent { } } -private let refreshPhases: Set = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"] -private let refreshKinds: Set = [ - "started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected", +private let refreshPhases: Set = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake] +private let refreshKinds: Set = [ + .started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected, ] private extension CoreEventModel { var shouldRefreshTransfers: Bool { - refreshPhases.contains(phase) && refreshKinds.contains(kind) + guard let eventPhase, let eventKind else { return false } + return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind) } } diff --git a/apple/VniDrop/Core/TransferProgress.swift b/apple/VniDrop/Core/TransferProgress.swift index 227dbc4..9c8fe3d 100644 --- a/apple/VniDrop/Core/TransferProgress.swift +++ b/apple/VniDrop/Core/TransferProgress.swift @@ -18,8 +18,8 @@ func windowClassFor(width: Double) -> WindowClass { /// resolved at the view layer. struct TransferProgress: Equatable { let transferId: UInt64? - let phase: String - let kind: String + let phase: EventPhase + let kind: EventKind let labelKey: String.LocalizationValue let progress: Double? var detail: String? = nil @@ -40,32 +40,19 @@ func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue { } } -private let progressPhases: Set = [ - "import", "ticket", "access", "transfer", "download", "export", - "lifecycle", "network", "handshake", "error", -] - -private let progressKinds: Set = [ - "started", "copy-progress", "copy-done", "outboard-progress", "done", - "created", "progress", "completed", "aborted", "failed", - "connecting", "connected", "found-collection", - "cancelled", "share-stopped", -] - -/// Latest progress snapshot for a transfer. Events are newest-first. +/// Latest progress snapshot for a transfer. Events are newest-first. Only events +/// whose `phase` and `kind` map to known cases participate. func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? { let relevant = events.filter { event in - event.transferId == transferId - && progressPhases.contains(event.phase) - && progressKinds.contains(event.kind) + event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil } - guard let latest = relevant.first else { return nil } + guard let latest = relevant.first, let phase = latest.eventPhase, let kind = latest.eventKind else { return nil } let sizeHint = findKnownSize(events: events, transferId: transferId) return TransferProgress( transferId: transferId, - phase: latest.phase, - kind: latest.kind, - labelKey: humanProgressLabel(latest), + phase: phase, + kind: kind, + labelKey: humanProgressLabel(phase: phase, kind: kind), progress: parseProgress(latest.dataJson, sizeHint: sizeHint), detail: progressDetail(latest) ) @@ -80,32 +67,31 @@ func progressForReceiver( ) -> TransferProgress? { if remoteEndpointId.isEmpty { return nil } let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId) + let receiverKinds: Set = [.started, .progress, .completed, .aborted] let transferEvents = events.filter { event in event.transferId == transferId - && event.direction == "send" - && event.phase == "transfer" - && ["started", "progress", "completed", "aborted"].contains(event.kind) + && event.eventDirection == .send + && event.eventPhase == .transfer + && (event.eventKind.map(receiverKinds.contains) ?? false) && eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds) } - if transferEvents.isEmpty { return nil } - - let latest = transferEvents[0] - if latest.kind == "aborted" { + guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil } + if latestKind == .aborted { return TransferProgress( - transferId: transferId, phase: "transfer", kind: "aborted", + transferId: transferId, phase: .transfer, kind: .aborted, labelKey: L10n.Progress.interrupted, progress: nil, detail: nil ) } - if latest.kind == "completed" && !transferEvents.contains(where: { $0.kind == "progress" || $0.kind == "started" }) { + if latestKind == .completed && !transferEvents.contains(where: { $0.eventKind == .progress || $0.eventKind == .started }) { return TransferProgress( - transferId: transferId, phase: "transfer", kind: "completed", + transferId: transferId, phase: .transfer, kind: .completed, labelKey: L10n.Progress.completed, progress: 1, detail: nil ) } let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint) return TransferProgress( - transferId: transferId, phase: "transfer", kind: latest.kind, + transferId: transferId, phase: .transfer, kind: latestKind, labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest) ) } @@ -127,25 +113,25 @@ func formatBytes(_ size: UInt64) -> String { // MARK: - Internals (ported literally from AppUiModels.kt) -private func humanProgressLabel(_ event: CoreEventModel) -> String.LocalizationValue { - switch (event.phase, event.kind) { - case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"): +private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue { + switch (phase, kind) { + case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started): return L10n.Progress.preparing - case ("import", "done"): return L10n.Progress.ready - case ("ticket", "created"): return L10n.Progress.shareReady - case ("network", "connecting"): return L10n.Progress.connecting - case ("network", "connected"): return L10n.Progress.connected - case ("download", "found-collection"): return L10n.Progress.gettingReady - case ("download", "progress"): return L10n.Progress.downloading - case ("export", "progress"): return L10n.Progress.saving - case ("transfer", "progress"): return L10n.Progress.sending - case ("transfer", "started"): return L10n.Progress.connected - case ("transfer", "completed"): return L10n.Progress.completed - case ("lifecycle", "done"): return L10n.Progress.completed - case ("lifecycle", "cancelled"): return L10n.Progress.cancelled + case (.importing, .done): return L10n.Progress.ready + case (.ticket, .created): return L10n.Progress.shareReady + case (.network, .connecting): return L10n.Progress.connecting + case (.network, .connected): return L10n.Progress.connected + case (.download, .foundCollection): return L10n.Progress.gettingReady + case (.download, .progress): return L10n.Progress.downloading + case (.export, .progress): return L10n.Progress.saving + case (.transfer, .progress): return L10n.Progress.sending + case (.transfer, .started): return L10n.Progress.connected + case (.transfer, .completed): return L10n.Progress.completed + case (.lifecycle, .done): return L10n.Progress.completed + case (.lifecycle, .cancelled): return L10n.Progress.cancelled default: - if event.phase == "handshake" { return L10n.Progress.requestingAccess } - if event.kind == "failed" { return L10n.Progress.failed } + if phase == .handshake { return L10n.Progress.requestingAccess } + if kind == .failed { return L10n.Progress.failed } return L10n.Progress.working } } @@ -217,14 +203,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint: order.append(requestKey) } if let size, size > 0 { state.size = size } - switch event.kind { - case "progress", "started": + switch event.eventKind { + case .progress, .started: if let endOffset { state.offset = max(state.offset, endOffset) } state.aborted = false - case "completed": + case .completed: state.completed = true if let s = state.size { state.offset = s } - case "aborted": + case .aborted: state.aborted = true default: break diff --git a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift index ee80794..38141f1 100644 --- a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift +++ b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift @@ -129,7 +129,7 @@ struct InvitationReviewPanel: View { if state.isReceiving { let progressId = state.activeReceiveTransferId - ?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId + ?? model.coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) } ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail) SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive) diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift index aa7f82f..31c95d2 100644 --- a/apple/VniDrop/Features/Receive/ReceiveModel.swift +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -202,7 +202,7 @@ final class ReceiveModel: ObservableObject { func cancelActiveReceive() { let transferId = state.activeReceiveTransferId ?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId - ?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId + ?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId guard let transferId else { return } Task { let result = await repository.cancel(transferId: transferId) diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index ab9e3a4..ee8b55d 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -134,10 +134,10 @@ struct SendScreen: View { } let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) if active.count == 1 { - return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", + return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress, labelKey: L10n.Progress.sending, progress: combined) } - return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", + return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress, labelKey: L10n.Progress.sending, progress: combined, label: L10n.Progress.sendingToCount(count: active.count)) }