Files
vnidrop/apple/VniDrop/Features/App/AppModel.swift
cdricms 8bb1442338 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.
2026-08-13 19:42:37 +02:00

76 lines
2.6 KiB
Swift

import Foundation
import Combine
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
/// core on launch and tracks the selected destination + theme.
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var destination: AppDestination = .send
@Published private(set) var themeMode: ThemeMode = .system
/// Why startup failed, or nil while it is still in progress or has succeeded.
/// The startup overlay covers the snackbar, so without this a failed
/// `initialize` was indistinguishable from an app that never finished loading.
@Published private(set) var startupError: UiText?
/// Untranslated failure detail, kept for the debug overlay only. The friendly
/// message alone cannot distinguish a missing keychain item from a database
/// fault, which makes a startup failure undiagnosable on a real device.
@Published private(set) var startupErrorDetail: String?
private let environment: PlatformEnvironment
private let repository: CoreGateway
private let messages: UiMessageController
private let relayConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>()
init(
environment: PlatformEnvironment,
repository: CoreGateway,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.environment = environment
self.repository = repository
self.messages = messages
self.relayConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task { await initializeCore() }
preferences.$preferences
.map(\.themeMode)
.removeDuplicates()
.sink { [weak self] mode in self?.themeMode = mode }
.store(in: &cancellables)
}
/// Runs core startup, keeping the failure reason for the overlay to show.
/// Also logged, because a user-facing message alone is not diagnosable.
func initializeCore() async {
startupError = nil
startupErrorDetail = nil
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: relayConfiguration
)
if case .failure(let error) = result {
AppLogger.error("lifecycle", "core initialization failed", error)
startupError = error.toUiText()
#if DEBUG
startupErrorDetail = error.technicalDetail
#endif
messages.error(error)
}
}
func retryStartup() {
guard startupError != nil else { return }
Task { await initializeCore() }
}
func selectDestination(_ destination: AppDestination) {
guard destination != self.destination else { return }
self.destination = destination
}
}