refactor(apple): type core event phase/kind/direction as enums

Replaces the stringly-typed transfer-event phase/kind/direction values
throughout the progress-derivation logic with EventPhase, EventKind and
EventDirection enums (String-backed to match the core's wire values).

CoreEventModel keeps the raw wire strings as a faithful boundary DTO but
exposes typed eventPhase/eventKind/eventDirection accessors; all logic —
progressForTransfer/Receiver, humanProgressLabel, aggregateReceiverProgress,
the refresh trigger, and the SendScreen snapshots — now compares enum cases
instead of literals. TransferProgress.phase/kind are the enums directly, so
constructions read `phase: .transfer, kind: .progress`. The two ad-hoc
phase/kind Sets collapse into "is a recognized case" (non-nil) checks.
This commit is contained in:
2026-07-23 18:00:29 +02:00
parent 08e61c57af
commit 3c8267adc5
7 changed files with 96 additions and 63 deletions

View File

@@ -57,7 +57,7 @@ final class ProgressDerivationTests: XCTestCase {
remoteEndpointId: "peer-a", remoteEndpointId: "peer-a",
totalSizeHint: 100 totalSizeHint: 100
) )
XCTAssertEqual(progress?.kind, "completed") XCTAssertEqual(progress?.kind, .completed)
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed) XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
XCTAssertEqual(progress?.progress, 1) XCTAssertEqual(progress?.progress, 1)
} }

View File

@@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
let timestamp: Int64 let timestamp: Int64
let scope: String let scope: String
let transferId: UInt64? 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 direction: String?
let phase: String let phase: String
let kind: String let kind: String
let dataJson: 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 { enum ShareAccessPolicy: Equatable, Sendable {

View File

@@ -298,14 +298,15 @@ private extension CoreEvent {
} }
} }
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"] private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
private let refreshKinds: Set<String> = [ private let refreshKinds: Set<EventKind> = [
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected", .started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
] ]
private extension CoreEventModel { private extension CoreEventModel {
var shouldRefreshTransfers: Bool { var shouldRefreshTransfers: Bool {
refreshPhases.contains(phase) && refreshKinds.contains(kind) guard let eventPhase, let eventKind else { return false }
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
} }
} }

View File

@@ -18,8 +18,8 @@ func windowClassFor(width: Double) -> WindowClass {
/// resolved at the view layer. /// resolved at the view layer.
struct TransferProgress: Equatable { struct TransferProgress: Equatable {
let transferId: UInt64? let transferId: UInt64?
let phase: String let phase: EventPhase
let kind: String let kind: EventKind
let labelKey: String.LocalizationValue let labelKey: String.LocalizationValue
let progress: Double? let progress: Double?
var detail: String? = nil var detail: String? = nil
@@ -40,32 +40,19 @@ func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
} }
} }
private let progressPhases: Set<String> = [ /// Latest progress snapshot for a transfer. Events are newest-first. Only events
"import", "ticket", "access", "transfer", "download", "export", /// whose `phase` and `kind` map to known cases participate.
"lifecycle", "network", "handshake", "error",
]
private let progressKinds: Set<String> = [
"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.
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? { func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
let relevant = events.filter { event in let relevant = events.filter { event in
event.transferId == transferId event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
&& progressPhases.contains(event.phase)
&& progressKinds.contains(event.kind)
} }
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) let sizeHint = findKnownSize(events: events, transferId: transferId)
return TransferProgress( return TransferProgress(
transferId: transferId, transferId: transferId,
phase: latest.phase, phase: phase,
kind: latest.kind, kind: kind,
labelKey: humanProgressLabel(latest), labelKey: humanProgressLabel(phase: phase, kind: kind),
progress: parseProgress(latest.dataJson, sizeHint: sizeHint), progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
detail: progressDetail(latest) detail: progressDetail(latest)
) )
@@ -80,32 +67,31 @@ func progressForReceiver(
) -> TransferProgress? { ) -> TransferProgress? {
if remoteEndpointId.isEmpty { return nil } if remoteEndpointId.isEmpty { return nil }
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId) let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
let receiverKinds: Set<EventKind> = [.started, .progress, .completed, .aborted]
let transferEvents = events.filter { event in let transferEvents = events.filter { event in
event.transferId == transferId event.transferId == transferId
&& event.direction == "send" && event.eventDirection == .send
&& event.phase == "transfer" && event.eventPhase == .transfer
&& ["started", "progress", "completed", "aborted"].contains(event.kind) && (event.eventKind.map(receiverKinds.contains) ?? false)
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds) && eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
} }
if transferEvents.isEmpty { return nil } guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
if latestKind == .aborted {
let latest = transferEvents[0]
if latest.kind == "aborted" {
return TransferProgress( return TransferProgress(
transferId: transferId, phase: "transfer", kind: "aborted", transferId: transferId, phase: .transfer, kind: .aborted,
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil 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( return TransferProgress(
transferId: transferId, phase: "transfer", kind: "completed", transferId: transferId, phase: .transfer, kind: .completed,
labelKey: L10n.Progress.completed, progress: 1, detail: nil labelKey: L10n.Progress.completed, progress: 1, detail: nil
) )
} }
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint) let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
return TransferProgress( return TransferProgress(
transferId: transferId, phase: "transfer", kind: latest.kind, transferId: transferId, phase: .transfer, kind: latestKind,
labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest) 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) // MARK: - Internals (ported literally from AppUiModels.kt)
private func humanProgressLabel(_ event: CoreEventModel) -> String.LocalizationValue { private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue {
switch (event.phase, event.kind) { switch (phase, kind) {
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"): case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started):
return L10n.Progress.preparing return L10n.Progress.preparing
case ("import", "done"): return L10n.Progress.ready case (.importing, .done): return L10n.Progress.ready
case ("ticket", "created"): return L10n.Progress.shareReady case (.ticket, .created): return L10n.Progress.shareReady
case ("network", "connecting"): return L10n.Progress.connecting case (.network, .connecting): return L10n.Progress.connecting
case ("network", "connected"): return L10n.Progress.connected case (.network, .connected): return L10n.Progress.connected
case ("download", "found-collection"): return L10n.Progress.gettingReady case (.download, .foundCollection): return L10n.Progress.gettingReady
case ("download", "progress"): return L10n.Progress.downloading case (.download, .progress): return L10n.Progress.downloading
case ("export", "progress"): return L10n.Progress.saving case (.export, .progress): return L10n.Progress.saving
case ("transfer", "progress"): return L10n.Progress.sending case (.transfer, .progress): return L10n.Progress.sending
case ("transfer", "started"): return L10n.Progress.connected case (.transfer, .started): return L10n.Progress.connected
case ("transfer", "completed"): return L10n.Progress.completed case (.transfer, .completed): return L10n.Progress.completed
case ("lifecycle", "done"): return L10n.Progress.completed case (.lifecycle, .done): return L10n.Progress.completed
case ("lifecycle", "cancelled"): return L10n.Progress.cancelled case (.lifecycle, .cancelled): return L10n.Progress.cancelled
default: default:
if event.phase == "handshake" { return L10n.Progress.requestingAccess } if phase == .handshake { return L10n.Progress.requestingAccess }
if event.kind == "failed" { return L10n.Progress.failed } if kind == .failed { return L10n.Progress.failed }
return L10n.Progress.working return L10n.Progress.working
} }
} }
@@ -217,14 +203,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint:
order.append(requestKey) order.append(requestKey)
} }
if let size, size > 0 { state.size = size } if let size, size > 0 { state.size = size }
switch event.kind { switch event.eventKind {
case "progress", "started": case .progress, .started:
if let endOffset { state.offset = max(state.offset, endOffset) } if let endOffset { state.offset = max(state.offset, endOffset) }
state.aborted = false state.aborted = false
case "completed": case .completed:
state.completed = true state.completed = true
if let s = state.size { state.offset = s } if let s = state.size { state.offset = s }
case "aborted": case .aborted:
state.aborted = true state.aborted = true
default: default:
break break

View File

@@ -129,7 +129,7 @@ struct InvitationReviewPanel: View {
if state.isReceiving { if state.isReceiving {
let progressId = state.activeReceiveTransferId 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) } let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail) ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive) SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)

View File

@@ -202,7 +202,7 @@ final class ReceiveModel: ObservableObject {
func cancelActiveReceive() { func cancelActiveReceive() {
let transferId = state.activeReceiveTransferId let transferId = state.activeReceiveTransferId
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId ?? 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 } guard let transferId else { return }
Task { Task {
let result = await repository.cancel(transferId: transferId) let result = await repository.cancel(transferId: transferId)

View File

@@ -134,10 +134,10 @@ struct SendScreen: View {
} }
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
if active.count == 1 { 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) 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, labelKey: L10n.Progress.sending, progress: combined,
label: L10n.Progress.sendingToCount(count: active.count)) label: L10n.Progress.sendingToCount(count: active.count))
} }