mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 22:29:58 +02:00
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.
265 lines
9.8 KiB
Swift
265 lines
9.8 KiB
Swift
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)
|
|
}
|
|
}
|