Tapping an approval notification while the app was backgrounded crashed on iOS
with "Call must be made on main thread". The UNUserNotificationCenterDelegate
methods are `async` and nonisolated, so their continuation resumes off the main
thread at the return point — where UIKit synchronously runs state-restoration /
snapshot work, tripping the main-thread assertion. (The empty iOS `didReceive`
body didn't matter; even an empty async method returns off-main.)
Isolate NotificationPresenter to `@MainActor` so the delegate returns on the main
thread. `@preconcurrency` on the UNUserNotificationCenterDelegate conformance is
required because those requirements are nonisolated with non-Sendable UN*
parameters, which strict concurrency won't otherwise let a main-actor type
witness. The macOS branch's now-redundant `await MainActor.run { … }` is dropped.
Replace the free-form InvitationError.message(String) case with semantic
cases mapped to L10n keys at the UI boundary (Error.uiText), so user-facing
error text is localized instead of substring-matched from English blobs.
.raw(String) remains only for genuinely dynamic system/core messages.
Localize the CoreNFC alertMessage prompts via existing L10n keys, and add
SwiftLint rules (raw_alert_message, raw_invitation_error) to catch raw
alert strings and literal .raw("…") errors going forward.
Approval modal: since the Share/QR sheet auto-opens after creating a transfer,
it is always up when a receiver request arrives, and macOS silently drops a sheet
presented while another is still dismissing — so the approval sheet never appeared.
Drive the approval sheet from explicit state (not a constant binding) and, on
macOS, defer its presentation one dismiss-beat after closing the Share/QR sheet so
the hand-off is serialized. Still a non-dismissable sheet; iOS timing unchanged.
Sandboxed file sharing: the macOS picker released its security scope immediately,
so the core's later import failed with EPERM under the App Store sandbox (the
non-sandboxed .dmg was unaffected). Capture a security-scoped bookmark at pick
time and re-acquire access across shareFiles() — during which the core imports the
bytes — mirroring the receive-folder scoped-access pattern.
iOS suspends the process on backgrounding, freezing the core's network
threads so in-flight transfers stall and never fire notifications. Hold a
UIApplication background-task assertion (BackgroundActivityController) while
transfers/shares are active so iOS grants its grace window — long enough to
finish and notify. Released on foreground, on completion, or on expiration.
No UIBackgroundModes added (keeps App Store validation clean); macOS is a
no-op since it already runs unfocused.
Add a localized iOS-only Settings notice explaining the platform limit so it
doesn't read as a bug.
Add a UNUserNotificationCenterDelegate didReceive handler so tapping a notification
is handled inside the running app — activating and bringing the existing window
forward — instead of falling through to default launch behavior, which on macOS
can surface a second process. The approval/transfer UI is driven by core state, so
activating the window reveals any pending approval.
Brings in custom relays, relay connection policies, storage cache clearing, and
receiver-failure reporting. Apple-side conflict resolutions:
- CoreRepository: keep CoreDispatcher, adopt master's relay factory + network
transition guard, drop the now-unused serial queue.
- TransferDetailsView: keep the toolbar-share layout; adopt master's
invitationPresentation-based QR panel and the new .failed receiver case (typed).
- SettingsModel/SettingsScreen: typed L10n titleKey with master's .network case;
relay controls and the Free up space / storage redesign coexist.
- Add the missing transfer_receiver_failed localization key.
- Regenerate l10n from the merged strings.json; keep the Apple catalog untracked.
- Drop the notificationsEnabled test assertion (notifications preference was
intentionally removed on this branch).
progressForReceiver only labelled a receiver Completed when no progress/started
events preceded the completion, so the normal progress→completed sequence fell
through and rendered as Sending despite a .completed kind. Events are newest-first,
so a completed latest event is always terminal — label it Completed. Fixes the
failing ProgressDerivationTests.testReceiverCompletionAfterProgressIsTerminal.
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".
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.
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.
- Introduce a CoreGateway protocol so feature models depend on a seam
(CoreRepository conforms); enables faking the core in tests
- Add a VniDropTests target with 42 tests mirroring the KMP suites:
approval coordinator, send/receive/settings/app models, preferences,
file previews, invitation decode, message queue, error mapping
- Add a fake gateway/file-system/device-info and fixtures
- Add .github/workflows/apple.yml: build the Rust core, generate the
project, and run the tests on an iOS Simulator
Enable complete strict concurrency and switch the app target to Swift 6.
- Isolate model dependency protocols to @MainActor
- Make the CoreRepository blocking-FFI bridge race-free: nonisolated(unsafe)
core handle, nonisolated runCore/readSnapshot, @Sendable work block,
Sendable domain models
- Fix Binding method-reference captures; @preconcurrency imports for
CoreNFC/AVFoundation/VnidropCore; isolate the NFC/QR delegate helpers
- Settings: add Storage screen (size breakdown + delete-all-transfers) and
expand About (what it is/isn't, privacy & security, license/source)
- Move Report a bug to a toolbar sheet (cancel-only unless empty)
- Send progress: derive the list-row bar from receiver delivery status so it
clears once every receiver completes
- Fixes: iPad orientations, onChange(of:) iOS 17 API, weak-self captures,
invalid SF Symbol, macOS bug-report form labels; bump core build target to
match the app (18.2/15.0)
- Sender progress: aggregate only in-flight receivers so the bar clears on
completion and is order-independent ("Sending to N")
- Add Stop sharing and per-receiver Refuse (pending requests) on the sender
- Fix macOS: raise approval modal above the Share sheet; drive foreground
state off NSApplication so background notifications fire
- Persist app identity (display name, category) and signing team via
Info.plist / project.yml / gitignored Local.xcconfig
Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.