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",
totalSizeHint: 100
)
XCTAssertEqual(progress?.kind, "completed")
XCTAssertEqual(progress?.kind, .completed)
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
XCTAssertEqual(progress?.progress, 1)
}

View File

@@ -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 {

View File

@@ -298,14 +298,15 @@ private extension CoreEvent {
}
}
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
private let refreshKinds: Set<String> = [
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
private let refreshKinds: Set<EventKind> = [
.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)
}
}

View File

@@ -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<String> = [
"import", "ticket", "access", "transfer", "download", "export",
"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.
/// 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<EventKind> = [.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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}