Files
vnidrop/apple
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
..

VniDrop — native Apple app (iOS / iPadOS / macOS)

A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core (crates/vnidrop) through UniFFI-generated Swift bindings. The Rust crate is not modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose UI screen-for-screen. Android, Windows, and Linux continue to use shared/ + Compose.

Layout

apple/
  scripts/build-core.sh     # builds the Rust core + generates Swift bindings + xcframework
  VnidropCore/              # local SwiftPM package: xcframework + generated Vnidrop.swift
  VniDrop/                  # SwiftUI app sources
    App/                    # entry point, object graph, root view, environment
    Core/                   # repository, models, preferences, notifications, progress
    Features/Send|Receive|Approvals|Settings/
    UI/Theme|Components|Navigation|Feedback|Shell/
    Platform/               # pickers, QR, NFC, share/export, per-OS file services
    Resources/              # Localizable.xcstrings, Info.plist, entitlements, assets
  Tests/                    # XCTest bundle (VniDropTests target)
  project.yml               # XcodeGen spec for the iOS/macOS app and test targets

Build & run

Prerequisites: Xcode, Rust with the Apple targets (aarch64-apple-ios, aarch64-apple-ios-sim, x86_64-apple-ios, aarch64-apple-darwin), and xcodegen (brew install xcodegen).

# From the repository root:
make apple-core          # Rust core, Swift bindings, and XCFramework
make apple-project       # generate apple/VniDrop.xcodeproj
make open-apple-project  # generate and open the project in Xcode
make build-apple-macos   # unsigned macOS build (App Store target)
make open-apple          # build and launch the macOS app
make build-apple-ios     # unsigned iOS simulator app
make check-apple         # iOS simulator tests

make apple-project also generates ignored Store and Direct version xcconfig files. Their CURRENT_PROJECT_VERSION values come from the central version resolver as UTC YYYYMMDD.HHMM.SS build identifiers. Regenerate the project before creating another App Store archive so it receives a fresh build number; direct DMG builds refresh their own value automatically.

macOS shipping channels

The macOS app ships through two targets that build identical sources:

  • VniDrop (Release) — Mac App Store / TestFlight. Sandboxed, no self-updater.
  • VniDropDirect (Release-Direct) — direct-download .dmg on GitHub Releases + Homebrew cask. Adds the Sparkle auto-updater behind the DIRECT_DISTRIBUTION compile flag, so the App Store binary never links Sparkle.
make build-apple-macos-direct   # unsigned compile-check of the direct target
make build-apple-dmg                # signed (+ notarized) .dmg

Full signing, notarization, appcast, and cask flow: see RELEASE-MACOS.md.

Use APPLE_PROFILE=release to request a release Rust core, or set APPLE_DESTINATION to override the automatically selected iOS simulator. Code signing is disabled for the app and test targets; local and CI builds do not require an Apple Development team or provisioning profile. Make builds can opt in with APPLE_CODE_SIGNING=YES. For signed builds from Xcode, create the ignored apple/Local.xcconfig and override the signing settings there, including the development team.

Typecheck & tests

The Xcode project is the only build definition: it owns the UI, its package dependencies, and the VniDropTests bundle (module VniDrop, which is what the tests import). Everything runs through xcodebuild:

make check-apple         # iOS simulator unit tests
make build-apple-macos   # unsigned macOS build (typecheck)

There is deliberately no SwiftPM manifest for the app. A second build definition would duplicate the target's package dependencies, and the previous one had already drifted out of sync with project.yml badly enough that neither swift build nor swift test worked.

Generated / ignored artifacts

build-core.sh produces build outputs that are gitignored (see apple/.gitignore): VnidropCore/vnidrop.xcframework/, VnidropCore/Sources/VnidropCore/Vnidrop.swift, and .build-core/. A clean checkout must run build-core.sh before generating or opening the Xcode project. VniDrop.xcodeproj itself is generated by XcodeGen from project.yml and does not need to be committed.

Build profile note

The default is debug. The workspace [profile.release] uses thin LTO, which the current macOS toolchain miscompiles into corrupt host proc-macro dylibs ("mis-aligned LINKEDIT string pool"). build-core.sh sets CARGO_PROFILE_DEV_STRIP=none (matching the existing Gobley Xcode run-script) so debug builds succeed. For a release core, disable LTO for proc-macros/build scripts (e.g. add a [profile.release.build-override] lto = false locally) — the Rust crate itself is never changed.

System frameworks

The Rust core (iroh network stack) links SystemConfiguration, Security, and libresolv. These are declared in project.yml for the app target.

Parity & scope

Screens mirror the Compose UI in shared/. Two deliberate simplifications:

  • Empty-state Lottie animations are rendered as SF Symbols (no lottie-ios dependency); swap in lottie-ios if exact-parity animation is required.
  • Bug reporting is stubbed behind BugReportService (NoopBugReportService) and a real transport lands in a later phase. There is no telemetry or crash auto-reporting.