mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
feat(apple): saved devices and targeted transfers UI
Adds the native SwiftUI Saved Devices experience on top of the production saved-device core, as a top-level destination in the iOS tab bar and the macOS sidebar. Core seam: - App-facing saved-device domain models mirroring core/SavedDeviceModels.kt, with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views never hand-roll state checks. - 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping. cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial lane: each must reach the core while a targeted receive is blocking it. - Payload-free pairingChanged/targetedTransferChanged signals, dispatched before the numeric-transferId guard since saved-device events identify their subject by peer endpoint or a string transfer id. Experience: - Screen lists saved devices and outstanding consent requests only; the global targeted-transfer history stays out, reachable per device. - Details as a sheet with detents on compact layouts and a native inspector on macOS, owning Send, label, forget/block and that device's transfers. - Label editing is transactional: the draft and editor survive a failed write, conflicting actions are refused while saving, and the editor closes only after the core confirms. - Pairing and targeted-offer consent hosted at the app root, answerable from any tab and suppressed while a transfer approval is up. Dismissing a pairing prompt suppresses locally without consuming the single-use eligibility; dismissing an offer declines it, since an unanswered offer holds a slot in the core's bounded per-sender queue. - Targeted send reuses the invitation composer's affordances with file, folder, rename, replace and cleanup parity. Picker copies are released on replace/remove/clear/cancel and after a successful create, but kept after a failure so retry does not require re-picking. - Notifications for pairing requests and offers (withdrawn once answered) and for terminal targeted transfers. Wording follows direction: on the sending device the peer finished receiving, not us. Localization: - Widens 52 saved-device keys from kmp-only to both platforms. - Five keys carried a literal %1$s with no declared args, which Compose renders positionally but the Apple generator emits as a plain constant, leaking the placeholder into the UI. They now use named args; Compose output is byte-identical. - Adds targeted_offer_title/body. Reusing the invitation approval copy stated the roles backwards, announcing the sender as the receiver. Also surfaces core startup failures: the startup overlay is drawn above the snackbar host, so a failed initialize() was indistinguishable from an app that never finished loading. AppModel now keeps the reason, logs it, and the overlay shows it with a retry, plus the technical detail in DEBUG builds. Send and receive between two devices is verified only partially; a missing endpoint-identity credential currently blocks startup on the test device.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A saved-device moment worth a local notification.
|
||||
enum SavedDeviceNotificationKind: Equatable {
|
||||
/// A peer asked to pair and is waiting on an answer.
|
||||
case pairingRequest
|
||||
/// A targeted offer is waiting on approve/decline.
|
||||
case targetedOffer
|
||||
/// An incoming targeted transfer finished downloading here.
|
||||
case targetedReceiveCompleted
|
||||
/// An incoming targeted transfer failed here.
|
||||
case targetedReceiveFailed
|
||||
/// A transfer we sent finished downloading on the peer.
|
||||
case targetedSendCompleted
|
||||
/// A transfer we sent failed on the peer.
|
||||
case targetedSendFailed
|
||||
}
|
||||
|
||||
extension SavedDeviceNotificationKind {
|
||||
/// Stable id component. Distinct per kind so a transfer cannot collide with
|
||||
/// itself across directions.
|
||||
var idPrefix: String {
|
||||
switch self {
|
||||
case .pairingRequest: return "pairing-request"
|
||||
case .targetedOffer: return "offer"
|
||||
case .targetedReceiveCompleted: return "receive-completed"
|
||||
case .targetedReceiveFailed: return "receive-failed"
|
||||
case .targetedSendCompleted: return "send-completed"
|
||||
case .targetedSendFailed: return "send-failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved-device notification resolved from model state but not yet published.
|
||||
struct PlannedSavedDeviceNotification: Equatable {
|
||||
let id: String
|
||||
let kind: SavedDeviceNotificationKind
|
||||
/// Peer display name, already resolved; nil when we have no trustworthy one.
|
||||
let deviceName: String?
|
||||
let transferName: String?
|
||||
}
|
||||
|
||||
/// Pure: notifications for consent decisions currently waiting on the user.
|
||||
/// These are *pending* moments — they are withdrawn when the underlying request
|
||||
/// disappears, so the caller cancels ids that stop appearing here.
|
||||
func plannedSavedDevicePrompts(_ state: SavedDevicesState) -> [PlannedSavedDeviceNotification] {
|
||||
var planned: [PlannedSavedDeviceNotification] = []
|
||||
for relationship in state.pendingRelationships where relationship.state == .pendingIncoming {
|
||||
planned.append(PlannedSavedDeviceNotification(
|
||||
id: "pairing-request-\(relationship.remoteEndpointId)",
|
||||
kind: .pairingRequest,
|
||||
deviceName: state.eligibilities
|
||||
.first { $0.peerEndpointId == relationship.remoteEndpointId }?
|
||||
.remoteDisplayName
|
||||
?? state.device(relationship.remoteEndpointId)?.displayNameOrNil,
|
||||
transferName: nil
|
||||
))
|
||||
}
|
||||
for offer in state.targetedOffers.pending {
|
||||
planned.append(PlannedSavedDeviceNotification(
|
||||
id: "targeted-offer-\(offer.transferId)",
|
||||
kind: .targetedOffer,
|
||||
// Only a saved sender has a name we can vouch for.
|
||||
deviceName: state.targetedOffers.senderDisplayNames[offer.senderEndpointId],
|
||||
transferName: offer.transferName
|
||||
))
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
/// Pure: notifications for targeted transfers that reached a terminal outcome,
|
||||
/// excluding already-published ids. Terminal moments notify at most once.
|
||||
func plannedTargetedOutcomes(
|
||||
_ transfers: [SavedDeviceTransferItem],
|
||||
published: Set<String>
|
||||
) -> [PlannedSavedDeviceNotification] {
|
||||
transfers.compactMap { transfer in
|
||||
// Direction decides the wording: on the receiving device the transfer
|
||||
// "finished downloading", while on the sending device it is the *peer*
|
||||
// that finished. Ignoring direction told the sender it had downloaded
|
||||
// its own outgoing files.
|
||||
let kind: SavedDeviceNotificationKind
|
||||
switch (transfer.direction, transfer.state) {
|
||||
case (.incoming, .completed): kind = .targetedReceiveCompleted
|
||||
case (.incoming, .failed): kind = .targetedReceiveFailed
|
||||
case (.outgoing, .completed): kind = .targetedSendCompleted
|
||||
case (.outgoing, .failed): kind = .targetedSendFailed
|
||||
// Cancelled and declined are the user's own doing on one side or the
|
||||
// other; notifying about them would be noise.
|
||||
default: return nil
|
||||
}
|
||||
let id = "targeted-\(kind.idPrefix)-\(transfer.id)"
|
||||
guard !published.contains(id) else { return nil }
|
||||
return PlannedSavedDeviceNotification(
|
||||
id: id, kind: kind,
|
||||
deviceName: transfer.peerDisplayName,
|
||||
transferName: transfer.transferName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires local notifications for the saved-device domain: pairing requests and
|
||||
/// targeted offers waiting on a decision, and targeted transfers reaching a
|
||||
/// terminal outcome. Invitation-share moments belong to
|
||||
/// `TransferNotificationCoordinator`; approval prompts to `ApprovalCoordinator`.
|
||||
///
|
||||
/// Reads `SavedDevicesModel`'s published snapshot rather than querying the core
|
||||
/// again — the model already refreshes on every saved-device signal, and a second
|
||||
/// read loop would race it.
|
||||
@MainActor
|
||||
final class SavedDeviceNotificationCoordinator: ObservableObject {
|
||||
private let notifications: LocalNotificationService
|
||||
private let visibility: AppVisibility
|
||||
private let messages: UiMessageController
|
||||
|
||||
/// Prompt ids currently published, so they can be withdrawn when answered.
|
||||
private var publishedPrompts = Set<String>()
|
||||
/// Terminal ids already seen; never re-published.
|
||||
private var publishedOutcomes = Set<String>()
|
||||
private var primedOutcomes = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
model: SavedDevicesModel,
|
||||
notifications: LocalNotificationService,
|
||||
visibility: AppVisibility,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.notifications = notifications
|
||||
self.visibility = visibility
|
||||
self.messages = messages
|
||||
|
||||
// Recompute when any input changes: the snapshot itself, foregrounding, or
|
||||
// the permission being granted later.
|
||||
Publishers.CombineLatest3(
|
||||
model.$state,
|
||||
visibility.$isForeground,
|
||||
notifications.$permission
|
||||
)
|
||||
.sink { [weak self] state, _, _ in
|
||||
guard let self else { return }
|
||||
Task { await self.synchronize(state) }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// iOS suppresses notifications while the user is in the app (the modal or the
|
||||
/// screen shows instead); macOS presents them even when active, since the app
|
||||
/// window is usually open. Mirrors the other two coordinators.
|
||||
private var canPublish: Bool {
|
||||
guard notifications.permission == .granted else { return false }
|
||||
#if os(iOS)
|
||||
return !visibility.isForeground
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func synchronize(_ state: SavedDevicesState) async {
|
||||
await synchronizePrompts(state)
|
||||
await synchronizeOutcomes(state)
|
||||
}
|
||||
|
||||
private func synchronizePrompts(_ state: SavedDevicesState) async {
|
||||
let planned = plannedSavedDevicePrompts(state)
|
||||
let plannedIds = Set(planned.map(\.id))
|
||||
|
||||
// Withdraw notifications whose request is gone — answered here, answered on
|
||||
// the peer, expired, or dropped when the core restarted. Leaving them would
|
||||
// invite the user to act on a decision that no longer exists.
|
||||
for id in publishedPrompts.subtracting(plannedIds) {
|
||||
notifications.cancel(id: id)
|
||||
publishedPrompts.remove(id)
|
||||
}
|
||||
|
||||
guard canPublish else {
|
||||
// Suppressed: withdraw anything already showing, but keep the request
|
||||
// itself pending so it can notify again later.
|
||||
for id in publishedPrompts { notifications.cancel(id: id) }
|
||||
publishedPrompts.removeAll()
|
||||
return
|
||||
}
|
||||
|
||||
for plan in planned where !publishedPrompts.contains(plan.id) {
|
||||
// Reserve the id *before* awaiting: CombineLatest can fire repeatedly in
|
||||
// quick succession, and a repeated add of an in-flight identifier is
|
||||
// coalesced into a silent update with no banner.
|
||||
publishedPrompts.insert(plan.id)
|
||||
if case .failure(let error) = await publish(plan) {
|
||||
publishedPrompts.remove(plan.id)
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeOutcomes(_ state: SavedDevicesState) async {
|
||||
let planned = plannedTargetedOutcomes(state.targetedTransfers, published: publishedOutcomes)
|
||||
guard primedOutcomes else {
|
||||
// The first snapshot carries existing history; mark those terminal
|
||||
// transfers seen without notifying so only new transitions notify.
|
||||
primedOutcomes = true
|
||||
for plan in planned { publishedOutcomes.insert(plan.id) }
|
||||
return
|
||||
}
|
||||
for plan in planned {
|
||||
// Mark seen unconditionally — a terminal moment notifies at most once,
|
||||
// whether or not the gate let it through.
|
||||
publishedOutcomes.insert(plan.id)
|
||||
guard canPublish else { continue }
|
||||
if case .failure(let error) = await publish(plan) {
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ plan: PlannedSavedDeviceNotification) async -> Result<Void, Error> {
|
||||
let device = plan.deviceName ?? String(localized: L10n.Approval.nearbyDevice)
|
||||
let transferName = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
|
||||
let notification: LocalNotification
|
||||
switch plan.kind {
|
||||
case .pairingRequest:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Pairing.requestTitle),
|
||||
body: L10n.Pairing.requestBody(device: device)
|
||||
)
|
||||
case .targetedOffer:
|
||||
// The sender is offering to send to us — not the invitation-approval
|
||||
// case, where the remote device asks to receive from us.
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Targeted.offerTitle),
|
||||
body: L10n.Targeted.offerBody(device: device, transferName: transferName)
|
||||
)
|
||||
case .targetedReceiveCompleted:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiveCompletedTitle),
|
||||
body: L10n.Notifications.receiveCompletedBody(transferName: transferName)
|
||||
)
|
||||
case .targetedReceiveFailed:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiveFailedTitle),
|
||||
body: L10n.Notifications.receiveFailedBody(transferName: transferName)
|
||||
)
|
||||
case .targetedSendCompleted:
|
||||
// We are the sender: the *peer* finished downloading, not us.
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiverCompletedTitle),
|
||||
body: L10n.Notifications.receiverCompletedBody(receiver: device, transferName: transferName)
|
||||
)
|
||||
case .targetedSendFailed:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiverFailedTitle),
|
||||
body: L10n.Notifications.receiverFailedBody(receiver: device, transferName: transferName)
|
||||
)
|
||||
}
|
||||
return await notifications.publish(notification)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,10 @@ final class TransferNotificationCoordinator: ObservableObject {
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
case .pairingChanged, .targetedTransferChanged:
|
||||
// Saved-device notifications are owned by the saved-device
|
||||
// coordinator, which tracks its own notification identifiers.
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Reference in New Issue
Block a user