Creating a targeted transfer contacts the peer and only returns once the offer is answered or its timeouts expire — connection_timeout plus offer_wait_timeout, so minutes against a device that never answers. The composer disabled every control for that whole window, including Close, leaving no way out. Worse, the core records the row before it reaches out and leaves it in `failed` when the peer never replies, so giving up still produced a failure notification and a history entry for a send the user had already called off. Cancelling now reaches the core while that create is still running. The `created` lifecycle event carries the transfer id and is emitted before the peer is contacted, so the id is known in time; the cancel goes out through the interrupt lane, which exists precisely to reach a core busy inside another call. The transfer is then deleted, and its id is filtered out of the published list so a refresh racing the delete cannot leak it into history or into a notification. If the id has not arrived yet, the result carries it and the same cleanup runs on return. Picked sources are released only once the call lands, because the import owns them until then, and a generation counter keeps a late result from disturbing a newer send. Close and Cancel were also the same action under two labels. There is now one control: the sheet's cancellation item, reading Cancel while a send is waiting and Close otherwise, never disabled. Two further fixes from device testing: - Receive and Resume were gated on transfer state alone, so an approved outgoing transfer offered the sender a Receive button — an invitation to download the files it was uploading. Both pull into a local folder and are now receiver-only. - Renamed the deprecated `laptopcomputerAndIphone` symbol to `macbookAndIphone`; the deployment targets are well past where it was introduced, so no availability guard is needed. Adds a typed `targetedTransferId` accessor on CoreEventModel. This is a narrow, deliberate exception to the wake-up-only event rule in DESIGN-DEVICE-HISTORY.md §13: it takes the subject id and never state, and it exists because no query can answer while the create holds the serial lane — which is exactly when the user wants to cancel. Known gap: direction is inferred by comparing endpoint ids, because the binding does not expose the row's role. After an identity reset, rows predating it match neither endpoint, so past sends read as incoming from the device's own retired identity. Fixing that needs `role` on the core's TargetedTransfer.
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.dmgon GitHub Releases + Homebrew cask. Adds the Sparkle auto-updater behind theDIRECT_DISTRIBUTIONcompile 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-iosdependency); swap inlottie-iosif 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.