Confirming a transfer/history deletion flashed the same confirmation alert a
second time before it went away. The destructive button runs confirmDelete
synchronously (setting isDeleting = true), while the alert's isPresented
dismiss binding fires asynchronously and then no-ops because its
`if !isDeleting` guard is already false — leaving the open flag set, so macOS
re-reads the binding as true and re-presents the alert until the async delete
finally clears it.
Close the confirmation flag synchronously in confirmDeleteTransfer /
confirmHistoryDelete so there's no window for re-presentation. Tests assert
the flag clears immediately, before the async delete completes.
Adds project-wide build settings (applied to every target) so they persist
in project.yml instead of the gitignored generated .xcodeproj:
- DEAD_CODE_STRIPPING: strip unreachable code from release binaries
- CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: flag user-facing strings that
aren't localized (the app ships 9 languages), surfaced during Analyze
Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:
- a receive finished downloading (receive -> done)
- a receive failed / was interrupted (receive -> failed)
- a share you own failed (send -> failed)
- a receiver finished downloading your share (receiver status completed)
A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.
Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.
macOS delivery fixes:
- add a UNUserNotificationCenterDelegate so banners present even while the
app is active (the app window is usually open on macOS)
- present-when-active on macOS, suppress-when-foregrounded on iOS
- reserve the notification id before awaiting publish: the CombineLatest
fired several times and re-added the same identifier, which macOS
coalesces into a silent update with no banner
- LocalNotificationService seeds its permission at init so gating can't
race a not-yet-refreshed .notDetermined
Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
Localizable.xcstrings and Generated/L10n.swift are generated from
localization/strings.json — the single source of truth — yet were committed,
which caused redundant tracking and a spurious ~10k-line diff every time
Xcode reformatted the catalog on build.
Treat them like the (already gitignored) Rust bindings: generate at build
time instead of tracking them. gitignore both; make the `apple-project`
target depend on `localization` so `bun run generate` recreates them before
xcodegen; install Bun in the Apple CI job and trigger it on localization/**.
Android strings.xml stays tracked — it has no reformatting churn and its
build doesn't run the generator.
The Cancel button on an in-progress receive did nothing. CoreRepository
funnelled every core call through one serial DispatchQueue, but `receive`
is a blocking core call that occupies that queue for the whole transfer.
The tapped `cancelTransfer` was enqueued behind the in-flight `receive` on
the same serial queue, so it could never run until `receive` returned —
which it never would, because it was waiting to be cancelled. A deadlock
the button couldn't escape.
The Rust core is explicitly designed for cancel to arrive from another
thread mid-receive (VnidropCore.block_on uses a shared runtime handle for
exactly this). Extracts the two-lane dispatch into a CoreDispatcher: a
serial lane for ordered calls and a separate concurrent lane for
interrupt-style calls, and routes cancel through the latter so the signal
reaches the core and unblocks the receive.
Adds CoreDispatcherTests, including a regression guard that an interrupt
completes while the serial lane is blocked.
Replaces the stringly-typed transfer-event phase/kind/direction values
throughout the progress-derivation logic with EventPhase, EventKind and
EventDirection enums (String-backed to match the core's wire values).
CoreEventModel keeps the raw wire strings as a faithful boundary DTO but
exposes typed eventPhase/eventKind/eventDirection accessors; all logic —
progressForTransfer/Receiver, humanProgressLabel, aggregateReceiverProgress,
the refresh trigger, and the SendScreen snapshots — now compares enum cases
instead of literals. TransferProgress.phase/kind are the enums directly, so
constructions read `phase: .transfer, kind: .progress`. The two ad-hoc
phase/kind Sets collapse into "is a recognized case" (non-nil) checks.
The Apple catalog carried four stringly-named passthrough keys ("%@",
"%@ · %@", "%@ %@ · %@", "%@%%") left over from the KMP port. They were
never referenced as keys — the composite strings were built inline with
hardcoded separators, so the middot/percent formatting wasn't localizable.
Renames them to proper semantic keys in strings.json:
- format_separated_pair(first:second:) "{first} · {second}"
- format_separated_triple(first:second:third:)
- battery_level_value(level:) "{level}%"
and drops the pure-identity "%@". Wires the inline compositions (size ·
status, count files · size, receivers pending · completed, battery level)
to the generated typed accessors. Catalog now validates with zero warnings.
Replaces every stringly-typed SF Symbol name with a compile-time-checked
SFSymbol case, mirroring the L10n accessor approach. A mistyped or
OS-unavailable symbol is now a build error instead of a silently blank
glyph at runtime.
Adds the SFSafeSymbols SPM package (project.yml) and migrates all call
sites: Image(systemName:)/Label(systemImage:) -> systemSymbol, and the
five symbol-carrying view properties (AppDestination.systemSymbol,
SettingsRow.icon, AboutPoint.symbol, MethodRow.icon, PolicyOption.icon)
flipped from String to SFSymbol end to end.
Replaces every stringly-typed localization key in the Apple app with
compile-time-checked accessors generated from localization/strings.json.
A mistyped key is now a build error instead of a silent fallback to the
raw key at runtime. The runtime path is unchanged: plain keys are
String.LocalizationValue constants resolved with String(localized:) and
Apple's String Catalog still does the lookup; keys with arguments become
typed, named functions applying args through String(format:).
Generator: new renderSwiftAccessors emits apple/VniDrop/Generated/L10n.swift,
wired into generate. Renamed generic arg1/arg2 tokens on four keys to
semantic names (receiver, transferName, deviceId) and updated their context
notes; positional output is unchanged so .xcstrings (bar the 4 comments)
and the Android XML regenerate identical.
Migration: every key-carrying value flipped to String.LocalizationValue
end to end, resolved only at the leaf. Zero key literals and zero
LocalizedStringKey remain in app or test code. macOS build passes; iOS
test run pending.
Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
The send flow stacked two .fileImporter modifiers on the same view (one
for files, one for folders). On iOS/macOS before 27, SwiftUI can't have
two presentation modifiers of the same kind on one view — the second
shadows the first, so toggling the files importer presented nothing and
"Choose files" appeared to do nothing. macOS/iOS 27 changed presentation
handling, which is why it worked there.
Collapse the two importers into a single .fileImporter that switches its
allowedContentTypes and allowsMultipleSelection based on whether a file
or folder pick is pending. Behavior is unchanged on 27 and now works on
26 and earlier.
Replace "раздача"/"раздаваться" (literally "seeding") with neutral
"общий доступ"/"отправка" phrasing across the Russian strings, to avoid
BitTorrent connotations for App Store review.
Translate all 253 user-facing strings to Russian (professional UI register;
count strings use number-neutral "label: {count}" form). transfer_file_count
uses full CLDR plural categories (one/few/many/other). Generates
values-ru/strings.xml for KMP, ru localizations in the Apple catalog, and adds
ru to CFBundleLocalizations. Completes the initial 8-language set.
Translate all 253 user-facing strings to Dutch (formal "u"), including the
transfer_file_count plural. Generates values-nl/strings.xml for KMP, nl
localizations in the Apple catalog, and adds nl to CFBundleLocalizations.
Translate all 253 user-facing strings to Polish (standard professional UI
register; gender-neutral phrasing; count strings use number-neutral "label:
{count}" form). transfer_file_count uses full CLDR plural categories
(one/few/many/other). Generates values-pl/strings.xml for KMP, pl localizations
in the Apple catalog, and adds pl to CFBundleLocalizations.
Translate all 253 user-facing strings to European Portuguese (pt-PT, formal;
EP vocabulary — ficheiro, guardar, Definições, partilhar). Generates
values-pt/strings.xml for KMP, pt localizations in the Apple catalog, and adds
pt to CFBundleLocalizations.
Translate all 253 user-facing strings to German (formal "Sie"; standard iOS
term conventions). Generates values-de/strings.xml for KMP, de localizations in
the Apple catalog, and adds de to CFBundleLocalizations.
Translate all 253 user-facing strings to Italian (formal "Lei"; standard iOS
button terms follow Apple conventions). Generates values-it/strings.xml for
KMP, it localizations in the Apple catalog, and adds it to CFBundleLocalizations.